一个反直觉的开场:加密后等值查询慢了 50 万倍
在 PostgreSQL 17 上,对一个 10 万行的 users 表做 email = '[email protected]' 这样最普通的等值查询:
- 明文列加 B-tree 索引:执行时间 0.047 ms。
- 用
pgp_sym_encrypt把email加密成bytea,查询变成pgp_sym_decrypt(email_enc, key) = '...':执行时间 32 493 ms。
慢了大约 69 万倍。表本身只是 11 MB → 41 MB(约 3.7x)膨胀,结果不是“慢一点”,而是“查询直接不可用”。
这一节的实测来自我在本地 Docker 里跑的 PostgreSQL 17.10 + pgcrypto(输出原文存档于 assets/postgres-csfle/pgcrypto-bench-output.txt,建表脚本在 assets/postgres-csfle/pgcrypto-bench.sql)。环境是单进程 psql,没有连接池、没有 KMS、没有应用层缓存——只衡量数据库内部的加解密 + 索引代价,是“最理想”的字段级加密场景。
工程上真正要回答的问题不是“要不要做字段级加密”,而是:
- 哪些字段值得加密?
- 加密之后还要不要支持等值/前缀/范围查询?
- 谁来持有密钥、加解密在哪一层做?
- 这些选择会不会反过来废掉一部分业务功能?
本文围绕 PostgreSQL pgcrypto、MongoDB Queryable Encryption (QE) / CSFLE、AWS Database Encryption SDK for DynamoDB 三条主流路线,把实测数据和官方限制叠在一起讨论这些边界。
1. 字段级加密到底防什么、不防什么
PostgreSQL 18 文档把数据库加密分成几层:密码哈希、列级加密(pgcrypto)、磁盘/分区加密(dm-crypt + LUKS)、网络加密(SSL/GSSAPI)、客户端加密。其中和“字段级”最相关的是后两个。文档里写得很直接:
The pgcrypto module allows certain fields to be stored encrypted. ... The decrypted data and the decryption key are present on the server for a brief time while it is being decrypted and communicated between the client and server. This presents a brief moment where the data and keys can be intercepted by someone with complete access to the database server, such as the system administrator.
也就是说,只要解密发生在数据库服务器进程内(典型 pgcrypto 用法),针对“拿到 root 的数据库管理员”的威胁模型并没有真正解决。要真做到“服务器从未见过明文”,文档明确指出需要 Client-Side Encryption——加解密放在应用进程,不放在数据库。
MongoDB 的官方限制页面也用了类似措辞:
Queryable Encryption defends against data exfiltration, not against adversaries with persistent access to an environment, or those who can retrieve both database snapshots and accompanying query transcripts/logs.
把这些话翻译成工程语言:
| 想防的对象 | 字段级加密能不能防 | 备注 |
|---|---|---|
| 备份/快照外泄、误删 dump 落到第三方 | ✅ 能 | 备份里只有密文 |
| 读副本/审计日志中的 PII 泄漏 | ✅ 能(需要确认 redaction) | MongoDB QE 会自动从慢查询日志、system.profile、$collStats、$currentOp 等位置 redact 加密字段 |
| DBA / 持续在主机上的高权限攻击者 | ❌ 不能(pgcrypto 服务端解密) / ⚠️ 部分能(客户端加密路径) | KMS/HSM 持有 KEK 才能让 DBA 看不到 DEK |
| 同时拥有快照 + 查询日志的攻击者 | ❌ 不能 | MongoDB QE 自己写明,对范围查询尤其敏感 |
| 同义字段的频率分析(性别、城市这种低基数列) | ⚠️ 仅随机 IV 模式能挡 | 确定性加密 / 朴素 blind index 都会泄漏频率 |
| 应用代码 SQL 注入读出明文 | ❌ 完全防不住 | 应用本身就拿着密钥 |
接下来按“能不能查”这个最难的维度,把三种实现拆开看。
2. PostgreSQL + pgcrypto:把加密当函数用,会发生什么
pgcrypto 提供 pgp_sym_encrypt(text, psw[, options]) 和 pgp_sym_decrypt(bytea, psw[, options])。默认算法 aes128,可以切到 aes256,可选 ZIP 压缩。它本质是“服务器端 SQL 函数”,密钥要么写在 SQL 里,要么通过参数传给会话——这两种路径都会让数据库服务器在某个时刻看到明文密钥。
2.1 实测:10 万行 users 表的 4 种查询模式
我在 PostgreSQL 17.10(Alpine 容器,单实例无负载)上建了两张同结构的表,一张明文、一张加密;明文列建 B-tree 索引,加密列做了一个 email_bidx bytea(hmac(email, hmac_key, 'sha256'))作为 blind index。完整脚本见 assets/postgres-csfle/pgcrypto-bench.sql。
灌数据这一步代价已经能说明问题:
- 明文 100 000 行 INSERT:344 ms(约 290k 行/秒)。
- 加密 100 000 行 INSERT(每行 3 个字段调用
pgp_sym_encrypt+ 1 次hmac):111 796 ms(约 894 行/秒)。
写入吞吐下降约 325 倍。这是单线程极限值,生产里靠并发能拉回一些,但每行 ~1 ms 的纯 CPU 开销是确定的。
四种典型查询的 EXPLAIN (ANALYZE, BUFFERS) 实测如下(执行时间取自 Execution Time 字段,已截断到 ms):
| 查询语义 | 表 | 计划 | Buffers shared hit | Execution Time |
|---|---|---|---|---|
email = '[email protected]' | 明文 + B-tree | Index Scan | 1 | 0.065 ms |
pgp_sym_decrypt(email_enc, key) = '[email protected]' | 加密 | Seq Scan | 4000 | 32 493 ms |
email_bidx = hmac('[email protected]', hmac_key, 'sha256') | 加密 + blind index | Index Scan | 4 | 0.037 ms |
email LIKE 'user_5%'(11 111 行命中) | 明文 | Seq Scan | 1137 | 7.99 ms |
pgp_sym_decrypt(email_enc, key) LIKE 'user_5%'(11 111 行命中) | 加密 | Seq Scan | 4000 | 32 528 ms |
SELECT count(pgp_sym_decrypt(email_enc, key)) FROM users_enc | 加密 | Aggregate | 4000 | 33 042 ms |
几个直接结论:
- 任何把
pgp_sym_decrypt(...)放到WHERE里的写法,PostgreSQL 都会做 Seq Scan,因为函数对优化器不可证可索引(不是IMMUTABLE的等值代理)。10 万行扫描代价 ≈ 32 s,意味着百万行表会到分钟级,业务接口直接超时。 - blind index(
email_bidx = hmac(?, key))的 B-tree 走法和明文几乎一样快(0.037 ms vs 0.065 ms)。代价是:等值查询 OK,前缀/模糊/范围完全不行。 - 表大小从 11 MB 涨到 41 MB,约 3.7x。原因是
pgp_sym_encrypt的输出包含随机 IV、PGP 头、padding、可选压缩,单个 32 字节字段会膨胀到 100~120 字节左右。索引大小也受影响:blind index 列是 32 字节 SHA-256 摘要,比变长 email 字段索引体积接近一倍。 - 全表解密一次 = 33 s / 100 000 行 ≈ 330 µs/行。在一个走 ETL 把全表解密到下游的脚本里,这就是吞吐上限:单线程不到 3 000 行/s。
2.2 blind index 的真实安全边界
很多博客把 blind index 介绍成“安全的可搜索加密”,这是不准确的。它能避免确定性密文里的“同明文 → 同密文”模式,但只要 HMAC 输出和加密字段在同一行可见,攻击者依然能做:
- 频率分析:如果某一列基数小(例如
gender只有几种值),blind index 输出的分布也会暴露这几种值的概率。 - 已知明文匹配:知道用户邮箱
[email protected]后,攻击者可以本地算hmac('[email protected]', K)的密文(如果 K 泄漏)→ 反查行;或者反向:知道 K 后,能批量构造常用值的索引值,等于把列字典化。 - 跨字段关联:如果
phone_bidx、email_bidx用同一把 HMAC key,攻击者在多列上做交叉关联会更容易定位特定个体。
工程对策不复杂,但要写到设计里:
- HMAC key 与 KEK/DEK 分开管理,不要让数据库本身的 dump 里同时出现两类密钥。
- 对低基数列(性别/城市/状态)不要做 blind index,要做就加“身份盐”(per-tenant salt 或 per-user salt),让同一明文在不同租户/用户下哈希不同。
- 范围/前缀需求单独走应用层方案**,比如把
created_at按月哈希成 bucket、把姓名前缀单独 HMAC,而不是奢望从加密字段直接做LIKE。
2.3 pgcrypto 可以、不可以做的事
工程能做:
- 防止
pg_dump、磁盘镜像、误送第三方分析的备份泄漏明文; - 在多租户库里用 per-tenant key 限制爆炸半径;
- 满足“PII at rest 必须以非明文形式存储”这一类合规条款的字面要求。
工程做不到:
- 防止持续登录数据库主机的攻击者(密钥会在会话内存或 SQL 文本里);
- 让加密字段直接支持
LIKE、>、<、ORDER BY; - 在不重写所有写入路径的前提下迁移老数据(必须解密 → 再写入加密列)。
EnterpriseDB 的 PGD 文档对此说得很直白:列加密对 PGD 的索引和性能都有显著影响,建议只对真正需要的列做,并且要专门为加密列设计访问模式。
3. MongoDB 的两条路径:CSFLE 与 Queryable Encryption
MongoDB 的解决方式更彻底:把加解密完全放到 driver 里。但代价是限制更多。
3.1 两条路径的差别
- CSFLE(Client-Side Field Level Encryption):每个字段配置
algorithm,可选确定性(AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic)或随机(AEAD_AES_256_CBC_HMAC_SHA_512-Random)。确定性允许等值查询、能用 B-tree 索引;随机更安全但完全不能查。 - Queryable Encryption(QE):从 8.0 GA,等值和范围查询都受支持;前缀/后缀/子串在 8.2 还是 public preview。原理是在客户端做 OPE/索引化加密,server 端用专门的
__safeContent__元数据集合维护索引结构。
QE 的 limitations 页面里的工程约束远比博客里讲的多:
| 限制类别 | 关键约束 | 工程含义 |
|---|---|---|
| 拓扑 | 不支持 standalone;不支持 secondary read | 测试环境必须是 replica set;读分流策略要重新设计 |
| 元数据 | metadata collection > 1 GB 需要手动跑 compactStructuredEncryptionData | 运维多了一项必须做的周期任务,不做查询会变慢 |
| Schema | encryptedFieldsMap 不可修改、加密字段不可改名、查询类型不可改 | 一旦上线,schema 是“一次性决定” |
| 迁移 | 不支持从普通集合自动转加密集合,也不支持从 CSFLE 转 QE,必须逐条 re-insert | 大表迁移要做停机窗口或双写 |
| 集合特性 | 不支持 view、time series、capped、TTL index、unique index | 用 TTL 自动清理 PII 的常见模式直接失效 |
| 数组 | 不支持对“数组中嵌套文档字段”自动加密 | 像 addresses[].zip 这种字段必须摊平再加密 |
| Sharding | shard key 不能是加密字段 | 想用 user_id 之外的字段(如 email)做 shard key 时不可行 |
| Collation | 加密字段忽略 collation | 大小写不敏感的查询/排序在加密字段上不再保证语义 |
| 排查 | 慢日志、system.profile、$planCacheStats 都会跳过加密集合 | 出问题时官方支持也看不到 query plan 细节 |
| 安全前提 | 同时拥有 snapshot + query log 的攻击者超出威胁模型 | 范围查询尤其敏感,不能把 query log 长期裸存 |
最后这条是工程上最容易踩的雷:很多团队把 MongoDB profile 直接接到 SIEM 长期保留,再叠加快照+ELK,等于自己破坏了 QE 的威胁模型假设。
3.2 一个常见的“CSFLE 等值就够”的反例
“反正我只查 email 等值,确定性 CSFLE 完全够用”——这是常见误判。MongoDB 自己的 client-side-encryption 规范 里写:
Prohibiting CSFLE and Queryable Encryption in the same collection reduces complexity. From the product perspective, a random CSFLE field and a non-queryable Queryable Encryption field have the same behavior and similar security guarantees.
也就是说,CSFLE 的“确定性”模式提供的可查询性,安全语义和 QE 的“非可查询”字段是一个等级。如果一开始就用 CSFLE-deterministic,未来想升级到 QE 时不能在同一个集合里混用,必须新建集合迁移数据,并且重新评估 schema。这种“一次决策决定后续多年迁移成本”的特征,决定了选型必须配上 3~5 年的数据增长曲线一起评估,而不是当成一次性配置。
4. AWS Database Encryption SDK for DynamoDB:把加解密下沉到客户端
DynamoDB 表本身有静态加密(KMS-managed 或 CMK),但和 PostgreSQL TDE 一样,DynamoDB 服务进程能看到明文。AWS Database Encryption SDK(DB-ESDK,老名字 DynamoDB Encryption Client)的定位是属性级(attribute-level)客户端加密:每个 item 的指定属性在客户端加密,再 PutItem。
从 GitHub README 可以看到几个值得注意的工程信息:
- 库本身用 Dafny 写、形式化验证后编译到 Java/.NET/Rust 三种 runtime。意味着加密原语和签名/认证逻辑由形式化证明覆盖,但不要把这个安全保证扩展到上层应用代码。
- 目前不支持 Go / Python / Node.js(Go 在 issue 里跟踪)。如果服务用这些语言写,就只能用旧版 DynamoDB Encryption Client(功能更少)或自己实现,安全风险显著上升。
- 它做的是加密 + 签名:除了加密敏感字段,还会对“需要进入签名”的字段算 MAC,防止攻击者在 DynamoDB 层 swap 字段值。这一点比朴素的 pgcrypto 强:pgcrypto 没有跨字段绑定。
可查询性方面,AWS 的官方做法是 beacon(信标)——本质就是 blind index 的工程化实现,但加了:
- standard beacon:单字段 HMAC,类似 PostgreSQL 例子里的
email_bidx。 - compound beacon:把多个字段拼起来做 HMAC,支持“
country#status联合等值”。 - truncated beacon:HMAC 截断为 N 位,故意制造碰撞来抵御频率分析(牺牲精确度换安全)——这一招在 PostgreSQL blind index 里很少看到,但其实是更专业的做法。
4.1 KMS 调用模式:envelope + caching 不是可选项
DB-ESDK 默认对每个 item 调
用 KMS GenerateDataKey 拿一把 256-bit DEK,本地加密属性后丢弃 DEK 明文、把 ciphertext blob 存进 item。这是经典的 envelope encryption,安全上是对的,但每个 item 一次 KMS 调用意味着:
- 延迟:region 内同账户的 KMS 调用 P50 一般在 5–15 ms。一个写 1 万条 PutItem 的批处理就会多出几十秒到几分钟纯 KMS 时间。
- 成本:KMS 计费按调用次数(典型 0.03 USD / 10 000 次)。一个百万级写入的导入任务,本身的 KMS 账单就在 30+ USD 量级。
- 限速:KMS 对每个账户每 region 有 RPS 限制,超过会触发
ThrottlingException。
AWS Encryption SDK Data Key Caching 文档把这套缓存机制放在第一位讨论:
Data key caching can improve performance, reduce cost, and help you stay within service limits as your application scales.
但缓存本身是有安全代价的:缓存命中越多,同一把 DEK 加密的 item 越多,单次 DEK 泄漏波及的数据量就越大。生产里常见的策略是按时间窗 + 调用次数双限制(例如最多 10 分钟或 10 000 次复用),并把这个参数在威胁模型里写清楚,而不是默认走 SDK 的样例值。
4.2 工程上 DB-ESDK 比 pgcrypto/CSFLE 更难绕过的点
注意几条工程边界:
- 签名/验签是默认开启的,不是可选优化项。把签名关掉等于退化成普通 pgcrypto 模式,只剩机密性,没有完整性。
- schema 里区分加密 / 签名 / 明文 / 不签名。比如
created_at通常签名但不加密,因为业务需要按时间查询;签名保证写入后值不能被偷改。 - GSI 必须建立在 beacon 字段上,不是建在原始字段上。原始字段在 DynamoDB 里要么不存在(已加密),要么是密文,不能用作索引键。
DynamoDB 服务侧没有 query log,只有 CloudTrail 数据事件——比 MongoDB QE 的“同时拥有 snapshot + query log”担忧更轻,但前提是 CloudTrail 数据事件本身要保护好(默认不开,开了之后要单独管理 KMS key)。
5. 三条路径横向取舍
| 维度 | PostgreSQL pgcrypto | MongoDB CSFLE / QE | AWS DB-ESDK for DynamoDB |
|---|---|---|---|
| 加解密位置 | 服务器进程 | 客户端 driver | 客户端 SDK |
| DBA / 主机 root 攻击者 | ❌ 不防 | ✅ 防 | ✅ 防 |
| 等值查询 | ✅(需自建 blind index) | ✅(CSFLE-det 或 QE) | ✅(standard beacon) |
| 范围查询 | ❌ | ✅(QE 已 GA) | ⚠️(需自建 bucket beacon) |
| 前缀/子串查询 | ❌(必须应用层 bucket) | ⚠️(QE 8.2 preview,禁产品线) | ❌ |
| Unique 约束 | ✅(基于 blind index) | ❌(QE 不支持 unique index) | ⚠️(基于 beacon GSI,但有碰撞概率) |
| TTL 自动清理加密字段 | ✅(基于 created_at 明文) | ❌(QE 禁 TTL index) | ✅(基于明文 TTL 属性) |
| 实测写入吞吐影响 | ~325× 慢于明文(单线程,10 万行) | 取决于 driver;官方建议用第三方 APM 测 | 受 KMS 调用频率主导,缓存命中率直接决定性能 |
| 表/集合体积膨胀 | ~3.7×(pgp + 索引) | ≈ 2~4×(QE 还有元数据集合) | item size 增加(含签名 + beacons + ciphertext blob) |
| 模式不可变性 | 灵活(自己定 schema) | encryptedFieldsMap 几乎一次决定 | schema 在 SDK 配置里,可改但要重写历史数据 |
| 形式化验证 | 无 | 无 | ✅(Dafny) |
| 多语言支持 | SQL(任意 driver) | 主流 driver | 仅 Java/.NET/Rust |
| 适合场景 | 已用 PG,只对少数高敏字段做合规加密 | 文档型数据,PII 字段需要可查询 | 已在 AWS、用 DynamoDB、有 KMS/CloudHSM |
| 不适合场景 | 写吞吐敏感、查询路径复杂 | 强依赖 TTL/unique/secondary read 的应用 | Go/Python/Node.js 服务、跨云、需要全文检索 |
结论
字段级加密的工程边界在于:它不是『把所有字段加密就安全了』,而是要在数据分级、查询模式、索引策略和密钥层级之间做一系列取舍。等值查询可以用 blind index,范围查询需要 bucket 分桶,模糊查询基本不可行。最常被忽视的是数据迁移路径和回滚预案——明文到密文的 backfill 不能没有,密钥泄漏时的轮换能力不能事后补救。
7. 不应该用字段级加密解决的问题
最后一个边界,也是工程上最容易混淆的:字段级加密不是“数据脱敏”、不是“数据隔离”、不是“访问控制”。
- 想给 BI/分析师看脱敏后的数据 → 用 view + masking function(PostgreSQL 用
regexp_replace、MongoDB 用 redact pipeline),而不是把字段加密。 - 想把数据按租户隔离 → 用 row-level security 或独立 schema,而不是“每个租户一把 KEK”——后者的运维成本远高于收益,除非合规明确要求。
- 想限制谁能读 PII → 数据库账号 + IAM + 行级权限,而不是寄望“拿到表也读不出来”。
字段级加密最适合的是“最坏情况下 dump 落到第三方时仍然可控”这一类不可逆事件。把它放在威胁模型里这一格,其它问题用更便宜、更明确的工具去解决,工程总账才划算。
实测复现条件:PostgreSQL 17.10 (Alpine, x86_64),单实例、单进程 psql,pgcrypto 默认 aes128,HMAC-SHA256 生成 blind index,10 万行 users 表,环境无并发负载。脚本与原始 EXPLAIN 输出见 assets/postgres-csfle/。所有数字仅说明加密本身的代价量级,不构成生产容量规划依据;连接池、应用层 DEK 缓存、并行查询、磁盘 IO 等在生产中会显著改变曲线。
