Skip to content

sources: Speed up MySQL ingestion by parallelizing#35569

Closed
def- wants to merge 2 commits into
MaterializeInc:mainfrom
def-:pr-pg-mysql-speedup
Closed

sources: Speed up MySQL ingestion by parallelizing#35569
def- wants to merge 2 commits into
MaterializeInc:mainfrom
def-:pr-pg-mysql-speedup

Conversation

@def-

@def- def- commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

On a cluster with 8 workers, running on my 8 core / 16 thread dev server:

$ bin/mzcompose --find feature-benchmark down && bin/mzcompose --find feature-benchmark --release run default --scenario=MySqlInitialLoadMultiWorker --scale=+1
[...]
NAME                                | TYPE            |      THIS       |      OTHER      |  UNIT  | 'THIS' is
---------------------------------------------------------------------------------------------------------------------------------
MySqlInitialLoadMultiWorker         | wallclock       |           2.042 |          11.416 |   s    |    better:  5.6 times faster
MySqlInitialLoadMultiWorker         | memory_mz       |        1213.754 |        1019.012 |   MB   |    worse:  19.1% more

Co-written with Claude 🤖

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for opening this PR! Here are a few tips to help make the review process smooth for everyone.

PR title guidelines

  • Use imperative mood: "Fix X" not "Fixed X" or "Fixes X"
  • Be specific: "Fix panic in catalog sync when controller restarts" not "Fix bug" or "Update catalog code"
  • Prefix with area if helpful: compute: , storage: , adapter: , sql:

Pre-merge checklist

  • The PR title is descriptive and will make sense in the git log.
  • This PR has adequate test coverage / QA involvement has been duly considered. (trigger-ci for additional test/nightly runs)
  • If this PR includes major user-facing behavior changes, I have pinged the relevant PM to schedule a changelog post.
  • This PR has an associated up-to-date design doc, is a design doc (template), or is sufficiently small to not require a design.
  • If this PR evolves an existing $T ⇔ Proto$T mapping (possibly in a backwards-incompatible way), then it is tagged with a T-proto label.
  • If this PR will require changes to cloud orchestration or tests, there is a companion cloud PR to account for those changes that is tagged with the release-blocker label (example).

@def-
def- force-pushed the pr-pg-mysql-speedup branch 3 times, most recently from 9c63b2a to f7effd1 Compare March 20, 2026 14:17
@def- def- changed the title Try to speed up Postgres/MySQL ingestion Try to speed up MySQL ingestion Mar 20, 2026
@def-
def- force-pushed the pr-pg-mysql-speedup branch 10 times, most recently from 2ee8d42 to 2e9f0da Compare March 22, 2026 13:04
@def- def- changed the title Try to speed up MySQL ingestion Speed up MySQL ingestion by parallelizing Mar 22, 2026
@def- def- changed the title Speed up MySQL ingestion by parallelizing sources: Speed up MySQL ingestion by parallelizing Mar 23, 2026
@def-
def- force-pushed the pr-pg-mysql-speedup branch 4 times, most recently from db20263 to f97084a Compare March 26, 2026 14:40
@def-
def- marked this pull request as ready for review March 26, 2026 16:44
@def-
def- requested a review from a team as a code owner March 26, 2026 16:44
@def-
def- force-pushed the pr-pg-mysql-speedup branch from f97084a to 96687b4 Compare April 20, 2026 00:00
@def-
def- force-pushed the pr-pg-mysql-speedup branch from 96687b4 to 792128d Compare May 27, 2026 13:02
@def-
def- requested a review from a team as a code owner May 27, 2026 13:02
@def-
def- requested a review from martykulma May 27, 2026 16:12
@def-
def- force-pushed the pr-pg-mysql-speedup branch 2 times, most recently from 11d7b39 to a7eed48 Compare June 19, 2026 07:10
Partition each table's primary-key range across timely workers so they
read disjoint PK ranges of the initial snapshot concurrently, reducing
initial-load time for large tables with a single-column integer primary
key. A snapshot leader establishes the consistent point and broadcasts
SnapshotInfo to all workers over a timely feedback loop; each worker then
reads its range under a CONSISTENT SNAPSHOT transaction. Tables without a
suitable PK fall back to single-worker-per-table mode. Also adds a
MySqlInitialLoadMultiWorker feature benchmark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@def-
def- force-pushed the pr-pg-mysql-speedup branch from a7eed48 to b064ebc Compare July 8, 2026 07:52
Generalize PK-range snapshot partitioning beyond single-column integer
primary keys to also cover non-integer (char/varchar/text) and composite
PKs, so large tables keyed by strings (e.g. ULIDs) or multi-column keys
also load their initial snapshot in parallel across workers instead of
falling back to a single worker.

