Problem
Production workloads include full-table scans that compute MAX(CHAR_LENGTH(col)) on many string columns of a large table. In a few seconds TiFlash can read tens to hundreds of gigabytes, saturate cloud-disk read bandwidth, and stall other reads and writes on the same nodes.
Typical SQL:
SELECT
MAX(CHAR_LENGTH(c1)) AS c1,
MAX(CHAR_LENGTH(c2)) AS c2,
MAX(CHAR_LENGTH(c3)) AS c3,
-- ... a dozen or more string columns
MAX(CHAR_LENGTH(c16)) AS c16
FROM t;
The same pattern appears on other large tables, with anywhere from a few to more than ten columns. The intent is always “the maximum character length of each string column”.
Query characteristics
| Aspect |
Observation |
| Semantics |
Max character length per column; often used to size VARCHAR for downstream schemas |
| Access path |
TiFlash MPP full table scan, no WHERE / LIMIT |
| Column types |
All strings; a dozen-plus columns scanned together |
| Plan |
TableFullScan → Projection(char_length × N) → HashAgg(max × N) |
| Cadence |
Typically once per table, not a steady OLAP load |
| Resources |
Per-request Read RU often exceeds (10^6), peak over (7 \times 10^6) |
Observed scale of one run:
- About 1.8 billion rows, 16 columns, ~373 GB
- TableScan ~7 s; 7 TiFlash nodes (example: 32C / 256 GB, cloud-disk baseline read bandwidth 1250 MB/s)
- ~7.5 GB/s decompressed scan per node, above the instance’s disk read cap
Approximate per-node MPP stats:
| Operator |
Rows |
Outbound |
| TableScan |
~260 million |
~66 GB strings |
| Projection |
~260 million |
~38 GB Int64 |
| HashAgg |
1 |
144 B |
Why this SQL is expensive
The bottleneck is reading string columns from disk, not the aggregation.
-
Every value must be read; packs cannot be skipped
There is no filter, so late materialization and RS / MinMax do not help. MinMax stores lexicographic min/max of string values, which is unrelated to length, so packs that cannot refresh the global max still cannot be skipped.
-
CHAR_LENGTH must scan UTF-8 payload
TiFlash stores strings in DMFile as two streams: StringSizes (per-row length) and chars (payload).
LENGTH() uses offsets only; the function already ignores data.
CHAR_LENGTH() (utf8mb4) goes through lengthUTF8 and counts code points; TableScan still reads both streams.
-
Columnar layout does not help this query
Columnar storage avoids reading unused columns, but the selected columns are the fat string columns. Scanning a dozen of them in parallel saturates disk bandwidth.
-
Large intermediates
Projection materializes ~1.8 billion rows × 16 Int64s (~38 GB per node) before MAX. It pipelines with Scan, so wall time is still IO-bound, but extra CPU and memory are spent.
-
Instance bandwidth is saturated
373 GB / 7 nodes / ~7 s ≈ 7.5 GB/s decompressed per node. Disk baseline is 1.25 GB/s; even compressed reads overshoot, and other IO on the node (including wait-index) jitters.
Workarounds (application side, brief)
| Approach |
Notes |
| Decide whether a true global max is required |
Sampling or scanning hot partitions is often enough to size downstream VARCHAR |
| Rewrite SQL: fewer columns / batches |
Similar total IO, lower peak bandwidth |
| Resource Control |
Put these scans in one resource group and throttle. The query does not get faster, but the disk is less likely to saturate |
| Do not rely on adding a STORED generated column later |
TiDB currently rejects ALTER TABLE ADD COLUMN ... STORED (error 3106). VIRTUAL can be added, but TiFlash still expands it and reads the base strings |
| Materialize length only if the query is repeated |
Define INT AS (CHAR_LENGTH(c1)) STORED at CREATE TABLE, or maintain a regular INT column on the write path, and query MAX(length_col). The optimizer will not rewrite MAX(CHAR_LENGTH(c1)) to the generated column |
For a one-shot probe: prefer sampling / splitting columns / throttling. Do not change the table just to run the query once.
Proposed engine optimizations
Ordered by payoff for this SQL.
1. Pack-level max_len skip (highest priority)
Today
MinMaxIndex stores lexicographic string ranges. MAX(CHAR_LENGTH(col)) cannot skip packs from that.
Proposal
On pack write, also record max_byte_len / max_char_len (cost similar to existing MinMax). While scanning, keep a running max:
- If
pack_max_len <= running_max → skip the whole pack (including compressed chars)
- Otherwise read the pack and update the running max
Effect
After a few packs that contain longer strings, most remaining packs can be skipped. The more concentrated the length distribution, the more is skipped. This is the main way to turn a hundreds-of-GB full read into “pack metadata + a few packs”.
Notes
- Keep
CHAR_LENGTH and LENGTH stats separate (UTF-8 character count ≠ byte length), or store both.
- Delta / memtable still need a full scan or equivalent stats.
- Needs a new pack-stat format and a compatible reader.
2. Read StringSizes only, skip chars
Today
LengthImpl already uses offsets only; TableScan still reads both sizes and chars.
Proposal
Push “length only, no payload” down to the DMFile reader:
LENGTH(col): read StringSizes (and the nullmap), skip chars.
CHAR_LENGTH(col): utf8mb4 still needs chars by default; if a pack has an all_ascii flag (no high-bit bytes), ASCII packs can also read sizes only.
Effect
In the observation above, payload dominates the ~66 GB outbound per node; sizes are ~4 B/row. Reading sizes only can drop IO by an order of magnitude for those columns. This is general, not limited to MAX().
Notes
Scan / Projection must declare “this column needs length, not payload”. CHAR_LENGTH on non-ASCII packs must fall back to reading chars.
3. Fold CHAR_LENGTH into HashAgg
Today
The plan is Scan → Projection(char_length × N) → Agg(max × N). Projection emits Int64 columns for every row.
Proposal
Recognize MAX(CHAR_LENGTH(col)) / MAX(LENGTH(col)) and keep N running maxes inside Agg, without materializing all intermediate rows. Better: TableScan emits length columns directly and Agg only computes max.
Effect
Saves CPU / memory (~38 GB Projection outbound per node in the observation). Wall time barely changes when already IO-bound. Treat this as a companion to (1) / (2), not the fix for disk saturation.
4. Persist length in column metadata or a hidden column (optional)
Write length alongside the string (hidden column, or actually store a STORED generated column in TiFlash). MAX(length_col) becomes an integer-column scan, often an order of magnitude smaller after compression.
Large surface: replication decode, schema, file format, and alignment with TiDB generated-column semantics. TiDB cannot add STORED generated columns via ALTER TABLE; TiFlash inserts only a placeholder for VIRTUAL columns and still reads the base strings. After pack-level max_len skip, per-row length persistence is not required for this SQL; keep it as a longer-term, broader capability.
Not recommended as the primary engine fix
- Resource Control: protects the cluster; does not reduce IO of a single query.
- Rewriting to
LENGTH: close to CHAR_LENGTH on ASCII data, but TableScan still reads chars today, so only CPU is saved until (2) lands.
- Splitting into several queries with fewer columns: lowers peak, increases total time; a scheduling tactic.
Suggested order
- Pack-level
max_len skip
- Sizes-only scan
- Fold Projection into Agg as a companion
Problem
Production workloads include full-table scans that compute
MAX(CHAR_LENGTH(col))on many string columns of a large table. In a few seconds TiFlash can read tens to hundreds of gigabytes, saturate cloud-disk read bandwidth, and stall other reads and writes on the same nodes.Typical SQL:
The same pattern appears on other large tables, with anywhere from a few to more than ten columns. The intent is always “the maximum character length of each string column”.
Query characteristics
VARCHARfor downstream schemasWHERE/LIMITTableFullScan→Projection(char_length × N)→HashAgg(max × N)Observed scale of one run:
Approximate per-node MPP stats:
Why this SQL is expensive
The bottleneck is reading string columns from disk, not the aggregation.
Every value must be read; packs cannot be skipped
There is no filter, so late materialization and RS / MinMax do not help. MinMax stores lexicographic min/max of string values, which is unrelated to length, so packs that cannot refresh the global max still cannot be skipped.
CHAR_LENGTHmust scan UTF-8 payloadTiFlash stores strings in DMFile as two streams:
StringSizes(per-row length) andchars(payload).LENGTH()uses offsets only; the function already ignoresdata.CHAR_LENGTH()(utf8mb4) goes throughlengthUTF8and counts code points; TableScan still reads both streams.Columnar layout does not help this query
Columnar storage avoids reading unused columns, but the selected columns are the fat string columns. Scanning a dozen of them in parallel saturates disk bandwidth.
Large intermediates
Projection materializes ~1.8 billion rows × 16 Int64s (~38 GB per node) before
MAX. It pipelines with Scan, so wall time is still IO-bound, but extra CPU and memory are spent.Instance bandwidth is saturated
373 GB / 7 nodes / ~7 s ≈ 7.5 GB/s decompressed per node. Disk baseline is 1.25 GB/s; even compressed reads overshoot, and other IO on the node (including wait-index) jitters.
Workarounds (application side, brief)
VARCHARALTER TABLE ADD COLUMN ... STORED(error 3106). VIRTUAL can be added, but TiFlash still expands it and reads the base stringsINT AS (CHAR_LENGTH(c1)) STOREDatCREATE TABLE, or maintain a regular INT column on the write path, and queryMAX(length_col). The optimizer will not rewriteMAX(CHAR_LENGTH(c1))to the generated columnFor a one-shot probe: prefer sampling / splitting columns / throttling. Do not change the table just to run the query once.
Proposed engine optimizations
Ordered by payoff for this SQL.
1. Pack-level
max_lenskip (highest priority)Today
MinMaxIndexstores lexicographic string ranges.MAX(CHAR_LENGTH(col))cannot skip packs from that.Proposal
On pack write, also record
max_byte_len/max_char_len(cost similar to existing MinMax). While scanning, keep a running max:pack_max_len <= running_max→ skip the whole pack (including compressedchars)Effect
After a few packs that contain longer strings, most remaining packs can be skipped. The more concentrated the length distribution, the more is skipped. This is the main way to turn a hundreds-of-GB full read into “pack metadata + a few packs”.
Notes
CHAR_LENGTHandLENGTHstats separate (UTF-8 character count ≠ byte length), or store both.2. Read
StringSizesonly, skipcharsToday
LengthImplalready uses offsets only; TableScan still reads both sizes and chars.Proposal
Push “length only, no payload” down to the DMFile reader:
LENGTH(col): readStringSizes(and the nullmap), skipchars.CHAR_LENGTH(col): utf8mb4 still needscharsby default; if a pack has anall_asciiflag (no high-bit bytes), ASCII packs can also read sizes only.Effect
In the observation above, payload dominates the ~66 GB outbound per node; sizes are ~4 B/row. Reading sizes only can drop IO by an order of magnitude for those columns. This is general, not limited to
MAX().Notes
Scan / Projection must declare “this column needs length, not payload”.
CHAR_LENGTHon non-ASCII packs must fall back to readingchars.3. Fold
CHAR_LENGTHintoHashAggToday
The plan is
Scan → Projection(char_length × N) → Agg(max × N). Projection emits Int64 columns for every row.Proposal
Recognize
MAX(CHAR_LENGTH(col))/MAX(LENGTH(col))and keep N running maxes inside Agg, without materializing all intermediate rows. Better: TableScan emits length columns directly and Agg only computes max.Effect
Saves CPU / memory (~38 GB Projection outbound per node in the observation). Wall time barely changes when already IO-bound. Treat this as a companion to (1) / (2), not the fix for disk saturation.
4. Persist length in column metadata or a hidden column (optional)
Write length alongside the string (hidden column, or actually store a STORED generated column in TiFlash).
MAX(length_col)becomes an integer-column scan, often an order of magnitude smaller after compression.Large surface: replication decode, schema, file format, and alignment with TiDB generated-column semantics. TiDB cannot add STORED generated columns via
ALTER TABLE; TiFlash inserts only a placeholder for VIRTUAL columns and still reads the base strings. After pack-levelmax_lenskip, per-row length persistence is not required for this SQL; keep it as a longer-term, broader capability.Not recommended as the primary engine fix
LENGTH: close toCHAR_LENGTHon ASCII data, but TableScan still readscharstoday, so only CPU is saved until (2) lands.Suggested order
max_lenskip