假设集群里已经存在表级透明加密模块,在此基础上设计一个高性能、高易用性的 WAL 加密模块。
WAL 加密的难点几乎不在"加密"本身,而在两件事:同一个 8KB 页会被反复重写,以及恢复路径要在拿不到 catalog 的时候就能解密。下面按设计决策的顺序展开。
1 威胁模型
先划清边界,因为它直接决定后面几个取舍。
- 要防的:磁盘 / 备份 / 归档介质被拖走;DBA 之外的运维人员直接读
pg_wal/;WAL 归档到对象存储。 - 不防的:有 root 权限的在线攻击者(内存里必然有明文)、旁路攻击。
1.1 为什么不用 dm-crypt 了事
如果只是防"介质丢失",块设备加密(LUKS/dm-crypt)性价比高得多。在数据库内核里做 WAL 加密,真正的理由只有三个:
- 密钥要由数据库自己管,不能落在 OS 层(密评 / 国测要求);
- 归档件要天然带密;
- 和已有的表级 TDE 共用一套密钥体系。
设计要始终对齐这三个理由,否则很容易做成一个又慢、又没多大意义的模块。
1.2 FPI 是必须堵的洞
既然表级 TDE 已经存在,那么 FPI(full page image)就是个必堵的泄漏点。
如果 TDE 是在 smgr 层做的(缓冲区里是明文、写盘时加密),那么 WAL 里的 FPI 记录的是明文页 —— 加密表的数据会原封不动地漏进 WAL。
只要集群里存在任何一张加密表,WAL 加密就必须强制开启,不能是两个独立开关。
实现上做成一个集群级的 data_encryption = on,或者至少让 CREATE ENCRYPTION POLICY 在 WAL 加密未开启时直接报错。这是易用性设计里最重要的一条:不要把一个"配错了就静默泄密"的组合留给用户。
2 加密边界:页级,页头留明文
2.1 粒度选择
| 粒度 | 问题 |
|---|---|
| 按 record | 记录平均才一百多字节,AES-NI 流水线跑不起来;XLogReader 要改得面目全非;record header 还得留明文 |
| 按 segment(16MB) | 无法处理"写了一半的段",直接出局 |
| 按 XLOG_BLCKSZ 页 | ✅ 和 I/O 单位对齐,reader 天然按页读 |
选页级。
2.2 页头必须留明文
+------------------------------+
| XLogPageHeaderData | ← 明文,24B(段首页 40B)
+------------------------------+
| payload | ← 密文
| |
+------------------------------+
因为 IV / tweak 要从 xlp_pageaddr 和 xlp_tli 推导,而这两个字段本身就在页头里 —— 不留明文就是鸡生蛋。
好处是 pg_waldump 不带密钥也能遍历页结构、判断段边界;坏处是泄露 LSN 和 TLI,这个可以接受。
payload 里的一切都要加密,包括 record header(xl_rmid、xl_info、block refs)。否则攻击者能从 rmgr 序列反推出业务行为模式,密评评审时这算个实打实的扣分项。
3 算法与 tweak:核心是前缀稳定性
这是整个设计里最容易踩坑的地方。
3.1 反复重写带来的硬约束
WAL 页会被多次写盘:先写 02000 字节,后面填满了再写 05000 字节。这意味着:
同一个磁盘位置的同一段字节,必须在每次重写时产生完全相同的密文。
否则崩溃恢复时会读到前后不一致的页。
这条要求排除了任何"每次随机 IV"的方案 —— 加密必须是位置确定性的。
3.2 为什么选 XTS 而不是 CTR
CTR 满足前缀稳定性,而且并行、无填充、能随机访问,看起来是最优解。但 CTR 有一个致命的失效模式:同一段密钥流加密两份不同的明文,两份明文直接异或泄露。
而 WAL 里恰好有几个地方会让 LSN 重复:
- 时间线切换:备库提升后,TLI=2 的 WAL 会在和 TLI=1 相同的 LSN 区间写入不同内容。如果 IV 只含 LSN,这里就是直接的密钥流复用。
pg_resetwal/ 从备份重建后 LSN 回退。
所以如果用 CTR,IV 必须是 (system_identifier, timeline_id, LSN, block_counter) 的完整组合,少一个字段就是灾难 —— 而这个约束在代码里没有任何编译期保护。
AES-256-XTS(国密场景下 SM4-XTS)在这里更稳:tweak 复用最坏只泄露"这两个 16 字节块相同",不会泄露明文本身。
对一个要长期维护、会被后人改动的模块,我更愿意选一个"用错了不会当场崩盘"的原语。
tweak = f(segment_no, page_no_in_segment, timeline_id)
每 16B 块独立加密 → 天然前缀稳定
尾部不足 16 字节的部分用密文窃取(CTS);或者更简单:重写时从 ≤ 写入起点的最近 16 字节边界开始重新加密。跨边界的那个半块会被重新加密成不同密文,但因为写的是同一个磁盘位置,没有正确性问题。
3.3 国密与密码卡的性能陷阱
要过国测大概率要用 SM4。这里有个很实际的坑:
千万不要把 WAL 的批量加解密走密码卡(SDF 接口)。
一次 SDF 调用的往返在几十到几百微秒量级,而 WAL 刷盘路径上每页都要调一次,直接把提交延迟打穿。
正确的分层是:
| 层次 | 由谁做 | 频率 |
|---|---|---|
| 主密钥 wrap/unwrap、随机数、密钥生命周期证明 | 密码卡(SDF) | 低频 |
| WAL 页的批量加解密 | 软件实现 | 每页 |
- x86 上 SM4 走 AVX2/GFNI 的实现能到 1~3 GB/s;
- ARMv8 有 SM4 专用指令;
- 启动时探测 CPU 特性并选择实现。
这个分层在评审时也站得住脚:密钥全生命周期在密码卡内,工作密钥只在受保护内存中以明文存在。
4 性能设计:真正的瓶颈不在写路径
4.1 写路径
自然的挂载点是 XLogWrite() 里 pg_pwrite() 之前 —— 但那是 WALWriteLock 的临界区,把 crypto 塞进去会直接拉长 group commit 的串行段。
方案 A:bounce buffer(推荐作为 v1)
WAL buffers 保持明文,加密到一块独立的 scratch buffer 再写盘。
- 代价:每页多一次 8KB memcpy。1 GB/s 的 WAL 产生率下大约多 10% 的一个核,可以接受。
- buffer 必须按
PG_IO_ALIGN_SIZE(4KB)对齐,否则 direct I/O 路径会退化。 - 最大的好处是安全:不存在"某个字节被异或两次变回明文"这类静默泄密 bug。
方案 B:原地增量加密(后续优化)
在 XLogCtl 里维护一个和 xlblocks 平行的 encrypted_upto[] 数组,每次只加密 [encrypted_upto, write_end) 这一段,零拷贝。
因为 WaitXLogInsertionsToFinish() 保证了写入点以下没有在途的 insert,并发上是安全的。
但 encrypted_upto 只要算错一个字节,要么双重加密(磁盘上出现明文),要么漏加密 —— 这两种错误都不会报错,只会静默发生。所以要等 A 方案跑稳、有了完整回归用例之后再上 B。
密钥流预计算(两个方案都适用)
XTS/CTR 的密钥流(或 tweak 序列)只依赖位置,不依赖明文。所以可以让 walwriter 或一个辅助进程提前把未来 N 个页的密钥流算好,刷盘时临界区里只剩一次 XOR(≈ memcpy 速度)。
注意:预计算出来的密钥流等价于密钥材料,必须 mlock 住、禁止进 core dump、时间线切换时立即作废。
4.2 读路径才是真正要盯的
挂载点只有两处:XLogPageRead()(恢复)和 WALRead()(walsender / pg_waldump)。解密进 XLogReaderState.readBuf,每 8KB 一次,正好和现有的按页读取对齐。
但受影响最大的是崩溃恢复和备库重放:
| 路径 | 加密开销 |
|---|---|
| 主库写 WAL | 可并行、可预计算,影响小 |
| walreceiver 落盘 | 零开销(收到的就是密文,直接写) |
| startup 进程重放 | 单线程串行解密,无法并行 |
恢复是单进程串行读 WAL 的。如果软件 SM4 只有 500 MB/s,而 NVMe 能吐 3 GB/s,恢复时间会被解密直接拖长数倍。
RTO 才是这个模块真正的性能风险点。 验收时必须单独测崩溃恢复耗时和备库 apply lag,而不是只看 pgbench TPS。
4.3 与 wal_compression 的顺序
wal_compression 是在 XLogRecordAssemble() 里做的,加密在写盘时做,所以顺序天然是"先压缩后加密",是对的 —— 反过来就完全压不动了。
这个不用改,但值得在注释里写一句,防止后人调整层次时搞反。
5 密钥管理:易用性主要体现在这里
5.1 密钥必须放在文件里,不能放 catalog
这是最容易设计错的一点。
startup 进程开始重放 WAL 时,catalog 还不可读(catalog 本身要靠重放才能一致)。所以 WAL 密钥不能存在 pg_key_data 这类系统表里。
放在一个独立文件,比如 global/pg_wal_keys,内容自包含:
{ key_id, wrapped_wal_key, kms_ref / 密码卡容器标识, algo, start_lsn, kcv }
这样 pg_waldump、pg_rewind、pg_basebackup 这些离线工具在没有 running server 的情况下也能拿到密钥(用主密钥解开即可)。这一条决定了整套工具链是否好用。
5.2 LSN 密钥映射表
文件里存的不是一个密钥,而是一张有序映射:
LSN 0/00000000 → NONE (加密启用前的历史段,保持明文)
LSN 3/A0000000 → key_id = 1
LSN 9/1C000000 → key_id = 2 (轮转)
按段边界切换,不要按任意 LSN 切 —— 一个段一把密钥,归档和 restore 的逻辑会简单一个量级。
这张表带来两个很大的易用性收益:
- 在线轮转:生成新密钥 → 用主密钥包装 → 追加映射 →
pg_switch_wal()。旧段依然可解密,旧密钥等归档过期后再退役。全程不停机。 - 存量集群可直接启用:不需要
initdb,也不需要重写任何东西。这和表级 TDE 必须重写表数据形成鲜明对比,是个很好卖的特性。
5.3 和表级 TDE 共用主密钥
WAL 密钥用同一个 MEK 包装,挂在已有的密钥层次下面。
用户视角只有一个主密钥要轮转、一个 KMS 要配 —— 这是"高易用性"最实在的体现。
6 周边生态:别让工具链崩掉
| 场景 | 设计 |
|---|---|
| 物理复制 | walsender 直接发密文。备库零解密开销,且不依赖 TLS 配置是否正确。代价是备库必须先有密钥文件 —— 由 pg_basebackup 带过去 |
| 归档 | archive_command 拷的就是密文,归档件天然加密,不需要额外做任何事 |
| 逻辑复制 | 解码时在 walsender 里解密成明文,再按逻辑格式发给订阅端。这条链路上的明文只能靠 TLS 保护,必须在文档里写清楚。另外解码要重复回读 WAL,解密开销比物理复制高,readBuf 级别的缓存能吃掉大部分 |
| pg_waldump | 加 -D <datadir> 让它能找到密钥文件;不带密钥时降级为只输出页结构 |
| pg_rewind | 要读 WAL 找分歧点,走同一套离线密钥加载 |
| pg_receivewal | 存密文即可,不需要密钥 |
7 失败模式:这个模块最危险的地方
7.1 用错密钥 = 静默数据丢失
这个必须单独拿出来说。
WAL 记录的完整性靠 CRC32C。密钥错了 → 解出乱码 → CRC 校验失败 → 恢复代码会把它当成 “WAL 正常结束” 然后停止重放。
结果就是:数据库起来了,看着一切正常,实际上丢了最后一大段事务。
必须堵住:
- 页头(明文区)里存
key_id,读的时候比对; - 密钥文件里存 KCV(key check value,比如
E_K(0)截断),加载密钥时就验证,而不是等到解密失败; - 恢复过程中,如果一条记录 CRC 失败、但它所在的页
key_id指向一个我们有的密钥,就不能当作 end-of-WAL —— 要FATAL,宁可起不来也不能悄悄丢数据。
7.2 完整性的坦白
CTR/XTS 只给机密性,不给完整性。WAL 的 CRC32C 不是密码学 MAC,改了 WAL 再重算 CRC 是完全可行的。
要真正抗篡改就得上 AEAD(GCM / SM4-GCM),但每页 16 字节的 tag 没地方放(要么改页格式压缩可用载荷,要么搞 sidecar 文件),而且和"页被反复重写"叠加之后逻辑相当绕。
建议 v1 明确只做机密性,在设计文档里把这个边界写死,别让评审或用户误以为它防篡改;抗篡改作为后续版本,或者交给存储层 / 文件系统。
8 落地与验收
8.1 代码结构
控制在一个 walcrypt.c 里,对外只暴露两个函数:
void WALEncrypt(char *dst, const char *src, XLogRecPtr startptr,
Size nbytes, TimeLineID tli);
void WALDecrypt(char *buf, XLogRecPtr startptr,
Size nbytes, TimeLineID tli);
调用点应该只有三处:
XLogWrite()—— 写XLogPageRead()—— 恢复时读WALRead()—— walsender / 工具读
如果实现过程中发现需要往第四、第五个地方插钩子,说明抽象漏了,应该退回来重新想,而不是继续加钩子。这是判断设计是否还成立的标准。
8.2 验收指标
| 项 | 目标 |
|---|---|
| pgbench TPS 回退 | < 3% |
| 批量导入(WAL 密集)吞吐回退 | < 5% |
| 崩溃恢复耗时增加 | < 10%(重点) |
| 备库 apply lag 增加 | 基本为 0(密文传输) |
| 密钥轮转 | 在线,无连接中断 |
8.3 最该覆盖的测试用例
都在"反复重写"的边界上:
- 写了一半的页 + 崩溃 + 恢复 + 继续追加;
- 时间线切换后,同一 LSN 区间写入不同内容;
- 跨段边界的记录;
- 密钥轮转恰好落在段中间;
- 用错密钥启动(必须
FATAL,不能静默截断)。
这几个用例写扎实了,模块基本就稳了。
9 待深挖
两块值得继续展开:
- 原地增量加密的并发证明 —— 要论证
encrypted_upto和WaitXLogInsertionsToFinish()的交互确实无隙; - 逻辑解码路径上的解密开销 —— 解码要反复回读 WAL,解密可能会把已有的逻辑复制性能优化吃掉一部分。