2. 数据类型基础回顾:基本数据类型(int、string、float、bool)、复合类型(list、dict、struct)的底层存储与序列化差异
做字段级加密,说白了就是在数据离开内存之前,先把某些字段“封住”。但封住之前,你得先搞清楚这些字段在内存里长什么样,序列化之后又变了什么样子。我刚开始接触加密 SDK 时,就踩过一个坑:把一个 int 按大端序列化之后加密,服务端却用小端去解,出来一堆乱码。所以这一章,我们老老实实把数据类型的底牌翻一遍。
基本数据类型的底层存储
每种基本类型在内存中的布局,其实是由 CPU、编译器、语言运行时共同决定的。我习惯把它们分成两类:定长类型和变长类型。
int(整数)
常见的 int32、int64 都是定长。比如 int32 固定 4 字节。但有个细节很容易忽略——字节序。x86 用 little-endian,网络协议用 big-endian。我几年前做过一个跨语言加密方案,Go 端序列化用了小端,Java 端默认大端,结果整整 debug 了一下午。后来我强制所有加密前统一转成 big-endian,再没出过问题。
还有一点:有些加密 SDK 为了压缩空间,会把小整数用 varint(变长编码)表示。比如 Protobuf 的 int32 实际存储是 varint,位数越少的数字占越少字节。但这个变长会导致密文长度不确定,你如果要做字段级固定长度对齐,那就得小心了。
string(字符串)
字符串在内存里是一段连续的字节,但编码方式直接影响字节数。UTF-8 是字节流,每个字符 1-4 字节不等;UTF-16 是双字节流,有 BOM 头的问题。我建议字段级加密时,统一使用 UTF-8 编码,并且额外记录字符串的字节长度——不是字符长度。为什么?因为加密是对字节操作的,你不知道字符边界,解密后根本恢复不了原字符串。
序列化时,通常有两种方式:长度前缀法(先写 4 字节长度,再写内容)或 空字符结尾(C 风格)。加密 SDK 大多推荐长度前缀,因为空字符可能出现在内容中,截断数据。我曾经在一个遗留系统里看到用 '\0' 结尾的加密方案,结果用户昵称里带了个 '\0'……嗯,后面的事情大家都猜到了。
float / double(浮点数)
IEEE 754 标准是主流,float 4 字节,double 8 字节。但这玩意在序列化时有个大坑:精度丢失。同样的浮点值,在不同的编译器优化级别下,最后两个 bit 可能不同。字段级加密要求加密前后完全一致,否则解密出来对不上。所以我个人习惯的做法是:加密之前先把浮点数转成 定点数(比如乘以 10^6 后取整),或者直接用字符串保存。你想想看,乘法算出的结果和除法算回去,误差有时候就出现在尾数上,加密一旦掺进去,怎么对都对不回来。
bool(布尔值)
看起来最简单,但其实也有坑。C++ 里 bool 占 1 字节,Python 的 bool 是 int 的子类,序列化后变成 1 或 0。但有些系统会把 true 序列化成 1,有些序列化成 0xFF。字段级加密必须统一序列化协议,否则密文长度和内容都不一样。我建议把 bool 统一映射成单字节 0x00 和 0x01,别用多字节表示。
复合类型:底层存储与序列化差异
list(列表 / 数组)
底层存储最常见的是连续内存(像 C 数组)或链表。序列化时,我们要考虑:
- 元素个数:必须提前或一起写入。
- 元素类型:如果是异构 list,每个元素得带上类型标记。
- 变长元素:比如 list 里的字符串,每个字符串需要独立长度前缀。
我经历过一个性能问题:加密一个包含 10 万条小整数的 list,如果每加密一个元素都重新计算长度、申请内存,时间翻了好几倍。后来改成先批量计算总序列化长度,一次性分配 buffer,再逐个写入加密,速度提升了近 80%。
dict / map(字典)
底层是哈希表或平衡树。序列化时最大的差异在于 key 的排序。有些序列化协议(比如 JSON)不保证 key 顺序,但加密要求确定性——同样的数据必须产生同样的密文。如果不排序,每次序列化出来的字节流不同,加密结果也会不同。
我建议:无论是 encrypt 还是 decrypt,都要先对 dict 的 key 做字典序排序。如果 key 是非字符串类型,还得约定一个通用的序列化规则(比如 int 统一转成 big-endian 再按字节比较)。
| 序列化协议 | key 排序规则 | 对加密的影响 |
|---|---|---|
| JSON | 通常不排序 | 每次结果可能不同 → 不可加密 |
| Protobuf | 按 field number | 确定性,可直接加密 |
| MsgPack | 取决于实现 | 需要额外排序步骤 |
struct(结构体 / 对象)
底层是内存对齐后的连续字段。不同的语言、编译器可能会在字段间插入填充字节(padding)。比如 C:
struct Example {
char a; // 1 字节
int b; // 4 字节(实际从偏移 4 开始,因为 4 字节对齐)
}; // 总共 8 字节,而不是 5 字节
这种 padding 在不同架构下可能不同。如果把整个 struct 视为一个整体直接加密,换一台机器或换个编译器,解密出来的 padding 位置不一样,数据就坏了。所以字段级加密的最佳实践是:不要直接加密内存结构体,而是将每个字段提取出来,按显式序列化规范(比如 TLV:Type-Length-Value)逐一加密。
另外,struct 里的字段顺序也很关键。我见过一个案例:A 系统把 struct 的字段顺序改了一下,结果加密后的密文长度变了,下游解析全乱。所以一定要把字段顺序固化成 schema,用代码管理起来。
一张图看懂数据类型适配要点
下面这张 SVG 图,我把基本类型和复合类型的关键存储特征、序列化注意事项、以及对应的加密适配建议画出来了。你可以用它快速对照检查。
这张图其实把上面说的内容串起来了:基本类型各自有各自的坑,复合类型又叠加了顺序、长度、填充的问题。但只要你抓住“确定性”这根弦,在加密之前先拍成统一的字节序列,后面的适配就稳了。
一个小总结
数据类型这件事,看起来基础,但却是字段级加密最容易翻车的地方。我自己的经验是:写代码之前,先画一张每个字段的序列化描述表,标注好字节序、长度、编码、对齐规则。然后固化到 SDK 的配置文件中。别指望不同语言、不同版本的编译器自动帮你做对——它们不会。