Skip to content

Commit 908d561

Browse files
dnovitskiCopilot
andcommitted
Parallel row-copy, DML merge, frontier filter, and heartbeat lag throttle (#2)
Performance optimizations for gh-ost that significantly speed up row-copy under high write load while keeping binlog lag bounded: - Parallel row-copy with dedicated connection pool and time-bounded drain - DML event merging within batches (INSERT/DELETE cancellation, UPDATE folding) - Frontier filter to skip DML events beyond copy frontier - Heartbeat lag throttle (--copy-max-lag-millis) for row-copy pacing - Adaptive drain budget and auto-tuning chunk size - Runtime-changeable --copy-concurrency and --copy-max-lag-millis - Fix multithreaded replication data inconsistency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 154d214 commit 908d561

30 files changed

Lines changed: 2819 additions & 2368 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
/.vendor/
55
.idea/
66
*.tmp
7+
gh-ost

doc/command-line-flags.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@ password=123456
9292

9393
Defaults to `true`. See [`exact-rowcount`](#exact-rowcount)
9494

95+
### copy-concurrency
96+
97+
Number of concurrent row-copy goroutines. Default: `1` (original single-threaded behavior). Range: `1-32`.
98+
99+
Higher values speed up row-copy under heavy write load by using parallel `INSERT...SELECT` operations on non-overlapping ranges. Each worker copies a different chunk concurrently, and a bounded drain budget ensures DML events are still processed between copy batches.
100+
101+
When `--copy-concurrency` > 1, the [heartbeat lag throttle](#copy-max-lag-millis) is automatically active to prevent unbounded binlog lag growth.
102+
103+
This value can be changed at runtime via [interactive commands](interactive-commands.md).
104+
105+
Example: `--copy-concurrency=4` — uses 4 parallel copy workers.
106+
107+
### copy-max-lag-millis
108+
109+
Maximum allowed *internal* binlog processing lag (HeartbeatLag) in milliseconds before row-copy is paused. Default: `60000` (60 seconds). Set to `0` to disable.
110+
111+
This is only active when `--copy-concurrency` > 1. When HeartbeatLag exceeds this threshold, row-copy pauses and gh-ost drains the binlog event queue exclusively until lag drops to half the threshold (hysteresis prevents oscillation).
112+
113+
**Differs from [`--max-lag-millis`](#max-lag-millis):** `--max-lag-millis` measures *replica* replication lag and throttles the entire migration. `--copy-max-lag-millis` measures gh-ost's *own* binlog processing lag and only pauses row-copy while DML apply continues at full speed.
114+
115+
This value can be changed at runtime via [interactive commands](interactive-commands.md).
116+
117+
See [Throttle: Copy-specific lag throttle](throttle.md#copy-specific-lag-throttle---copy-max-lag-millis) for a detailed comparison.
118+
95119
### critical-load
96120

97121
Comma delimited status-name=threshold, same format as [`--max-load`](#max-load).
@@ -200,6 +224,16 @@ See [`initially-drop-ghost-table`](#initially-drop-ghost-table)
200224

201225
Default False. Should `gh-ost` forcibly delete an existing socket file. Be careful: this might drop the socket file of a running migration!
202226

227+
### copy-max-lag-millis
228+
229+
When using parallel row-copy (`--copy-concurrency` > 1), this flag sets the maximum allowed *internal* binlog processing lag (HeartbeatLag) before row-copy is paused. Unlike `--max-lag-millis` which measures replica lag and throttles the entire migration, `--copy-max-lag-millis` only pauses row-copy while DML event processing continues at full speed.
230+
231+
Default: `60000` (60 seconds). Set to `0` to disable (maximum copy speed, unbounded lag).
232+
233+
Row-copy resumes when HeartbeatLag drops to half the threshold (hysteresis prevents oscillation).
234+
235+
See [Throttle: Copy-specific lag throttle](throttle.md#copy-specific-lag-throttle---copy-max-lag-millis) for a detailed comparison with `--max-lag-millis`.
236+
203237
### max-lag-millis
204238

205239
On a replication topology, this is perhaps the most important migration throttling factor: the maximum lag allowed for migration to work. If lag exceeds this value, migration throttles.
@@ -252,6 +286,28 @@ Defaults to an auto-determined and advertised upon startup file. Defines Unix so
252286

253287
By default `gh-ost` verifies no foreign keys exist on the migrated table. On servers with large number of tables this check can take a long time. If you're absolutely certain no foreign keys exist (table does not reference other table nor is referenced by other tables) and wish to save the check time, provide with `--skip-foreign-key-checks`.
254288

289+
### skip-dml-frontier-filter
290+
291+
Disable the frontier-based DML skip optimization. Default: `false` (optimization is enabled).
292+
293+
When enabled (default), gh-ost skips DML events targeting rows that have not yet been copied — since row-copy will capture the latest value when it reaches those rows. This reduces redundant DML apply work during the copy phase.
294+
295+
**Safety constraints** — the frontier filter is automatically disabled in two situations regardless of this flag:
296+
297+
1. **When `--copy-concurrency` > 1**: With parallel row-copy, multiple chunks are being copied concurrently. The frontier (last completed chunk boundary) is not a reliable boundary because in-flight chunks may not have committed yet. A DML event targeting a row in an in-flight chunk could be incorrectly skipped, causing data loss.
298+
299+
2. **In replica modes** (`--test-on-replica`, `--migrate-on-replica`): In replica mode, gh-ost reads binlog events from the replica's relay log. These events may be ahead of the replica's SQL thread apply position — meaning row-copy `SELECT` queries may not yet see the data from the skipped events. This would cause silent data loss since neither the DML apply nor the row-copy would capture those changes.
300+
301+
Use `--skip-dml-frontier-filter` for benchmarking or if you suspect the optimization is causing issues.
302+
303+
### skip-dml-merge
304+
305+
Disable DML event merging within batches. Default: `false` (merging is enabled).
306+
307+
When enabled (default), gh-ost merges redundant DML events for the same row before applying them. For example, an INSERT followed by multiple UPDATEs to the same row becomes a single INSERT with the final values. Under high write load, this can reduce applied statements by ~36%.
308+
309+
Use `--skip-dml-merge` for benchmarking or if you suspect merging is causing issues.
310+
255311
### skip-metadata-lock-check
256312

257313
By default `gh-ost` performs a check before the cut-over to ensure the rename session holds the exclusive metadata lock on the table. In case `performance_schema.metadata_locks` cannot be enabled on your setup, this check can be skipped with `--skip-metadata-lock-check`.
@@ -333,3 +389,11 @@ Makes the _old_ table include a timestamp value. The _old_ table is what the ori
333389
### tungsten
334390

335391
See [`tungsten`](cheatsheet.md#tungsten) on the cheatsheet.
392+
393+
### workers
394+
395+
Number of concurrent workers for applying DML events. Default: `8`. Each worker uses one goroutine and its own database connection to apply binlog events in parallel.
396+
397+
This controls the parallelism of the multithreaded replication (MTR) subsystem which processes binlog DML events. Higher values can improve DML throughput under heavy write load, but beyond 8-16 workers the gains diminish as contention on the ghost table increases.
398+
399+
Example: `--workers=16` — uses 16 parallel DML apply workers.

doc/interactive-commands.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Both interfaces may serve at the same time. Both respond to simple text command,
2424
- `chunk-size=<newsize>`: modify the `chunk-size`; applies on next running copy-iteration
2525
- `dml-batch-size=<newsize>`: modify the `dml-batch-size`; applies on next applying of binary log events
2626
- `max-lag-millis=<max-lag>`: modify the maximum replication lag threshold (milliseconds, minimum value is `100`, i.e. `0.1` second)
27+
- `copy-concurrency=<N>`: modify the number of parallel row-copy workers (range 1-32). Setting to `1` switches to single-threaded legacy behavior. Takes effect on the next copy iteration.
28+
- `copy-max-lag-millis=<millis>`: modify the heartbeat lag threshold for copy throttling (milliseconds). Set `0` to disable. Takes effect immediately on the next drain check.
2729
- `max-load=<max-load-thresholds>`: modify the `max-load` config; applies on next running copy-iteration
2830
- The `max-load` format must be: `some_status=<numeric-threshold>[,some_status=<numeric-threshold>...]`'
2931
- For example: `Threads_running=50,threads_connected=1000`, and you would then write/echo `max-load=Threads_running=50,threads_connected=1000` to the socket.

doc/throttle.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ In addition to the above, you are able to take control and throttle the operatio
7676
echo no-throttle | nc -U /tmp/gh-ost.test.sample_data_0.sock
7777
```
7878

79+
### Copy-specific lag throttle (--copy-max-lag-millis)
80+
81+
When using parallel row-copy (`--copy-concurrency` > 1), gh-ost uses a bounded drain strategy that gives row-copy more execution turns. This can cause the binlog processing to fall behind, resulting in increasing "HeartbeatLag" — the time since the last heartbeat event was *processed* from the binlog stream.
82+
83+
`--copy-max-lag-millis` (default: `60000`, i.e. 60 seconds) sets a threshold for how far behind binlog processing may fall during the row-copy phase. When HeartbeatLag exceeds this threshold, row-copy is paused and gh-ost drains the binlog event queue exclusively until lag drops to half the threshold (hysteresis to prevent oscillation).
84+
85+
**How it differs from `--max-lag-millis`:**
86+
87+
| | `--max-lag-millis` | `--copy-max-lag-millis` |
88+
|---|---|---|
89+
| **What it measures** | Replication lag on downstream replicas (via heartbeat table queries or `SHOW SLAVE STATUS`) | Binlog processing lag *within gh-ost itself* (wall-clock time since last heartbeat event was read from the binlog stream) |
90+
| **What it throttles** | The entire migration — all reads and writes pause | Only row-copy — DML event processing continues at full speed |
91+
| **When it matters** | Always — protects replicas from falling behind | Only with parallel row-copy (`--copy-concurrency` > 1) |
92+
| **Default** | 1500ms (strict — sub-second replica lag) | 60000ms (permissive — allows some internal lag buildup for speed) |
93+
| **Resume behavior** | Resumes immediately when lag drops below threshold | Resumes when lag drops to threshold/2 (hysteresis) |
94+
95+
**Why both are needed:** `--max-lag-millis` protects *downstream replicas* from excessive replication lag. `--copy-max-lag-millis` protects against *gh-ost's own internal lag* — which only occurs with parallel row-copy because the bounded drain budget gives copy more turns at the expense of binlog processing. With single-copy mode (the default), `ProcessEventsUntilDrained()` blocks indefinitely so internal lag never accumulates.
96+
97+
Set `--copy-max-lag-millis=0` to disable the throttle entirely (maximum copy speed, unbounded lag growth).
98+
7999
### Throttle precedence
80100

81101
Any single factor in the above that suggests the migration should throttle - causes throttling. That is, once some component decides to throttle, you cannot override it; you cannot force continued execution of the migration.

go/base/context.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,23 @@ type MigrationContext struct {
176176
CutOverType CutOver
177177
ReplicaServerId uint
178178

179+
// Number of workers used by the trx coordinator
180+
NumWorkers int
181+
182+
// Number of concurrent row-copy goroutines (default 1 = legacy behavior)
183+
CopyConcurrency int64
184+
185+
// SkipDMLMerge disables DML event merging within batches
186+
SkipDMLMerge bool
187+
188+
// SkipDMLFrontierFilter disables the frontier-based DML skip optimization
189+
SkipDMLFrontierFilter bool
190+
191+
// CopyMaxLagMillis is the heartbeat lag threshold (in ms) at which row-copy
192+
// is paused to let binlog processing catch up. Only applies when copy-concurrency > 1.
193+
// Default 60000 (60s). Set to 0 to disable lag-based copy throttling.
194+
CopyMaxLagMillis int64
195+
179196
Hostname string
180197
AssumeMasterHostname string
181198
ApplierTimeZone string

go/binlog/gomysql_reader.go

Lines changed: 33 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
/*
22
Copyright 2022 GitHub Inc.
3-
See https://github.com/github/gh-ost/blob/master/LICENSE
3+
See https://github.com/github/gh-ost/blob/master/LICENSE
44
*/
55

66
package binlog
77

88
import (
9+
"context"
910
"fmt"
1011
"sync"
12+
"time"
1113

1214
"github.com/github/gh-ost/go/base"
1315
"github.com/github/gh-ost/go/mysql"
14-
"github.com/github/gh-ost/go/sql"
1516

16-
"time"
17-
18-
"context"
1917
gomysql "github.com/go-mysql-org/go-mysql/mysql"
2018
"github.com/go-mysql-org/go-mysql/replication"
2119
uuid "github.com/google/uuid"
@@ -85,53 +83,17 @@ func (gmr *GoMySQLReader) GetCurrentBinlogCoordinates() mysql.BinlogCoordinates
8583
return gmr.currentCoordinates.Clone()
8684
}
8785

88-
func (gmr *GoMySQLReader) handleRowsEvent(ev *replication.BinlogEvent, rowsEvent *replication.RowsEvent, entriesChannel chan<- *BinlogEntry) error {
89-
currentCoords := gmr.GetCurrentBinlogCoordinates()
90-
dml := ToEventDML(ev.Header.EventType.String())
91-
if dml == NotDML {
92-
return fmt.Errorf("unknown DML type: %s", ev.Header.EventType.String())
93-
}
94-
for i, row := range rowsEvent.Rows {
95-
if dml == UpdateDML && i%2 == 1 {
96-
// An update has two rows (WHERE+SET)
97-
// We do both at the same time
98-
continue
86+
// StreamEvents reads binlog events and sends them to the given channel.
87+
// It is blocking and should be executed in a goroutine.
88+
func (gmr *GoMySQLReader) StreamEvents(ctx context.Context, canStopStreaming func() bool, eventChannel chan<- *replication.BinlogEvent) error {
89+
for {
90+
if canStopStreaming() {
91+
return nil
9992
}
100-
binlogEntry := NewBinlogEntryAt(currentCoords)
101-
binlogEntry.DmlEvent = NewBinlogDMLEvent(
102-
string(rowsEvent.Table.Schema),
103-
string(rowsEvent.Table.Table),
104-
dml,
105-
)
106-
switch dml {
107-
case InsertDML:
108-
{
109-
binlogEntry.DmlEvent.NewColumnValues = sql.ToColumnValues(row)
110-
}
111-
case UpdateDML:
112-
{
113-
binlogEntry.DmlEvent.WhereColumnValues = sql.ToColumnValues(row)
114-
binlogEntry.DmlEvent.NewColumnValues = sql.ToColumnValues(rowsEvent.Rows[i+1])
115-
}
116-
case DeleteDML:
117-
{
118-
binlogEntry.DmlEvent.WhereColumnValues = sql.ToColumnValues(row)
119-
}
93+
if err := ctx.Err(); err != nil {
94+
return err
12095
}
121-
122-
// The channel will do the throttling. Whoever is reading from the channel
123-
// decides whether action is taken synchronously (meaning we wait before
124-
// next iteration) or asynchronously (we keep pushing more events)
125-
// In reality, reads will be synchronous
126-
entriesChannel <- binlogEntry
127-
}
128-
return nil
129-
}
130-
131-
// StreamEvents
132-
func (gmr *GoMySQLReader) StreamEvents(canStopStreaming func() bool, entriesChannel chan<- *BinlogEntry) error {
133-
for !canStopStreaming() {
134-
ev, err := gmr.binlogStreamer.GetEvent(context.Background())
96+
ev, err := gmr.binlogStreamer.GetEvent(ctx)
13597
if err != nil {
13698
return err
13799
}
@@ -153,45 +115,38 @@ func (gmr *GoMySQLReader) StreamEvents(canStopStreaming func() bool, entriesChan
153115

154116
switch event := ev.Event.(type) {
155117
case *replication.GTIDEvent:
156-
if !gmr.migrationContext.UseGTIDs {
157-
continue
158-
}
159-
sid, err := uuid.FromBytes(event.SID)
160-
if err != nil {
161-
return err
162-
}
163-
gmr.currentCoordinatesMutex.Lock()
164-
if gmr.LastTrxCoords != nil {
165-
gmr.currentCoordinates = gmr.LastTrxCoords.Clone()
118+
if gmr.migrationContext.UseGTIDs {
119+
sid, err := uuid.FromBytes(event.SID)
120+
if err != nil {
121+
return err
122+
}
123+
gmr.currentCoordinatesMutex.Lock()
124+
if gmr.LastTrxCoords != nil {
125+
gmr.currentCoordinates = gmr.LastTrxCoords.Clone()
126+
}
127+
coords := gmr.currentCoordinates.(*mysql.GTIDBinlogCoordinates)
128+
trxGset := gomysql.NewUUIDSet(sid, gomysql.Interval{Start: event.GNO, Stop: event.GNO + 1})
129+
coords.GTIDSet.AddSet(trxGset)
130+
gmr.currentCoordinatesMutex.Unlock()
166131
}
167-
coords := gmr.currentCoordinates.(*mysql.GTIDBinlogCoordinates)
168-
trxGset := gomysql.NewUUIDSet(sid, gomysql.Interval{Start: event.GNO, Stop: event.GNO + 1})
169-
coords.GTIDSet.AddSet(trxGset)
170-
gmr.currentCoordinatesMutex.Unlock()
171132
case *replication.RotateEvent:
172-
if gmr.migrationContext.UseGTIDs {
173-
continue
133+
if !gmr.migrationContext.UseGTIDs {
134+
gmr.currentCoordinatesMutex.Lock()
135+
coords := gmr.currentCoordinates.(*mysql.FileBinlogCoordinates)
136+
coords.LogFile = string(event.NextLogName)
137+
gmr.migrationContext.Log.Infof("rotate to next log from %s:%d to %s", coords.LogFile, int64(ev.Header.LogPos), event.NextLogName)
138+
gmr.currentCoordinatesMutex.Unlock()
174139
}
175-
gmr.currentCoordinatesMutex.Lock()
176-
coords := gmr.currentCoordinates.(*mysql.FileBinlogCoordinates)
177-
coords.LogFile = string(event.NextLogName)
178-
gmr.migrationContext.Log.Infof("rotate to next log from %s:%d to %s", coords.LogFile, int64(ev.Header.LogPos), event.NextLogName)
179-
gmr.currentCoordinatesMutex.Unlock()
180140
case *replication.XIDEvent:
181141
if gmr.migrationContext.UseGTIDs {
182142
gmr.LastTrxCoords = &mysql.GTIDBinlogCoordinates{GTIDSet: event.GSet.(*gomysql.MysqlGTIDSet)}
183143
} else {
184144
gmr.LastTrxCoords = gmr.currentCoordinates.Clone()
185145
}
186-
case *replication.RowsEvent:
187-
if err := gmr.handleRowsEvent(ev, event, entriesChannel); err != nil {
188-
return err
189-
}
190146
}
191-
}
192-
gmr.migrationContext.Log.Debugf("done streaming events")
193147

194-
return nil
148+
eventChannel <- ev
149+
}
195150
}
196151

197152
func (gmr *GoMySQLReader) Close() error {

go/cmd/gh-ost/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ func main() {
112112
flag.BoolVar(&migrationContext.PanicOnWarnings, "panic-on-warnings", false, "Panic when SQL warnings are encountered when copying a batch indicating data loss")
113113
cutOverLockTimeoutSeconds := flag.Int64("cut-over-lock-timeout-seconds", 3, "Max number of seconds to hold locks on tables while attempting to cut-over (retry attempted when lock exceeds timeout) or attempting instant DDL")
114114
niceRatio := flag.Float64("nice-ratio", 0, "force being 'nice', imply sleep time per chunk time; range: [0.0..100.0]. Example values: 0 is aggressive. 1: for every 1ms spent copying rows, sleep additional 1ms (effectively doubling runtime); 0.7: for every 10ms spend in a rowcopy chunk, spend 7ms sleeping immediately after")
115+
flag.IntVar(&migrationContext.NumWorkers, "workers", 8, "Number of concurrent workers for applying DML events. Each worker uses one goroutine.")
116+
flag.Int64Var(&migrationContext.CopyConcurrency, "copy-concurrency", 1, "Number of concurrent row-copy goroutines. Higher values speed up row-copy under write load by using parallel INSERT...SELECT operations on non-overlapping ranges. (range 1-32)")
117+
flag.BoolVar(&migrationContext.SkipDMLMerge, "skip-dml-merge", false, "Disable DML event merging within batches (for benchmarking)")
118+
flag.BoolVar(&migrationContext.SkipDMLFrontierFilter, "skip-dml-frontier-filter", false, "Disable frontier-based DML skip optimization (for benchmarking)")
119+
flag.Int64Var(&migrationContext.CopyMaxLagMillis, "copy-max-lag-millis", 60000, "Heartbeat lag (ms) at which row-copy pauses to let binlog catch up. Only active with --copy-concurrency > 1. Set 0 to disable. Default 60s balances copy speed vs lag growth.")
115120

116121
maxLagMillis := flag.Int64("max-lag-millis", 1500, "replication lag at which to throttle operation")
117122
replicationLagQuery := flag.String("replication-lag-query", "", "Deprecated. gh-ost uses an internal, subsecond resolution query")

0 commit comments

Comments
 (0)