Skip to content

[fix](binlog) Write binlog records with their actual serialized length in the FE image - #68300

Open
Ryan19929 wants to merge 1 commit into
apache:masterfrom
Ryan19929:binlog-image-actual-length
Open

Ryan19929 wants to merge 1 commit into
apache:masterfrom
Ryan19929:binlog-image-actual-length

Conversation

@Ryan19929

@Ryan19929 Ryan19929 commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: none (found during internal investigation)

Problem Summary:

BinlogManager.writeTBinlogToStream persists every binlog record of the FE image with the capacity of its serialization buffer instead of the number of bytes actually serialized:

TMemoryBuffer buffer = new TMemoryBuffer(BUFFER_SIZE);   // BUFFER_SIZE = 16KB
TBinaryProtocol protocol = new TBinaryProtocol(buffer);
binlog.write(protocol);
byte[] data = buffer.getArray();   // the whole expanded backing array
dos.writeInt(data.length);         // capacity, not the valid byte count
dos.write(data);

libthrift's TMemoryBuffer.getArray() returns the backing array of its TByteArrayOutputStream — allocated at BUFFER_SIZE and grown by doubling — while length() is the number of valid bytes. Verified on both libthrift 0.16.0 and 0.24.0 (the version master uses today); the behaviour is identical.

Consequences:

  • every record occupies at least 4 + 16384 bytes in the image, whatever its real size. A DUMMY record — one per database and one per table that has produced a binlog, see DBBinlog.getAllBinlogs() — serializes to 52 bytes, so it should take 56 bytes of stream and instead takes 16388, 292x;
  • a record larger than 16KB is padded up to the next doubled capacity, so up to 2x;
  • on load, readTBinlogFromStream allocates a byte[] of that padded length for every record. Checkpoint.doCheckpoint() loads the image, saves the new one and then loads it again to validate it, so this happens twice per checkpoint, plus once on FE startup;
  • the master pushes the whole image file to every non-master frontend after each checkpoint.

Write buffer.length() bytes instead.

The bug has been present since #17881 (May 2023). This PR targets master; the same code is present on the release branches, so maintainers may want to consider it there as well.

It only materializes when enable_feature_binlog = true; with the default false, Env.saveBinlogs returns before writing anything, so this change is a no-op for those clusters.

Measurements

1. Real FE images. The binlogs module of FE images from three binlog-enabled test clusters, parsed offline (record count, bytes on disk, bytes actually needed):

image records on disk actual ratio
CCR source cluster 7 (6 DUMMY, 1 DROP_TABLE) 112.03 KB 556 B 206.3x
CCR destination cluster 4 (DUMMY) 64.02 KB 228 B 287.5x
a third binlog-enabled cluster 2 (DUMMY) 32.01 KB 116 B 282.6x

Every DUMMY record in all three images is 292.6x — 16388 bytes on disk for 56 bytes of stream — and the single DROP_TABLE is 75.9x, 16388 for 216. These clusters are idle and retain almost no real binlogs, so the absolute sizes are small; the ratios are the point.

2. How large is a binlog record, by type. The 16KB floor only dominates if real records are well below it, so each type was measured with its own record class, the same GsonUtils.GSON that BinlogManager.addXxxRecord uses, and the real TBinlog thrift encoding (record bytes include the 4 byte length prefix):

type shape record B < 16KB
DUMMY one per db, one per table with binlog 56 yes
DROP_TABLE the one found in the CCR source image above 216 yes
UPSERT 1 table, 1 partition, 1 tablet 478 yes
UPSERT 1 table, 1 partition, 32 tablets 974 yes
UPSERT 1 table, 4 partitions x 32 tablets 2990 yes
UPSERT 1 table, 16 partitions x 32 tablets 11054 yes
UPSERT 5 tables, 4 partitions x 32 tablets 14046 yes
CREATE_TABLE 5 / 50 / 200 columns 760 / 3135 / 11285 yes
ADD_PARTITION 32 buckets x 3 replicas 18602 no

UPSERT is emitted once per committed transaction, so it dominates the record population of any actively ingesting cluster, and DUMMY is one per database and per table. For an UPSERT to reach 16KB a single transaction has to touch about 92 partitions (1 tablet each), or 24 partitions x 32 tablets, or 8 partitions x 128 tablets — roughly 500 to 1000 tablets in one commit. A CREATE_TABLE reaches 16KB at about 293 columns.

