Sooua
登录
返回文章列表
数据安全··17 分钟阅读·1 次阅读

把 PII 字段加密之后,等值查询从 0.05ms 变成 32 秒:字段级加密三种实现路径的工程边界

用 PostgreSQL 17 + pgcrypto 在 10 万行表上实测字段级加密的真实代价,对照 MongoDB Queryable Encryption 与 AWS DB-ESDK 的能力边界,给出选型与落地清单。

一个反直觉的开场:加密后等值查询慢了 50 万倍

在 PostgreSQL 17 上,对一个 10 万行的 users 表做 email = '[email protected]' 这样最普通的等值查询:

  • 明文列加 B-tree 索引:执行时间 0.047 ms
  • pgp_sym_encryptemail 加密成 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 byteahmac(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 hitExecution Time
email = '[email protected]'明文 + B-treeIndex Scan10.065 ms
pgp_sym_decrypt(email_enc, key) = '[email protected]'加密Seq Scan400032 493 ms
email_bidx = hmac('[email protected]', hmac_key, 'sha256')加密 + blind indexIndex Scan40.037 ms
email LIKE 'user_5%'(11 111 行命中)明文Seq Scan11377.99 ms
pgp_sym_decrypt(email_enc, key) LIKE 'user_5%'(11 111 行命中)加密Seq Scan400032 528 ms
SELECT count(pgp_sym_decrypt(email_enc, key)) FROM users_enc加密Aggregate400033 042 ms

几个直接结论:

  1. 任何把 pgp_sym_decrypt(...) 放到 WHERE 里的写法,PostgreSQL 都会做 Seq Scan,因为函数对优化器不可证可索引(不是 IMMUTABLE 的等值代理)。10 万行扫描代价 ≈ 32 s,意味着百万行表会到分钟级,业务接口直接超时。
  2. blind index(email_bidx = hmac(?, key))的 B-tree 走法和明文几乎一样快(0.037 ms vs 0.065 ms)。代价是:等值查询 OK,前缀/模糊/范围完全不行
  3. 表大小从 11 MB 涨到 41 MB,约 3.7x。原因是 pgp_sym_encrypt 的输出包含随机 IV、PGP 头、padding、可选压缩,单个 32 字节字段会膨胀到 100~120 字节左右。索引大小也受影响:blind index 列是 32 字节 SHA-256 摘要,比变长 email 字段索引体积接近一倍。
  4. 全表解密一次 = 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_bidxemail_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运维多了一项必须做的周期任务,不做查询会变慢
SchemaencryptedFieldsMap 不可修改、加密字段不可改名、查询类型不可改一旦上线,schema 是“一次性决定”
迁移不支持从普通集合自动转加密集合,也不支持从 CSFLE 转 QE,必须逐条 re-insert大表迁移要做停机窗口或双写
集合特性不支持 view、time series、capped、TTL index、unique index用 TTL 自动清理 PII 的常见模式直接失效
数组不支持对“数组中嵌套文档字段”自动加密addresses[].zip 这种字段必须摊平再加密
Shardingshard 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 pgcryptoMongoDB CSFLE / QEAWS 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 等在生产中会显著改变曲线。

分享

评论

登录 后参与讨论。

加载中…

相关文章