Skip to content

Commit ecaeb56

Browse files
dnovitskiclaude
andcommitted
Speed up parallel row-copy: overlap range calc, drop per-batch barrier, cut per-chunk round-trips
The --chunk-concurrent-size parallel row-copy only ran the INSERTs in parallel; the boundary calculation and the per-chunk transaction overhead serialized work and capped the achievable speedup well below the hardware's parallel-insert ceiling. This addresses three of those caps. Prefetch range producer (overlap serialized boundary calc with INSERTs): - A single dedicated producer goroutine is the sole caller of CalculateNextIterationRangeEndValues and streams pre-computed ranges into a buffered channel, so boundary scans now overlap the parallel INSERTs of earlier work instead of stalling between batches. - Split iterateChunks into iterateChunksSingle (unchanged single-threaded semantics) and iterateChunksConcurrent. - Size the applier pool for concurrentSize + producer + headroom. #1 Per-chunk round-trips (applier.go): - ApplyIterationInsertQuery sent BEGIN / SET SESSION / INSERT / COMMIT as four round-trips per chunk. It now sends "SET SESSION ...; INSERT ..." as a single autocommit, multi-statement round-trip on one pinned connection. The applier pool already enables multiStatements + interpolateParams + autocommit; RowsAffected() reports the INSERT (last statement), and the optional SHOW WARNINGS runs on the same pinned connection. 4 round-trips -> 1. #2 Persistent worker pool (migrator.go): - Replace the per-batch errgroup+g.Wait barrier (which stalled N workers on the slowest chunk every N chunks) with continuous dispatch to an errgroup bounded by SetLimit(concurrentSize) for a 200ms time quantum. Workers stay saturated; the only barrier is at the quantum boundary. The time bound keeps executeWriteFuncs returning to apply binlog events and re-check throttling, preserving row-copy/event mutual exclusion. Checkpoints record the last contiguous completed range (not the producer's prefetched cursor), so resume restarts from fully-copied data. Benchmarked on MySQL 8.0.46 (innodb_autoinc_lock_mode=2), 2.1M rows: copy time vs the prior parallel impl improved up to 32% (chunk=200, conc=4: 22s->15s; chunk=1000, conc=8: 8s->6s). Data integrity verified by row count + checksum. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de32943 commit ecaeb56

3 files changed

Lines changed: 278 additions & 137 deletions

File tree

doc/command-line-flags.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ See also: [`resuming-migrations`](resume.md)
8484

8585
When set to a value greater than 1, multiple chunks are calculated and copied in parallel within each write-function invocation. This can significantly speed up row-copy on large tables when MySQL can handle concurrent writes to the ghost table.
8686

87-
Each concurrent chunk calculates its own non-overlapping key range under a serialization lock, so there is no risk of duplicate or overlapping copies.
87+
Each concurrent chunk calculates its own non-overlapping key range under a serialization lock, so there is no risk of duplicate or overlapping copies. A single dedicated producer goroutine streams these pre-calculated ranges to a pool of copy workers that run continuously (rather than in fixed barrier-synchronized batches), so the serialized boundary calculation overlaps with the parallel `INSERT`s and a slow chunk does not stall the others. Each chunk also applies its session variables and `INSERT` in a single autocommit round-trip, avoiding the per-chunk `BEGIN`/`SET SESSION`/`COMMIT` overhead. The applier connection pool is sized to `chunk-concurrent-size + 1 (producer) + headroom` automatically.
88+
89+
For the speedup to materialize, MySQL should allow concurrent inserts to scale: on MySQL 8.0+ the default `innodb_autoinc_lock_mode = 2` (interleaved) is required for tables with an `AUTO_INCREMENT` column — under mode 0/1 an `INSERT ... SELECT` holds a table-level AUTO-INC lock that serializes concurrent chunks.
8890

8991
Note: concurrency multiplies write pressure per time slot. Throttling (`--max-load`, `--nice-ratio`) applies per batch, not per chunk. Start with small values (2-8) and monitor replication lag.
9092

go/logic/applier.go

Lines changed: 61 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,14 @@ func (apl *Applier) InitDBConnections() (err error) {
116116
return err
117117
}
118118
concurrentSize := atomic.LoadInt64(&apl.migrationContext.ChunkConcurrentSize)
119-
if concurrentSize > int64(mysql.MaxDBPoolConnections) {
120-
apl.db.SetMaxOpenConns(int(concurrentSize) + mysql.MaxDBPoolConnections)
121-
apl.db.SetMaxIdleConns(int(concurrentSize) + mysql.MaxDBPoolConnections)
119+
if concurrentSize > 1 {
120+
// Size the pool for concurrentSize parallel chunk-INSERTs plus the dedicated
121+
// range-producer connection, with MaxDBPoolConnections of additional headroom
122+
// for other applier queries. Without this, small concurrency values (2, 3)
123+
// would contend with the producer for a connection and serialize.
124+
poolSize := int(concurrentSize) + 1 + mysql.MaxDBPoolConnections
125+
apl.db.SetMaxOpenConns(poolSize)
126+
apl.db.SetMaxIdleConns(poolSize)
122127
}
123128
singletonApplierUri := fmt.Sprintf("%s&timeout=0", applierUri)
124129
if apl.singletonDB, _, err = mysql.GetDB(apl.migrationContext.Uuid, singletonApplierUri); err != nil {
@@ -958,6 +963,13 @@ func (apl *Applier) CalculateNextIterationRangeEndValues(advanceCursor bool) (va
958963

959964
// ApplyIterationInsertQuery issues a chunk-INSERT query on the ghost table. It is where
960965
// data actually gets copied from original table.
966+
//
967+
// The session variables (time_zone, sql_mode) and the chunk INSERT are sent as a single
968+
// multi-statement, autocommit round-trip on one pinned connection. The applier pool sets
969+
// multiStatements + interpolateParams + autocommit, so this avoids the extra
970+
// BEGIN / SET SESSION / COMMIT round-trips an explicit transaction would add to every
971+
// chunk — the dominant per-chunk overhead at small chunk sizes. `RowsAffected()` reports
972+
// the last statement's count (the INSERT), so the returned row count stays correct.
961973
func (apl *Applier) ApplyIterationInsertQuery(iterationRangeValues *base.IterationRangeValues) (chunkSize int64, rowsAffected int64, duration time.Duration, warnings []string, err error) {
962974
startTime := time.Now()
963975
chunkSize = iterationRangeValues.Size
@@ -981,60 +993,31 @@ func (apl *Applier) ApplyIterationInsertQuery(iterationRangeValues *base.Iterati
981993
return chunkSize, rowsAffected, duration, nil, err
982994
}
983995

996+
sessionQuery := fmt.Sprintf(`SET SESSION time_zone = '%s', %s`,
997+
apl.migrationContext.ApplierTimeZone, apl.generateSqlModeQuery())
998+
combinedQuery := sessionQuery + "; " + query
999+
1000+
ctx := apl.migrationContext.GetContext()
9841001
sqlResult, sqlWarnings, err := func() (gosql.Result, []string, error) {
985-
tx, err := apl.db.Begin()
1002+
// Pin a single connection so the optional SHOW WARNINGS observes this INSERT's
1003+
// warnings (and not another pooled query's).
1004+
conn, err := apl.db.Conn(ctx)
9861005
if err != nil {
9871006
return nil, nil, err
9881007
}
989-
defer tx.Rollback()
990-
991-
sessionQuery := fmt.Sprintf(`SET SESSION time_zone = '%s'`, apl.migrationContext.ApplierTimeZone)
992-
sessionQuery = fmt.Sprintf("%s, %s", sessionQuery, apl.generateSqlModeQuery())
1008+
defer conn.Close()
9931009

994-
if _, err := tx.Exec(sessionQuery); err != nil {
995-
return nil, nil, err
996-
}
997-
result, err := tx.Exec(query, explodedArgs...)
1010+
result, err := conn.ExecContext(ctx, combinedQuery, explodedArgs...)
9981011
if err != nil {
9991012
return nil, nil, err
10001013
}
10011014

10021015
var collectedWarnings []string
10031016
if apl.migrationContext.PanicOnWarnings {
1004-
rows, err := tx.Query("SHOW WARNINGS")
1005-
if err != nil {
1006-
return nil, nil, err
1007-
}
1008-
defer rows.Close()
1009-
if err = rows.Err(); err != nil {
1010-
return nil, nil, err
1011-
}
1012-
1013-
// Compile regex once before loop to avoid performance penalty and handle errors properly
1014-
migrationKeyRegex, err := apl.compileMigrationKeyWarningRegex()
1017+
collectedWarnings, err = apl.collectChunkInsertWarnings(ctx, conn)
10151018
if err != nil {
10161019
return nil, nil, err
10171020
}
1018-
1019-
for rows.Next() {
1020-
var level, message string
1021-
var code int
1022-
if err := rows.Scan(&level, &code, &message); err != nil {
1023-
apl.migrationContext.Log.Warningf("Failed to read SHOW WARNINGS row")
1024-
continue
1025-
}
1026-
if strings.Contains(message, "Duplicate entry") && migrationKeyRegex.MatchString(message) {
1027-
continue
1028-
}
1029-
collectedWarnings = append(collectedWarnings, fmt.Sprintf("%s: %s (%d)", level, message, code))
1030-
}
1031-
if err := rows.Err(); err != nil {
1032-
return nil, nil, err
1033-
}
1034-
}
1035-
1036-
if err := tx.Commit(); err != nil {
1037-
return nil, nil, err
10381021
}
10391022
return result, collectedWarnings, nil
10401023
}()
@@ -1054,6 +1037,41 @@ func (apl *Applier) ApplyIterationInsertQuery(iterationRangeValues *base.Iterati
10541037
return chunkSize, rowsAffected, duration, warnings, nil
10551038
}
10561039

1040+
// collectChunkInsertWarnings runs SHOW WARNINGS on the given (pinned) connection right
1041+
// after a chunk INSERT and returns the warnings, skipping the benign duplicate-key
1042+
// warnings that INSERT IGNORE produces on the migration unique key.
1043+
func (apl *Applier) collectChunkInsertWarnings(ctx context.Context, conn *gosql.Conn) ([]string, error) {
1044+
rows, err := conn.QueryContext(ctx, "SHOW WARNINGS")
1045+
if err != nil {
1046+
return nil, err
1047+
}
1048+
defer rows.Close()
1049+
1050+
// Compile regex once before loop to avoid performance penalty and handle errors properly
1051+
migrationKeyRegex, err := apl.compileMigrationKeyWarningRegex()
1052+
if err != nil {
1053+
return nil, err
1054+
}
1055+
1056+
var collectedWarnings []string
1057+
for rows.Next() {
1058+
var level, message string
1059+
var code int
1060+
if err := rows.Scan(&level, &code, &message); err != nil {
1061+
apl.migrationContext.Log.Warningf("Failed to read SHOW WARNINGS row")
1062+
continue
1063+
}
1064+
if strings.Contains(message, "Duplicate entry") && migrationKeyRegex.MatchString(message) {
1065+
continue
1066+
}
1067+
collectedWarnings = append(collectedWarnings, fmt.Sprintf("%s: %s (%d)", level, message, code))
1068+
}
1069+
if err := rows.Err(); err != nil {
1070+
return nil, err
1071+
}
1072+
return collectedWarnings, nil
1073+
}
1074+
10571075
// LockOriginalTable places a write lock on the original table
10581076
func (apl *Applier) LockOriginalTable() error {
10591077
query := fmt.Sprintf(`lock /* gh-ost */ tables %s.%s write`,

0 commit comments

Comments
 (0)