ADD_PARTITION is the exception and is worth stating plainly: AddPartitionRecord embeds the whole Partition -> MaterializedIndex -> Tablet -> Replica tree, and crosses 16KB at about 28 buckets x 3 replicas, which is an ordinary table layout. Those records are still padded, just to the next doubled capacity rather than to 16KB, so the fix is worth ~2x for them instead of ~40x. They are also emitted once per partition creation rather than once per transaction.

3. A/B on a realistic workload. Records written to a real file with fsync, then read back through the released BinlogManager. JDK 17, one JVM per configuration. Image size and allocation are deterministic and came out byte-identical in every run; wall clock is given as the range over the runs, because the host is a shared and heavily loaded test machine.

5000 table dummies + 20 db dummies + 100000 UPSERT binlogs (~300 B json each), 105020 records — 6 runs

image bytes write ms read ms write alloc read alloc
before 1.60 GB 14923-16562 2131-2830 1.68 GB 1.76 GB
after 41.16 MB 933-1150 83-166 1.68 GB 162.71 MB
39.9x 13-18x 13-34x 1.0x 11.1x

20000 table dummies + 50 db dummies + 10000 UPSERT binlogs, 30050 records (dummy dominated) — 3 runs

image bytes write ms read ms write alloc read alloc
before 469.65 MB 5689-5886 1525-1663 487.89 MB 536.8 MB
after 5.15 MB 469-634 17-22 487.2 MB 30.91 MB
91.2x 9-13x 69-98x 1.0x 17.3x

Note the write-side allocation is unchanged (1.0x): this PR does not touch the TMemoryBuffer growth chain, only what gets written out of it. The read-side reduction comes from readTBinlogFromStream no longer allocating padded arrays.

4. Sensitivity to a partition-creation heavy workload. Starting from the measured run above (its 429 B average UPSERT record) and mixing in ADD_PARTITION records of 32 buckets x 3 replicas:

mix before after ratio
no ADD_PARTITION (the measured run) 1.60 GB 41.18 MB 39.9x
1 new partition/day for 10% of tables 1.62 GB 50.05 MB 33.1x
1 new partition/day for every table 1.76 GB 129.88 MB 13.8x
hourly partitions for every table 5.27 GB 2.12 GB 2.5x

The gain degrades gracefully with the share of large records and never turns negative.

The per-type and real-image numbers were produced against a released 4.0.5 binary, so the ADD_PARTITION row reflects that version's Partition serialization; the UPSERT record layout is unchanged on master.

Compatibility

The record framing is unchanged (int length + payload), no FeMetaVersion bump is needed, and writeTBinlogToStream has a single caller — the image write path. The CCR get_binlog RPC path is not involved.

Both directions were measured, not just argued, by feeding both formats to the BinlogManager of a released Doris 4.0.5 binary:

legacy (buffer capacity)   -> released 4.0.5 reader: OK, 105020 records, identical payloads: true
fixed  (actual length)     -> released 4.0.5 reader: OK, 105020 records, identical payloads: true
  • An old image read by a new FE: the padding sits after the thrift STOP field and is ignored by the generated TBinlog.read(); the read side is unchanged by this PR.
  • A new image read by an old FE: it is simply a sequence of shorter records.

Release note

None

Check List (For Author)

  • Test: Unit Test (BinlogManagerTest.testWriteTBinlogWithActualLength: writes a small and a larger-than-BUFFER_SIZE binlog, asserts that every record length in the stream equals the actual thrift serialized length rather than the buffer capacity, that the stream contains no trailing bytes, and that the binlogs still round trip). BinlogManagerTest is 7/7 green with this change. Reverting only the production change and keeping the test makes it fail with expected: <52> but was: <16384> at the per-record length assertion, i.e. it is a real regression test for this bug.
  • Behavior changed: No
  • Does this need documentation: No

🤖 Generated with Claude Code

…h in the FE image

BinlogManager.writeTBinlogToStream persists every binlog record of the FE image
with the capacity of its serialization buffer instead of the number of bytes
actually serialized. libthrift's TMemoryBuffer.getArray() returns the backing
array of its TByteArrayOutputStream, allocated at BUFFER_SIZE (16KB) and grown
by doubling, while length() is the number of valid bytes. Verified identical on
libthrift 0.16.0 and 0.24.0.

As a result every record occupies at least 4 + 16384 bytes in the image whatever
its real size: a DUMMY record, one per database and one per table that has
produced a binlog, serializes to 52 bytes, so it should take 56 bytes of stream
and instead takes 16388 (292x).
Records larger than 16KB are padded to the next doubled capacity, up to 2x. On
load, readTBinlogFromStream then allocates a byte[] of that padded length for
every record, which happens twice per checkpoint (Checkpoint.doCheckpoint loads
the image, saves the new one, and loads it again to validate it) plus once on
startup, and the master pushes the whole image to every non-master frontend
after each checkpoint.

Write buffer.length() bytes instead. Measured on a 105020 record workload, over
6 runs on a shared host: image 1.60GB -> 41.16MB (39.9x, byte identical in every
run), read side allocation 1.76GB -> 162.71MB (11.1x), write 14.9-16.6s ->
0.9-1.2s, read 2.1-2.8s -> 0.08-0.17s. Write side allocation is unchanged: this
does not touch the TMemoryBuffer growth chain, only what is written out of it.

Measuring each record type with its own record class and the GsonUtils.GSON that
BinlogManager uses: DUMMY is 56 B, a typical single partition UPSERT 478-974 B, and a
CREATE_TABLE stays under 16KB up to ~293 columns. An UPSERT only reaches 16KB when one
transaction touches ~92 partitions or 500-1000 tablets. The exception is ADD_PARTITION,
which embeds the whole Partition -> Tablet -> Replica tree and crosses 16KB at ~28
buckets x 3 replicas; those records are still padded, just to the next doubled capacity,
so the fix is worth ~2x for them rather than ~40x.

The record framing is unchanged (int length + payload) so no FeMetaVersion bump
is needed, and both formats were read back by the BinlogManager of a released
Doris 4.0.5 binary with identical payloads. Old images stay readable because the
padding sits after the thrift STOP field and is ignored by TBinlog.read().

Present since apache#17881 (May 2023), and the same code is on the release branches.
Only materializes when enable_feature_binlog is true; with the default false,
Env.saveBinlogs returns before writing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@Ryan19929

Copy link
Copy Markdown
Contributor Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 27237 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit b772398931ddd56c0d8ca4fdcc2140fa2b7d1009, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17722	3888	3783	3783
q2	2183	376	303	303
q3	10076	1359	812	812
q4	4682	471	345	345
q5	7459	841	551	551
q6	176	169	134	134
q7	731	782	601	601
q8	9296	1384	1575	1384
q9	5364	4176	4161	4161
q10	6833	1326	1022	1022
q11	424	267	249	249
q12	635	423	290	290
q13	18035	2619	2006	2006
q14	261	257	230	230
q15	q16	730	721	662	662
q17	1756	1162	942	942
q18	6489	5586	5509	5509
q19	1155	1214	1042	1042
q20	474	411	259	259
q21	5387	2961	2663	2663
q22	424	340	289	289
Total cold run time: 100292 ms
Total hot run time: 27237 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	4200	4113	4048	4048
q2	718	560	513	513
q3	4468	4831	4323	4323
q4	2216	2305	1441	1441
q5	4210	4078	4074	4074
q6	223	173	129	129
q7	1701	1641	1425	1425
q8	2197	1956	2334	1956
q9	7432	7245	7404	7245
q10	3712	3621	3200	3200
q11	560	395	367	367
q12	743	711	520	520
q13	2494	2809	2161	2161
q14	286	297	284	284
q15	q16	708	713	627	627
q17	7888	7210	7136	7136
q18	11914	11037	11787	11037
q19	1208	1106	1092	1092
q20	2277	2269	1961	1961
q21	5223	4524	4584	4524
q22	508	444	424	424
Total cold run time: 64886 ms
Total hot run time: 58487 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 151852 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit b772398931ddd56c0d8ca4fdcc2140fa2b7d1009, data reload: false

query5	4341	619	490	490
query6	437	218	203	203
query7	4840	576	304	304
query8	321	201	167	167
query9	8820	3934	3906	3906
query10	453	305	246	246
query11	5832	3551	3275	3275
query12	144	92	87	87
query13	1274	600	425	425
query14	6497	4503	4190	4190
query14_1	3959	3963	3932	3932
query15	203	194	180	180
query16	1003	448	419	419
query17	898	671	544	544
query18	2435	472	338	338
query19	199	186	139	139
query20	84	84	93	84
query21	225	132	113	113
query22	12962	12903	12714	12714
query23	13857	12988	12491	12491
query23_1	12639	12545	12403	12403
query24	7299	1142	607	607
query24_1	650	664	686	664
query25	536	415	354	354
query26	1135	293	157	157
query27	2679	534	330	330
query28	4533	1913	1914	1913
query29	1583	710	498	498
query30	292	216	179	179
query31	880	748	632	632
query32	140	89	89	89
query33	519	303	233	233
query34	1165	1105	646	646
query35	732	743	627	627
query36	775	813	706	706
query37	149	104	90	90
query38	1841	1756	1677	1677
query39	690	684	629	629
query39_1	645	652	659	652
query40	221	123	107	107
query41	74	71	63	63
query42	102	93	90	90
query43	332	344	294	294
query44	1355	703	709	703
query45	189	175	160	160
query46	1079	1168	722	722
query47	1484	1471	1389	1389
query48	389	365	297	297
query49	591	415	290	290
query50	927	334	243	243
query51	10404	10805	10617	10617
query52	86	85	81	81
query53	241	252	181	181
query54	257	200	181	181
query55	77	73	68	68
query56	228	223	221	221
query57	1491	1417	1310	1310
query58	274	259	252	252
query59	1963	2073	1858	1858
query60	275	238	226	226
query61	152	147	149	147
query62	402	318	267	267
query63	217	175	175	175
query64	2624	1002	837	837
query65	3458	3395	3411	3395
query66	1750	441	303	303
query67	20162	20146	19895	19895
query68	3321	1540	940	940
query69	402	301	257	257
query70	879	769	808	769
query71	303	236	215	215
query72	2665	2508	2165	2165
query73	781	764	441	441
query74	4641	4500	4256	4256
query75	2311	2248	1904	1904
query76	2341	1111	757	757
query77	354	391	299	299
query78	8943	8964	8375	8375
query79	1209	1116	675	675
query80	515	455	366	366
query81	518	320	278	278
query82	249	159	132	132
query83	211	217	185	185
query84	301	144	113	113
query85	780	461	375	375
query86	272	239	229	229
query87	1969	1954	1834	1834
query88	3587	2682	2676	2676
query89	317	281	236	236
query90	2114	178	171	171
query91	161	154	130	130
query92	101	89	91	89
query93	1381	1479	873	873
query94	526	321	306	306
query95	657	382	372	372
query96	1039	789	340	340
query97	2436	2427	2356	2356
query98	163	153	150	150
query99	731	732	632	632
Total cold run time: 234373 ms
Total hot run time: 151852 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 23.83 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit b772398931ddd56c0d8ca4fdcc2140fa2b7d1009, data reload: false

query1	0.00	0.00	0.00
query2	0.09	0.05	0.05
query3	0.26	0.14	0.14
query4	1.60	0.14	0.14
query5	0.24	0.22	0.22
query6	1.16	0.92	0.93
query7	0.04	0.01	0.01
query8	0.06	0.04	0.04
query9	0.38	0.33	0.34
query10	0.57	0.57	0.57
query11	0.19	0.13	0.14
query12	0.18	0.15	0.15
query13	0.46	0.46	0.47
query14	0.95	0.95	0.93
query15	0.59	0.57	0.58
query16	0.30	0.32	0.33
query17	1.07	1.08	1.05
query18	0.21	0.20	0.20
query19	2.02	1.90	1.88
query20	0.02	0.01	0.01
query21	15.44	0.21	0.14
query22	4.92	0.05	0.05
query23	16.14	0.31	0.12
query24	2.94	0.41	0.31
query25	0.11	0.04	0.04
query26	0.73	0.20	0.15
query27	0.05	0.03	0.03
query28	3.55	0.81	0.34
query29	12.48	4.06	3.21
query30	0.28	0.15	0.15
query31	2.77	0.58	0.30
query32	3.23	0.58	0.49
query33	3.24	3.14	3.19
query34	15.81	3.93	3.28
query35	3.21	3.20	3.20
query36	0.54	0.42	0.41
query37	0.08	0.06	0.06
query38	0.05	0.04	0.04
query39	0.03	0.03	0.03
query40	0.17	0.15	0.15
query41	0.09	0.03	0.02
query42	0.04	0.02	0.02
query43	0.04	0.03	0.04
Total cold run time: 96.33 s
Total hot run time: 23.83 s

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.

4 participants