原理
json-packer 是一个 JSON 压缩库,它的核心思想是通过分析 JSON 数据的特点,使用多种压缩技术来减少存储空间。本文从原理角度解释它是如何工作的。
为什么 JSON 可以被压缩
JSON 数据有几个明显的特点,这些特点为压缩创造了机会:
- 重复的键名:在复杂的 JSON 数据中,同样的对象键会大量重复出现
- 冗余的类型信息:JSON 的文本格式包含大量的引号、冒号、逗号等语法字符
- 重复的字符串值:某些字符串值会在数据中重复出现
- 数字的低效表示:JSON 中的数字以文本形式存储,占用空间较大
压缩策略
1. 类型标签:去掉语法冗余
JSON 的文本格式需要大量语法字符来区分不同类型:
{
"name": "Alice",
"age": 25,
"active": true,
"data": null
}
在这个例子中,引号、冒号、逗号、花括号等字符占用了相当比例的空间,但实际上并不承载数据本身的信息。
json-packer 的解决方案是为每种 JSON 类型分配一个 3 位的二进制标签:
- null → 000
- false → 001
- true → 010
- 数字 → 011
- 浮点数 → 100
- 字符串 → 101
- 对象 → 110
- 数组 → 111
这样,原本需要多个字符表示的类型信息,现在只需要 3 个二进制位。
2. 霍夫曼编码:压缩重复键名
考虑这样的 JSON 数据:
[
{"name": "Alice", "age": 25, "city": "Beijing"},
{"name": "Bob", "age": 30, "city": "Shanghai"},
{"name": "Carol", "age": 28, "city": "Beijing"}
]
在这个数据中,"name"、"age"、"city" 这三个键各出现了 3 次。如果用常规方式存储,每次都要完整保存这些字符串。
霍夫曼编码的思路是:出现频率高的键用更短的编码,出现频率低的键用较长的编码。
具体过程:
- 统计频率:遍历整个 JSON,统计每个键出现的次数
- 构建霍夫曼树:根据频率构建二叉树,频率高的键距离根节点更近
- 分配编码:树的路径就是编码,左子树为 0,右子树为 1
比如上面的例子中:
- "name"、"age"、"city" 各出现 3 次,可能被分配 1-2 位的短编码
- 如果有其他只出现 1 次的键,会被分配更长的编码
这样,原本每个键需要 4-5 个字节,现在可能只需要 1-2 个二进制位。
3. 变长整数编码:压缩数字
JSON 中的数字以文本形式存储效率很低。比如数字 123
在 JSON 中需要 3 个字节,但用二进制只需要 1 个字节就能表示 0-255 的范围。
json-packer 使用 LEB128(Little Endian Base 128)编码:
- 小数字用更少的字节:0-127 只需要 1 字节,128-16383 需要 2 字节
- 大数字自动扩展:根据数字大小动态决定使用几个字节
- 区分有符号和无符号:正整数和负整数使用不同的编码策略
4. 字符串池:去重复值
在某些 JSON 数据中,会有大量重复的字符串值:
[
{"status": "connected", "message": "Successfully connected to server"},
{"status": "connected", "message": "Successfully connected to server"},
{"status": "disconnected", "message": "Connection lost"},
{"status": "connected", "message": "Successfully connected to server"}
]
如果每次都完整存储这些重复的字符串,会造成很大浪费。
字符串池的思路是:
- 收集重复字符串:找出数据中重复出现且足够长的字符串
- 建立索引映射:将这些字符串提取到一个"池子"中,每个分配一个 ID
- 引用代替内容:在原始位置存储 ID 引用,而不是完整字符串
比如 "Successfully connected to server" 这个 30 字节的字符串,可能只用 1 个字节的 ID 就能引用。
霍夫曼编码的理论最优
理论上,霍夫曼编码的收益计算应该考虑原来字符串的长度。最优的霍夫曼编码应该基于频率和原长度的乘积来构建,因为:
- 一个键的压缩收益 = 原长度 × 出现次数 - 编码长度 × 出现次数
- 因此,我们应该优先为"原长度 × 出现次数"值最大的键分配最短的编码
例如,如果有一个键"extraLongKeyName"出现了2次,而"key"出现了3次:
- "extraLongKeyName"的理论收益权重为 16 × 2 = 32
- "key"的理论收益权重为 3 × 3 = 9
按理论最优,我们应该优先为"extraLongKeyName"分配更短的编码,因为它能带来更大的总收益。
但在实践中我们发现,对于大多数 JSON 数据场景,简单按频率进行霍夫曼编码已经能够获得很好的压缩效果。这主要有以下几个原因:
1. JSON键名的特点
键名通常较短且长度相近:大多数JSON键名长度在3-15个字符之间,差异不大。即使按理论最优方案,长键和短键分配的编码长度差异也并不显著。
高频键往往本身就是短键:如"id"、"name"、"type"等常见键既是高频键又是短键,所以简单按频率的方案与理论最优方案结果相近。
2. 实现复杂度和性能考虑
按理论最优方案需要额外计算和存储每个键的长度信息,增加了算法复杂度和编码时间。在绝大多数场景下差异不大的情况下,没有在最终的设计中引入。
为什么字符串池设计成可选的
字符串池并不总是有益的,这取决于数据的特征:
1. 数据特征决定收益
有利场景:
- 日志数据:大量重复的状态码、错误信息
- 配置数据:重复的路径、URL、标识符
- 数据库导出:重复的分类、标签、描述
不利场景:
- 用户生成内容:评论、消息等,重复度低
- 时间序列数据:主要是数字,字符串较少
- 小数据量:构建字符串池的开销可能大于收益
2. 性能权衡
启用字符串池的成本:
- 需要遍历整个数据两次(第一次统计,第二次压缩)
- 需要额外内存存储字符串池和索引映射
- 压缩和解压速度会变慢
禁用字符串池的优势:
- 只需遍历数据一次
- 内存占用更少
- 处理速度更快
- 代码逻辑更简单
3. 灵活性考虑
不同的应用场景对压缩率和性能的要求不同:
- 实时通信:延迟敏感,可能选择 v1 格式获得更快的处理速度
- 数据存储:空间敏感,可能选择 v2 格式获得更高的压缩率
- 批处理:可以容忍较高的处理时间,倾向于选择 v2 格式
- 嵌入式设备:内存受限,可能选择 v1 格式
4. 自适应策略
json-packer 允许用户根据数据特征进行配置:
CompressOptions {
enable_value_pool: true, // 是否启用字符串池
pool_min_repeats: 3, // 至少重复 3 次才加入池
pool_min_string_len: 8, // 长度至少 8 字节才考虑
}
这种设计让用户可以根据实际数据进行调优:
- 如果数据中短字符串居多,可以提高
pool_min_string_len
- 如果内存受限,可以提高
pool_min_repeats
来减少池的大小 - 如果确定没有重复字符串,直接关闭
enable_value_pool
位级优化
为了榨取最后的压缩空间,json-packer 在位级别进行优化:
1. 紧凑的位打包
- 类型标签只用 3 位,不浪费整个字节
- 布尔值用 1 位表示,而不是整个字节
- 多个小的信息可以打包在同一个字节中
2. 字节对齐处理
- 在需要的时候进行字节对齐,避免读取错误
- 智能的位缓冲区管理,减少内存分配
确定性设计
为了保证相同的输入总是产生相同的输出,json-packer 在所有涉及排序的地方都使用确定性策略:
- 键统计排序:按字典序排序,而不是依赖哈希表的随机顺序
- 霍夫曼树构建:在频率相同时,按符号顺序决定优先级
- 字符串池排序:先按频率降序,再按字典序升序
这样设计的好处是:
- 便于调试和测试
- 支持增量更新和差异比较
- 提高缓存命中率