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 反而会膨胀,此时应先做字典编码再评估。

判断顺序建议:先看基数(唯一值个数 / 总行数),基数低于 5% 优先字典编码;再看连续重复长度,平均游程长度大于 4 再叠加 RLE。两者可组合使用:先字典编码把字符串变整数码,再对整数码序列做 RLE。

二、真实数据集与编码过程

我们构造一份贴近实际的采集数据集 vib_2024.csv,共 1,000,000 行,字段如下:

列名类型唯一值数平均游程长度原始字节/行
device_id字符串81.08
channel字符串41.04
status字符串33206
unit字符串11,000,0005
tsint641,000,0001.08
valuefloat32约 900,0001.04

对 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 以上。

字典编码 + 游程编码处理链与压缩效果 原始列 device_id (8B) channel (4B) status (6B) unit (5B) ts (8B) value (4B) 合计 35 MB 编码决策 基数 8 → 字典 基数 4 → 字典 基数 3 + 长游程 → 字典 + RLE 常量 → RLE 高基数 → 不编码 高基数 → 不编码 压缩后 1 MB (3bit码) 1 MB (2bit码) 15.6 KB (3125游程) 5 B (1游程) 8 MB (未压缩) 4 MB (未压缩) 合计 14.02 MB 整体效果 压缩率 2.5 : 1 编码四列 20 : 1 查询扫描量↓ I/O 显著降低

三、查询速度测试设计

压缩率只是手段,查询速度才是目的。我们设计三组典型查询,在相同硬件(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 左右,状态过滤类查询获得数倍加速,而实现成本仅是一次性的编码逻辑与字典维护。