4. 字典编码与游程编码实战
高频采集数据有一个非常鲜明的特征:单列内部的取值重复度极高。以一台工业振动采集终端为例,每秒写入 2000 行记录,其中 device_id 只有 8 个取值,channel 只有 4 个取值,status 长期在 RUN、IDLE、FAULT 三个状态间切换,而 unit 列几乎恒为 mm/s。如果按原始字符串逐行存储,一列 8 字符的 device_id 每秒就要写入 16 KB,其中 99% 是重复内容。字典编码与游程编码正是针对这类"低基数、高重复"结构的两把利器,本节结合一份模拟的真实数据集,完整走一遍编码、压缩率计算与查询速度测试。
一、两类编码的适用边界
字典编码(Dictionary Encoding)的核心是建立"值 → 整数码"的映射表,把原始列替换为定长整数码。它适合基数低但取值分散的列,例如设备编号、通道号、状态枚举、单位、测点名称。压缩收益来自两方面:一是字符串变整数,二是整数码可用位宽压缩(如 8 个取值只需 3 bit)。
游程编码(Run-Length Encoding, RLE)则把"连续相同的值"折叠为"值 + 重复次数"的二元组。它适合取值成块出现的列,例如状态量、开关量、告警标志、采样模式。若一列是 RUN,RUN,RUN,...,FAULT,FAULT,...,RLE 能把上千行压成几个游程。但若取值高频抖动(如 RUN,FAULT,RUN,FAULT),RLE 反而会膨胀,此时应先做字典编码再评估。
二、真实数据集与编码过程
我们构造一份贴近实际的采集数据集 vib_2024.csv,共 1,000,000 行,字段如下:
| 列名 | 类型 | 唯一值数 | 平均游程长度 | 原始字节/行 |
|---|---|---|---|---|
| device_id | 字符串 | 8 | 1.0 | 8 |
| channel | 字符串 | 4 | 1.0 | 4 |
| status | 字符串 | 3 | 320 | 6 |
| unit | 字符串 | 1 | 1,000,000 | 5 |
| ts | int64 | 1,000,000 | 1.0 | 8 |
| value | float32 | 约 900,000 | 1.0 | 4 |
对 device_id 建立字典:{D01:0, D02:1, ..., D08:7},每行只需 3 bit,按字节对齐存 1 字节,压缩后 1 MB。对 channel 建字典 {CH1:0, CH2:1, CH3:2, CH4:3},2 bit 存 1 字节,压缩后 1 MB。对 status 建字典 {RUN:0, IDLE:1, FAULT:2} 后,序列呈现长游程,再做 RLE:假设 1,000,000 行被折叠为 3,125 个游程,每个游程存"码 + 计数"共 5 字节,压缩后约 15.6 KB。对 unit 这种常量列,RLE 直接压成 1 个游程,仅 5 字节。
而 ts 与 value 基数接近行数,字典编码收益极低,应改用差分编码或浮点量化,不属于本节重点。最终各列压缩后总字节约为 1 MB + 1 MB + 15.6 KB + 5 B + 8 MB + 4 MB ≈ 14.02 MB,相比原始 35 MB,整体压缩率约 2.5:1;若只统计被编码的四列,压缩率高达 20:1 以上。
三、查询速度测试设计
压缩率只是手段,查询速度才是目的。我们设计三组典型查询,在相同硬件(4 核 / 16 GB / NVMe SSD)上对比"原始列存储"与"编码后存储"的耗时。测试使用 Python + DuckDB 作为执行引擎,编码列以整数码落盘,查询时通过字典表还原。
-- Q1: 按状态过滤(高选择性)
SELECT count(*) FROM vib WHERE status = 'FAULT';
-- Q2: 按设备 + 通道分组聚合
SELECT device_id, channel, avg(value)
FROM vib GROUP BY device_id, channel;
-- Q3: 时间范围 + 状态联合过滤
SELECT * FROM vib
WHERE ts BETWEEN 1700000000 AND 1700003600
AND status = 'RUN';
测试结果(示例数据,非实测):Q1 在原始列上需扫描 6 MB 字符串并逐行比较,耗时约 42 ms;编码后 status 仅 15.6 KB,且整数比较远快于字符串比较,耗时降至约 6 ms,提速约 7 倍。Q2 的分组键 device_id、channel 编码后为 1 字节整数,哈希表键更小、缓存命中率更高,耗时从 310 ms 降至 180 ms。Q3 因涉及 ts 未压缩列,收益主要来自 status 过滤,耗时从 95 ms 降至 70 ms。
SELECT device_id),则必须回表查字典,若字典表未常驻内存,反而可能引入额外随机 I/O。建议将字典表控制在几千条以内并全量缓存。四、落地要点与常见陷阱
- 字典表要稳定:高频采集场景下新设备、新状态可能动态加入。若字典频繁重建,已落盘的数据块需重写。建议预留码位(如 0–255),新值追加到高位,避免重排。
- RLE 块大小要匹配查询粒度:若按小时分区,RLE 游程不应跨分区边界,否则单分区查询需解压整段。建议在每个数据块内部独立做 RLE。
- 组合顺序:先字典后 RLE 通常优于先 RLE 后字典,因为整数码的游程计数更紧凑,且字典表只需存一份。
- 验证压缩率:不要只看总字节,要按列统计。常量列压缩率可达百万倍,但若被高基数时间戳稀释,整体收益会被掩盖。
- 查询回归测试:编码后必须重跑原有查询集,确认结果一致且耗时未退化。重点观察含
ORDER BY、JOIN的查询,字典还原可能成为新瓶颈。
把上述流程固化为一个可复用的检查清单:统计各列基数与平均游程长度 → 对低基数列建字典 → 对长游程列叠加 RLE → 常量列直接 RLE → 高基数列保持原样或改用差分/量化 → 落盘后按列统计压缩率 → 跑三组典型查询对比耗时 → 确认字典表常驻内存。按此执行,一份 35 MB 的百万行采集数据可稳定压到 14 MB 左右,状态过滤类查询获得数倍加速,而实现成本仅是一次性的编码逻辑与字典维护。