背景:同一篇,百万行透视表。上篇讲了应用层砍掉 30 万个 Proxy。这篇讲存储层------把行式对象数组换成列式 TypedArray,100 万行从 132MB 散落堆压到 25MB 连续内存,groupBy 从 709ms 降到 326ms。
本文会展开到 V8 隐藏类、HeapNumber 装箱、CPU cache line 这一层。会讲清楚"为什么列式快"不是一句"连续内存快",而是三重开销的消除。
这是这个系列的四篇里的第二篇,讲的是存储层------把行式对象数组换成列式连续内存。第一篇是应用层(Vue 响应式),后面还有线程层和布局层各一篇。
一、一个反直觉的事实
先看两段代码,都是对 100 万行做累加:
js
// A:对象数组(行式)
for (let i = 0; i < n; i++) {
group[sums[s]] += data[i][sums[s]] || 0;
}
// B:TypedArray(列式)
for (let i = 0; i < n; i++) {
g[s] += sumCols[s][i];
}
A 和 B 的源码看起来几乎一样。但实测 A 跑 709ms,B 跑 326ms------差 2.2 倍。
这 2.2 倍从哪来?不是算法差异,是每一轮循环里 V8 和 CPU 干的额外工作不一样。
二、V8 里一个 {} 到底长什么样
先搞清楚 data[i] 是什么。
js
const row = {
styleNo: "12225202133-3-1",
qty: 10.5,
lineNo: 1,
};
在 V8 堆里,这个对象不是一块连续内存。它由三部分组成:
scss
┌─────────────────────────────┐
│ 隐藏类指针 (Hidden Class) │ ← 记录有哪些属性、每个属性的位置
├─────────────────────────────┤
│ in-object 槽位 │ ← styleNo, qty, lineNo 直接存在这里
│ (按隐藏类定义的 offset) │
├─────────────────────────────┤
│ properties 数组 (可能外部) │ ← 溢出的属性
├─────────────────────────────┤
│ elements 数组 │ ← 数组索引元素
└─────────────────────────────┘
2.1 隐藏类是什么
V8 内部把隐藏类叫 Map(不是 JS 的 Map,是内部数据结构)。每个对象有一个隐藏类指针,描述这个对象有哪些属性、每个属性存在哪个 offset。
关键机制:transition tree。同样属性、同样添加顺序的对象共享同一个隐藏类。如果两个对象的属性添加顺序不同,V8 会沿 transition tree 新建隐藏类,不会合并。
这意味着:如果后端下发的 30 万行数据,每行的字段顺序不完全一致(常见于 JSON 解析后的对象),它们会分散在多个隐藏类上。
2.2 为什么这会影响性能
V8 有一个内联缓存(Inline Cache,IC)优化:连续访问同一个字段时,IC 会记住"上次这个隐藏类的 qty 在 offset 5",下次直接跳过查表步骤。
但 IC 的优化前提是 monomorphic------调用点遇到的隐藏类越单一越好。多隐藏类会让 IC 退化成 polymorphic 甚至 megamorphic,每次访问都要重新查 DescriptorTable 而不是直接取缓存 offset。
问题在于:30 万行数据,每行的隐藏类可能不同(字段顺序、属性数量不完全一致),IC 命中率下降。而且每个对象是独立分配的堆块,地址散落在堆里。
即便 IC 完全命中,属性访问也要:查隐藏类 → 定位 offset → 从对象内存块读值。对比 typed array 的 arr[i]:基地址 + i × 元素字节长度 → 一条 mov 指令。中间差了至少两次间接寻址和一次查表。
2.3 两种存储方式的内存布局对比
用一张图来对比对象数组和 typed array 在内存里的样子:
kotlin
对象数组(行式)------ 每个对象是独立堆块,散落在堆各处
┌──────────────────┐
│ data[0] @0x1000 │ ┌─ 隐藏类指针 (8B)
│ │ ├─ in-object 槽 (32B): styleNo, qty, lineNo...
│ │ └─ properties (可能外部)
└──────────────────┘
┌──────────────────┐
│ data[1] @0x5000 │ ← 与 data[0] 相距 4KB,不在同一 cache line
│ │
└──────────────────┘
┌──────────────────┐
│ data[2] @0x9000 │ ← 又是另一块
└──────────────────┘
... 30 万个对象散落在堆各处,访问一个就 cache miss 一次
TypedArray(列式)------ 同一列的值紧密排列
qty 列 (Float64Array):
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│10│11│18│ 9│..│..│..│..│..│..│..│..│ 每个 8 字节,紧密排列
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
└──── 一个 cache line (64B) ────┘ └──── 下一个 cache line ────┘
8 个元素 per line,顺序访问时硬件预取器提前拉取
styleNo 字典索引 (Uint32Array):
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ 0│ 1│ 0│ 2│..│..│..│..│..│..│..│..│ 每个 4 字节,紧密排列
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
└──── 一个 cache line (64B) ─────────┘ └──── 下一个 ─────┘
16 个元素 per line,比 Float64 更省 cache
三、HeapNumber 装箱:被忽略的隐性分配
再看 A 里的累加:
js
group[sums[s]] += data[i][sums[s]] || 0;
拆开看每一步:
scss
1. data[i] → 对象指针
2. data[i][sums[s]] → 属性查找 → 读出的是一个 HeapNumber(堆上的 double 对象)
3. group[sums[s]] → 同上,又一个 HeapNumber
4. HeapNumber + HeapNumber → 分配一个新的 HeapNumber 存结果
5. group[sums[s]] = ... → 写回属性槽,存新 HeapNumber 指针
每次累加都分配一个新的 HeapNumber。100 万行 × 5 个度量 = 500 万次累加 = 500 万个临时 HeapNumber。这些对象很快变成垃圾,触发 V8 的 minor GC。
3.1 V8 的 number 有两种表示
- Smi(Small Integer) :直接编码在指针里(tagged pointer),无堆分配。整数且在 Smi 范围内(约 ±2³⁰,31 位有符号)用它。
lineNo: 1这种字段就是 Smi。 - HeapNumber:装在堆上的对象,有隐藏类、有内存块。浮点数(金额、数量带小数)和超出 Smi 范围的整数用它。
3.2 为什么浮点数一定会装箱
group[sums[s]] += item[sums[s]],当度量是浮点(金额、数量带小数,本场景的常见情况)时:
- 读
group[sums[s]]:属性查找,值是 HeapNumber(因为浮点不能是 Smi) - 读
item[sums[s]]:同上 - 相加:结果是浮点,必须分配一个新 HeapNumber(堆分配 + 隐藏类 transition)
- 写回
group[sums[s]]:属性槽存新 HeapNumber 指针
100 万行 × 5 度量 = 500 万次累加,产生 500 万个临时 HeapNumber。这些 HeapNumber 是垃圾,触发频繁 minor GC。GC 一停就是几 ms,叠加起来是几十上百 ms 的卡顿,而且 GC 期间线程被暂停。
用图来看每次累加发生了什么:
3.3 即便都是整数,对象式也慢
即便度量恰好都是 Smi 范围内的整数,对象式仍要付属性查找的代价------只是少了 HeapNumber 这一项。而且 Smi 的 += 也要读-改-写,属性槽的写回仍然走隐藏类的 offset 定位。
B 里没有这个问题:
js
g[s] += sumCols[s][i];
g是Float64Array,g[s]是 8 字节裸内存槽位,不是对象属性sumCols[s][i]也是 8 字节裸内存- 相加是 CPU 寄存器里的
double add,结果直接写回 typed array 槽位 - 零堆分配、零 HeapNumber、零 GC 压力
四、CPU cache:为什么连续内存这么重要
这是列式存储最底层的收益,也是一句话解释不清的。
4.1 Cache line 是怎么工作的
CPU 读内存不是一个个字节读的,是以 cache line 为单位(x86 是 64 字节)。每次读取会把连续 64 字节加载到 L1 cache。
ini
内存地址: [0x00] [0x08] [0x10] [0x18] [0x20] [0x28] [0x30] [0x38]
└────────────── 一个 cache line (64B) ──────────────┘
如果程序访问 0x00,CPU 把 0x00~0x3F 整块加载到 L1。接下来访问 0x08、0x10 就是 cache hit (纳秒级)。如果接下来访问 0x100,CPU 要重新加载另一块 cache line------cache miss(百纳秒级,约 100 倍差距)。
各级 cache 的实际延迟(近似值,因 CPU 架构而异):
| 存储层级 | 典型延迟 | 说明 |
|---|---|---|
| L1 cache | ~1ns | CPU 核心私有,最快 |
| L2 cache | ~3-4ns | CPU 核心私有,稍大 |
| L3 cache | ~10-40ns | 多核共享 |
| 主存(DRAM) | ~60-100ns | 真正的内存,比 cache 慢两个数量级 |
4.2 行式存储的访问模式
对象数组里,data[0]、data[1]、data[2] 是三个独立分配的堆块,地址可能相隔几百字节甚至几 KB:
ini
堆地址: [data[0]对象 @0x1000] ... [data[1]对象 @0x2000] ... [data[2]对象 @0x3000]
└─ 64B ─┘ └─ 64B ─┘ └─ 64B ─┘
遍历 data[0].qty → data[1].qty → data[2].qty:
- 访问
data[0]:加载 cache line @0x1000(miss) - 访问
data[1]:加载 cache line @0x2000(miss) - 访问
data[2]:加载 cache line @0x3000(miss)
几乎每次访问都是 cache miss。100 万行遍历 = 100 万次 miss ≈ 100 万 × 100ns = 100ms 纯粹浪费在等内存上。
这里还有第二层:即便 data[0] 和 data[1] 的对象块恰好在相邻地址,访问 data[0].qty 和 data[1].qty 时,qty 在对象内的 offset 虽然相同,但两个对象是不同的 cache line。所以对象数组的字段访问天然是"跳线"模式,硬件预取器抓不到规律。
4.3 列式存储的访问模式
列式下,qty 列的所有值连续存储:
ini
Float64Array(qty): [10.5] [11.2] [18.5] [9.8] ...
└────── 一个 cache line (8 个 Float64) ──────┘
遍历时:
- 访问
qty[0]:加载 cache line(miss),同时把qty[1]~qty[7]也拉进 L1 - 访问
qty[1]:hit(已经在 L1 里了) - 访问
qty[2]~qty[7]:全部 hit - 访问
qty[8]:加载下一块 cache line(miss),同时拉进qty[9]~qty[15]
每 8 次访问只有 1 次 miss。硬件预取器(prefetcher)检测到顺序访问模式,会提前把后续 cache line 拉进来,miss 率进一步降低。
4.4 两种模式的 cache 行为对比
用一张图来对比行式和列式遍历时的 cache 命中情况:
4.5 为什么 Uint32 更划算
一个 cache line = 64 字节:
- 对
Float64Array:64/8 = 8 个元素每 line - 对
Uint32Array:64/4 = 16 个元素每 line
所以字典编码后的 indices(Uint32Array)比原始的 values(Float64Array)更省 cache line。维度字段做字典编码有双重收益:减少内存占用 + 提高 cache 利用率。
4.6 理论估算
| 存储方式 | 每 8 个元素的 miss 数 | 每元素等效耗时 |
|---|---|---|
| 对象式(散落堆) | ~8 次 miss | ~100ns |
| 列式(连续内存) | ~1 次 miss | ~12ns |
| 差距 | ~8 倍 |
实际测出来的差距(2.2 倍)小于理论值(8 倍),因为 V8 的 IC 优化和 GC 也占了部分时间,不完全是 cache 的功劳。但方向一致:列式存储的 cache 友好性是实打实的。
五、内存对比:132MB → 25MB
5.1 为什么对象式那么大
除了访问速度,列式存储的内存占用也小得多:
| 项 | 对象式(每行) | 列式(每字段每行) |
|---|---|---|
| 存储单元 | 1 个对象(隐藏类指针 + in-object 槽 + properties + elements) | 4 字节(Uint32)或 8 字节(Float64) |
| 100 万行 × 17 字段 | 约 132MB 散落堆 | 约 25MB 连续 |
| 访问模式 | 指针跳转,cache miss | 顺序扫描,cache hit |
132MB 的构成(每行):对象头(隐藏类指针 + hash + 其他)约 16-24 字节,in-object 槽位预留 4-8 个 slot 约 32-64 字节,17 个字段的值本身约 17×8=136 字节(字符串另算),加上 GC overhead。散落在堆上还有每个对象块的对齐填充。
25MB 的构成:5 个 Float64 度量 × 8MB + 12 个 Uint32 维度 × 4MB + 字典数组(唯一值,数量远小于行数)。全部连续。
5.2 25MB 能装进 L3 cache
现代 CPU 的 L3 通常是 8~32MB。25MB 能装进 L3 cache,这意味着整个数据集可以完全在 CPU 缓存里跑,不用频繁访问主存。这是列式存储的第二重收益------不仅访问模式友好,数据总量也小到能进缓存。
六、字典编码:字符串变整数
字符串是性能黑洞。300k 行的 styleNo 列,原本是 300k 个长字符串对象(每个 32+ 字节),分组时要做字符串比较。
字典编码的做法:
js
const dict = new Map(); // String(v) -> idx(分组一致性)
const dictionary = []; // idx -> 原始值(保留类型)
const indices = new Uint32Array(n);
for (let i = 0; i < n; i++) {
const v = data[i][field];
const key = v == null ? '' : String(v);
let idx = dict.get(key);
if (idx === undefined) {
idx = dict.size;
dict.set(key, idx);
dictionary.push(v);
}
indices[i] = idx;
}
转换后:
indices是Uint32Array(n),每个值 4 字节,连续存储- 分组 key 用整数拼接(比字符串拼接快得多)
dictionary数组保留原始值(保证类型不丢,见下文)
6.1 字符串在 V8 里的代价
V8 字符串内部有几种表示:
- SeqString:连续存储的 UTF-16,访问最快
- ConsString :
a + b的结果,惰性拼接(两段指针),访问时才 flatten - 字符串的 hash 由 V8 惰性计算,首次用作 Map key 时算并缓存
字符串做 Map key 的查找代价:先算 hash(或取缓存),再定位 bucket,冲突时逐字符比较(先比长度,长度相同再 memcmp)。
而透视图的 groupBy 要拼 key:item[dim1] + SEP + item[dim2] + ...。维度值是真实字符串(如 "2024-12-19吊挂12225202133-3-1..."),拼出来的 key 很长。每行拼一次长字符串加 Map 查一次,长字符串的拼接分配、hash、比较全是开销。
6.2 字典编码的收益
- groupBy 时再也不碰原始字符串:Worker 聚合只用
indices[i](整数),全在 typed array 上 - 整数 key 拼接:
key += dimCols[d][i]是整数转短字符串("0","231"),比真实维度值短几个数量级,拼接快、hash 快、冲突时比较快 - 唯一值是免费副产物:建字典时
dictionary[]就是唯一值列表,省去了独立的computeUniqueValues函数 - 结果重建很便宜:聚合末尾要把 786 个组重建为对象行(
key.split(SEP)再查dictionary),但组的数量远小于 n(786 对 30 万),这部分是 O(组数 × 字段数),相对主循环的 O(n) 可忽略
6.3 字典编码的完整流程
用图来看整个字典编码过程:
6.4 类型保真
Map 做 String(v) -> idx 映射保证分组一致性(两个值 String(v) 相同就映射到同一个 idx),但 dictionary 数组 push 原始值 v。比如 lineNo 是 number,字典里也是 number,S2 拿到后排数值序;如果存 String(v) 就退化成字典序。
类型保真是正确性前提:字典编码压缩的是"重复值的存储",不改变值的语义和类型。如果为了性能把 number 都转成 string,排序就会从数值序退化成字典序------这是性能优化破坏业务语义的典型陷阱。
七、分类打包:避免为度量字段白建字典
不是所有字段都需要字典。度量字段(如 qty、produceQty)永远不当分组键,建字典是纯浪费:
js
function packColumnar(data, fields, dims, sums) {
for (const field of fields) {
const inDims = dims.includes(field);
const inSums = sums.includes(field);
if (inDims || !inSums) {
// 维度/分类字段 → 建字典
// ...
}
if (inSums) {
// 度量字段 → 只建 Float64Array
col.values = new Float64Array(n);
// ...
}
}
}
实测:1M 行打包从 1.22s 降到 681ms(-44%),因为省了度量字段的字典构建和去重。
7.1 为什么度量字段不需要字典
字典的作用是把"可能很长的重复值"压缩成"短整数",收益发生在 groupBy 的 key 拼接和 Map 查找环节。度量字段(qty、金额等)在 groupBy 里根本不参与 key 构建------它们只被 += 累加。给度量字段建字典,等于给一个从不参与分组的字段做整数映射,纯浪费。
7.2 为什么度量字段也不需要 Uint32
度量值是浮点(金额、数量带小数),需要 8 字节精度,所以用 Float64Array 而不是 Uint32Array。这是精度需求决定的,不是性能选择。
7.3 packColumnar 的分类决策
八、微基准数据
Node 环境下(无 Worker/Vue/S2 开销),三代实现的对比:
| 数据量 | 原始 setData | 列式 setData | 原始切换 | 列式切换 |
|---|---|---|---|---|
| 1万 | 26ms | 7ms | 26ms | 4ms |
| 10万 | 268ms | 68ms | 269ms | 31ms |
| 30万 | 854ms | 199ms | 870ms | 93ms |
| 50万 | 1.59s | 319ms | 1.55s | 156ms |
| 80万 | 2.45s | 700ms | 2.41s | 258ms |
| 100万 | 3.09s | 899ms | 3.18s | 326ms |
Node 数值低于浏览器实测(少了 Worker 通信/Vue/S2 开销),但相对关系可靠。
8.1 数据说明
- 原始 setData = structuredClone(对象数组)+ lodash groupBy + sumBy
- 列式 setData = packColumnar(顺序写 typed array)+ 列式 groupBy
- 原始切换 = 每次切换都重新 structuredClone 全量数据 + groupBy
- 列式切换 = Worker 常驻数据,切换只发几个字符串,零拷贝 groupBy
8.2 比例关系
从表中可以看出一个规律:列式相比原始,setData 约 3.4× 提升,切换约 9.7× 提升。切换提升更大,因为切换时原始版本要重新克隆全量数据(structuredClone),而列式版本只发几个字段名字符串。
九、这一层没有做的事
- 没有用 SharedArrayBuffer:需要 COOP+COEP 安全头,部署复杂度高。方案4(transfer)已满足需求,详见线程层那篇。
- 没有做数据分页:后端全量下发,前端只能全量接收。如果后端支持分页,可以进一步降低前端内存压力。
- 没有优化 JSON 解析:axios 内部已经做了 JSON 解析,这一步的开销(1M 行约 100-200ms)不在我们的控制范围内。