Single-column integer PKs keep the cheap MIN/MAX split. Other supported PKs
sample partition boundaries by keyset pagination over the primary-key index
(one forward pass), reading each boundary key back already rendered as a SQL
literal (QUOTE() for text, CAST(.. AS CHAR) for numeric) so range predicates
compare under each column's own collation, matching the ORDER BY used to
pick them. Composite keys use row-value comparisons. Every boundary is found
with a strict `pk > prev`, so boundaries strictly increase and the resulting
half-open ranges are disjoint and cover the entire key domain: each row is
read by exactly one worker regardless of data distribution.

Boundary computation runs before LOCK TABLES so the O(rows) sampling scan for
non-integer PKs does not extend the table-lock window; correctness does not
depend on boundaries matching the exact snapshot point.

Also fix a snapshot duplication bug: when a partitioned table has fewer
partitions than the cluster has workers, the worker responsible for the table
could fall through to the `responsible_for` path and read the whole table in
addition to the range-owning workers, duplicating every row. A read plan now
distinguishes range-owning workers, the single responsible worker of an
unpartitioned table, and a responsible worker that owns no range (which reads
nothing but still emits the table's rewind request).

Adds char and composite PK correctness cases to mysql-cdc.td and a
test/mysql-pk-benchmark composition that demonstrates the speedup under a
bandwidth-limited MySQL link (the regime where parallel range reads help).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@def-

def- commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Now also implementation and testing for non-integer and composite keys, numbers from this CI run: https://buildkite.com/materialize/nightly/builds/17115:

NAME                                   | TYPE            |      THIS       |      OTHER      |  UNIT  | THRESHOLD  |  Regression?  | 'THIS' is
-----------------------------------------------------------------------------------------------------------------------------------------------------------
MySqlInitialLoadMultiWorker            | wallclock       |           0.619 |           1.854 |   s    |    10%     |      no       | better:  3.0 times faster
MySqlInitialLoadMultiWorker            | memory_mz       |        1017.441 |         952.398 |   MB   |    60%     |      no       | worse:   6.8% more
MySqlInitialLoadMultiWorker            | memory_clusterd |          69.797 |         105.539 |   MB   |    60%     |      no       | better: 33.9% less
MySqlInitialLoadMultiWorkerCharPk      | wallclock       |           0.801 |           2.011 |   s    |    10%     |      no       | better:  2.5 times faster
MySqlInitialLoadMultiWorkerCharPk      | memory_mz       |        1135.566 |         947.895 |   MB   |    60%     |      no       | worse:  19.8% more
MySqlInitialLoadMultiWorkerCharPk      | memory_clusterd |         139.102 |         142.965 |   MB   |    60%     |      no       | better:  2.7% less
MySqlInitialLoadMultiWorkerCompositePk | wallclock       |           0.991 |           2.033 |   s    |    10%     |      no       | better:  2.1 times faster
MySqlInitialLoadMultiWorkerCompositePk | memory_mz       |        1064.559 |         931.352 |   MB   |    60%     |      no       | worse:  14.3% more
MySqlInitialLoadMultiWorkerCompositePk | memory_clusterd |          97.602 |          98.371 |   MB   |    60%     |      no       | better:  0.8% less

@peterdukelarsen

peterdukelarsen commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Ran with parallelism 2:

CI: https://buildkite.com/materialize/nightly/builds/17324/list?jid=019f6167-c9db-4e4f-8011-689748bc539b&tab=output

commit tweaking test: 5517b8a

NAME                                   | TYPE            |      THIS       |      OTHER      |  UNIT  | THRESHOLD  |  Regression?  | 'THIS' is
---------------------------------------------------------------------------------------------------------------------------------------------------------
MySqlInitialLoadMultiWorkerCharPk      | wallclock       |           1.369 |           2.284 |   s    |    10%     |      no       | better: 40.0% faster
MySqlInitialLoadMultiWorkerCharPk      | memory_mz       |         931.797 |         863.172 |   MB   |    60%     |      no       | worse:   8.0% more
MySqlInitialLoadMultiWorkerCharPk      | memory_clusterd |         157.047 |         138.918 |   MB   |    60%     |      no       | worse:  13.1% more

MySqlInitialLoadMultiWorkerCompositePk | wallclock       |           1.166 |           2.020 |   s    |    10%     |      no       | better: 42.3% faster
MySqlInitialLoadMultiWorkerCompositePk | memory_mz       |         894.367 |         870.566 |   MB   |    60%     |      no       | worse:   2.7% more
MySqlInitialLoadMultiWorkerCompositePk | memory_clusterd |         106.297 |          87.359 |   MB   |    60%     |      no       | worse:  21.7% more

@peterdukelarsen peterdukelarsen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's simplify this a bit by focusing just on a single-column primary key and using just one method for computing partitions (the one that supports the char(26) type). Let's flesh out the serde testing for the various types a little more aggressively to try to root out issues with round-tripping the stringified value -- including testing different character sets.

let row: Option<MySqlRow> = conn
.query_first(format!(
"SELECT {cols_literal} FROM {table}{predicate} \
ORDER BY {cols_ident} LIMIT 1 OFFSET {offset}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the offset results in an O(offset) scan, so across all partitions you'll have an O(N) scan here. Discussed with some S&S folks and we don't think this is a strict blocker because the decoding is a big part.

return (
"CREATE TABLE pk_table "
"(region CHAR(2) NOT NULL, id BIGINT NOT NULL, f2 BIGINT, "
"PRIMARY KEY (region, id));"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be interesting to try:

PRIMARY KEY (region ASC, id DESC));

I suspect this would cripple performance when running ORDER BY region, id (which I think defaults to ORDER BY region ASC, id ASC) on a large table because it will trigger a Filesort.

f"--binlog-row-metadata={binlog_row_metadata}",
f"--server-id={server_id}",
"--max-connections=500",
f"--max-connections={max_connections}",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any sense of how risky this is for customer mysqls? Maybe we want a docs change here. I did a little legwork and most clusters cap out at about 64 workers.

//! We have therefore succeeded in starting a transaction at a known point in time!
//! For tables with a suitable primary key, the leader computes `worker_count - 1` boundary keys
//! that split the key domain into disjoint half-open ranges, and broadcasts them. Each worker
//! reads only its assigned range. A single-column integer PK uses a cheap `MIN(pk)`/`MAX(pk)`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should remove this feature for now and stick with the table-scanning approach for simplicity. The table-scanning approach will be much more resilient to uneven distributions. Let's leave adding this as an optional optimization to consider in the future.

}

/// Classify a scalar type for range splitting, or `None` if unsupported.
fn pk_col_kind(scalar_type: &SqlScalarType) -> Option<PkColKind> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why stick to such a limited set of types?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a little claude research and it sounds like float/double conversions to string could be lossy. DECIMAL should work. BINARY and similar types likely won't cast cleanly to a String. ENUM may not compare the same way and could be risky. There were some other types considered too, but generally makes sense that round-tripping to String is not guaranteed to work for all types and that there may be more complex concerns than just round-tripping to String for other types (like ENUMs).

Would be interested if there was a way we could avoid round-tripping to a string to make support easier to extend correctly? i.e. can we keep it in the Value type of the my_sql library?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving to the Value type definitely not required and might lose us some protection of string conversion -- namely enforcing UTF8. Regardless, we should test with a couple of different charsets to make sure this is resilient to that -- or at least isn't a regression.

.iter()
.map(|(c, kind)| match kind {
PkColKind::Numeric => format!("CAST({c} AS CHAR)"),
PkColKind::Text => format!("QUOTE({c})"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid to the string casting and round-trip of strings by holding onto the typed value as a parameter? This will add some complexity to the PkSplits broadcast because now we'll need to serde the full type rather than a simple string, but it could make supporting other types simpler and less error prone.

// stay correct for any data distribution (see `PkSplits`).
// `None` means single-worker fallback.
let mut pk_bounds_map = BTreeMap::new();
for (table, outputs) in &tables_to_snapshot {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's open up a transaction for the computation of all of the partitioned tables here and make sure it's not closed until after the locks are acquired, the other workers have snapshotted, and the locks are released.

The concern driving this suggestion is around collation changes to the char primary key. If collation is altered between computing the partition boundaries and executing our snapshot there's a small risk we could have duplicate or dropped data.

let splits = if config.worker_count < 2 {
None
} else if let Some(pk_col) = integer_pk_col(desc) {
compute_integer_splits(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the linear cost of reading these tables being imposed on the leader, I'm slightly concerned we could have a regression in snapshot performance, i.e. imagine the case where snapshotting a small table would have been executed successfully within a minute or two but is now blocked behind computing the partitions for the largest table.

One option for improving on this could be some version of concurrency control locally since all the work happens in mysql, i.e. we could submit up to #workers of parallel tasks to compute the partitions which would bring us closer to historical performance.

.responsible_for(table)
.then_some(ReadPlan::RewindOnly),
},
_ => config

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should error on the None case here because we insert something for each table up top.

)
.await?;
// Phase G: The leader publishes the full snapshot size so the summed
// worker-local gauges reflect the upstream total without double-counting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code style nit: I have found these phases somewhat useful, but I wonder if we could get similar benefit with less verbose comments by factoring out some functions to function a little smaller

@def-

def- commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of #37642

@def- def- closed this Jul 16, 2026
peterdukelarsen added a commit that referenced this pull request Jul 24, 2026
### Motivation

Taking over implementation of
#35569.

For customers with large tables in MySQL the initial snapshot can be
painfully slow. We can improve performance by distributing the initial
snapshot across workers.

### Description

Previously we would:
1. Partition ownership of tables across workers
2. Each worker would take out a read lock on the tables it owned, grab
the gtid, open a txn and then release the readlock to let us get the
gtid of the transaction
3. Copy the entirety of the tables it owned one at a time

The problem with this is when one table is huge we are as slow as the
single worker copying the whole table.

Now the MySQL snapshot logic at a high level will be:
1. Have a leader compute partitions for tables where possible that can
be shared across workers (more on this later)
2. The leader takes a read lock on all tables to snapshot, grabs the
current GTID, then broadcasts the gtid and partitions
3. All workers including the leader will connect to mysql and open
transactions while the tables are locked, then drop their capability to
indicate to the leader they've opened the snapshot
4. The leader will drop the read locks at this point unblocking writes
to the source tables
5. All workers will perform snapshots of the tables or sections of
tables they own

The leader will parallelize the partition computation using
multithreading. The partition computation is much faster than actually
reading the full snapshot, but it is slow and scales with the table
size. The leader will step through the table along the primary key sort
order using offsets of `1 / # workers` to compute partitions of
approximately the same size to use for partitioning the table.

### Verificiation

- Testdrive tests

### Benchmark Performance

Some basic benchmarks:
https://buildkite.com/materialize/nightly/builds/17371/list. First two
show it hasn't gotten slower for many tables while the last demonstrates
the case of a single large table being sped up. This shows a 2.7x
speedup on a large table without a slowdown on many tables (although
it's possible there could be a slowdown of ~10% on perfectly even
distribution of tables due to the extra work of partitioning).

```

NAME                                   | TYPE            |      THIS       |      OTHER      |  UNIT  | THRESHOLD  |  Regression?  | 'THIS' is
--------------------------------------------------------------------------------------------------------------------------------------------
MySqlInitialLoadMultiWorkerSampled     | wallclock       |           2.834 |           3.389 |   s    |    10%     |      no       | better: 16.4% faster

MySqlInitialLoadMultiWorkerSampled     | memory_mz       |        1379.387 |        1106.102 |   MB   |    60%     |      no       | worse:  24.7% more

MySqlInitialLoadMultiWorkerSampled     | memory_clusterd |          86.762 |          64.301 |   MB   |    60%     |      no       | worse:  34.9% more


MySqlInitialLoadMultiWorkerSingleTable | wallclock       |           0.813 |           2.233 |   s    |    10%     |      no       | better:  2.7 times faster

MySqlInitialLoadMultiWorkerSingleTable | memory_mz       |        1125.410 |         958.266 |   MB   |    60%     |      no       | worse:  17.4% more

MySqlInitialLoadMultiWorkerSingleTable | memory_clusterd |         216.004 |         213.613 |   MB   |    60%     |      no       | worse:   1.1% more
```

### Known trade-offs/risk areas
1. Snapshotting may be ~10-20% slower for an existing workload if the
work is well-distributed. Walking the tables to compute partition
boundaries is an extra step that only improves wallclock performance
when there are underutilized workers.
2. MySQL may experience more sustained periods of high load in a
customer environment due to the additional table scan up-front and due
to the better utilization of workers on previously skewed workloads.
3. Table read locks will be held for longer. The read locks are now all
taken out by the leader and transactions need to be started by all
workers before we release that read lock. This increases the duration
during which the read locks are held to the slowest worker to start a
transaction. It also decreases the granularity of read locking, so could
result in some tables being locked for longer while we're waiting to
acquire the lock on more actively written to tables.
4. For places where we weren't fully utilizing the worker count -- i.e.
16 workers but 2 tables -- we'll be increasing the number of connections
to mysql when we distribute the snapshot load across workers. MySQL by
default has a relatively low max_connection count and Materialize will
fail to connect if MySQL is saturated, potentially resulting in a retry
loop.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants