Ryan19929 opened a new pull request, #68300:
URL: https://github.com/apache/doris/pull/68300

   ### 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:
   
   ```java
   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](https://claude.com/claude-code)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to