07 · 列存与分析:为什么少读几列会更快
目标:根据扫描列数和更新模式解释分析系统的存储布局取舍。
同一张表有不同物理布局
报名列表通常要显示一条报名的多个字段;日报可能只需要城市和人数。行存把一行字段放在一起,读取少量完整记录很方便。列存把同一列的值连续组织,分析扫描可以跳过无关列,同类型、重复值也更容易压缩。
“列存”说的是物理布局,不是把表的每个字段拆成一张业务表。你仍可以对列存数据库执行关系查询。反过来,支持 SQL 也不能证明它是行存。
压缩减少读取字节,向量化执行让一个算子一次处理一批值,减少逐行解释开销;查询编译则把查询转成更直接的执行代码。三者可组合,但不是同一机制。对于高频小更新,维持列块和压缩结构会带来成本,因此在线修改与分析扫描常采用不同组织方式。
估算先于产品选择
以下是逻辑读取量模型,不是磁盘基准。假定 1 万条记录,每条 20 个等宽字段、每字段 8 字节,查询只需要 2 列且不能利用索引跳过行。保存 analytics_storage.py,先计算两种布局的未压缩扫描字节数。
from itertools import groupby
rows, columns, selected, width = 10_000, 20, 2, 8
row_bytes = rows * columns * width
column_bytes = rows * selected * width
cities = ["north"] * 6000 + ["south"] * 4000
runs = [(city, sum(1 for _ in values)) for city, values in groupby(cities)]
assert runs == [("north", 6000), ("south", 4000)]
assert row_bytes == 10 * column_bytes
print("row_bytes:", row_bytes)
print("column_bytes:", column_bytes)
print("runs:", runs)
uv run --python 3.12 --no-project analytics_storage.py
预期输出:
row_bytes: 1600000
column_bytes: 160000
runs: [('north', 6000), ('south', 4000)]
前两行只比较必须涉及的字段宽度,未计元数据、压缩、块边界和缓存。最后一行演示游程编码:连续相同值可表示成“值 + 次数”。实际编码长度依赖格式,这里没有声称 Python 元组只占几字节。
引导修改:① 将 selected 改为 20,并把比例断言改为相等;② 两种未压缩读取量应一致;③ 把城市列表改成 ['north', 'south'] * 5000,runs 将有 1 万段。值集合相同,但排序影响压缩效果,不能只看基数。
聚合结果也能成为派生数据
日报按日期、城市预先算出报名数量,就是物化视图。它减少重复扫描,但改变统计定义后要重新计算。按多个维度预聚合可以形成数据立方体;维度组合增长很快,不能预先计算所有想得到的问题。数据仓库中的事实表和维度表负责语义,列布局与物化视图负责执行成本。
云分析引擎可以把对象存储中的数据交给临时计算资源扫描。存算分离便于弹性分配,但小查询可能受启动、远程读取和缓存命中影响。选择架构时要计算整个工作负载的成本,不把本例的 10 倍字段比例直接换算成 10 倍实际加速。
独立练习:组织者只查最近一天按城市的人数,每五分钟刷新。写出一个物化视图的键、值和重建输入,并说明退款或取消如何影响它。
参考设计
键为 (日期, 城市),值为有效报名人数。输入必须包含确定的报名状态,或可重放的报名与取消事件。只对“报名事件”做累加会把已取消报名计入;可按报名 ID 保存最终有效状态后再分组,或对幂等处理后的取消做撤回。刷新时间与统计截至时间一并展示。第 20–23 课将实现重算和增量维护。
若游程结果异常,检查是否排序而不是仅有重复值;若计数对但报告不对,检查日期时区、有效状态和城市到底取当前值还是报名时的值。在 design.md 固定日报口径和允许的更新延迟。