1. 为什么"加密字段"和"模糊查询"从一开始就犯冲
上个月内部方案评审,业务同事突然问我:"咱们用户手机号加密之后,后台那个'客户是139开头的'搜索框怎么写SQL?"我当场愣了一下。加密和模糊查询,这两个词放在一起本身就是悖论:加密是为了让数据"不可读",模糊查询是要在"可读"的基础上做近似匹配。数据库里存的是密文,LIKE '%139%'匹配的是密文字节,根本不是明文里的"139"。
这个问题不只在面试里出现,几乎所有做隐私合规改造的团队都会撞上。最近几年业务系统加密改造越来越普遍,手机号、身份证、银行卡号这些敏感字段都要落库加密。但加密做完,产品经理马上会追过来:"后台用户列表的搜索框怎么变砂了?按手机号后四位搜不出来了?"这时候你才会意识到,加密不是换个函数存进去就完事,它会把一套成熟的查询体系直接掀翻。
这篇文章把我在这个坑里爬出来的思路整理一下:密文为什么不能直接LIKE,有哪些绕法,哪些是看着能走实际是弯路,以及最后我用了什么组合方案落地。文章偏工程实践,适合正在做敏感字段加密改造的后端同学参考。
1.1 AES加密会把明文抹成随机字节流
先说最根本的原因。现代对称加密算法,比如 AES-CBC、AES-GCM,设计目标之一就是同一个明文、不同时间加密,产生的密文必须完全不同。为了实现这一点,加密时会引入随机IV(初始化向量)或者随机Nonce。同一个手机号"13800138000",第一次加密得到c3e2...,第二次加密得到9fa1...,两条密文没有任何规律可循。
而数据库的模糊查询,本质是在字节层面做子串匹配:
SELECT * FROM user WHERE mobile_enc LIKE '%139%';这条SQL期望的是:在mobile_enc这个字段存的字符串里,找到包含"139"这三个字符(字节)的记录。问题是"139"的密文是什么?如果服务端把用户输入的"139"同样加密,得到的密文和数据库里mobile_enc中哪怕包含"139"的密文块也没有任何可比性,因为:
- 用户手机号整个字段是一次加密的,"139"只是其中一个片段,密文里根本不存在独立的"139"密文块。
- 加密算法是块加密,明文按16字节分组,字符和密文没有一一对应关系。
- 即便用流加密,密文异或结果也近似随机,子串的统计特征全部消失。
所以从密文上直接做模糊查询,本质上是在随机字节串里找一个随机的子序列,概率上能撞上的可能性几乎为零。
1.2 就算让加密可确定,模糊查询依然无从下嘴
有人会想:既然随机IV导致密文不一致,那我改成固定IV或者ECB模式,让同一个明文产生同一个密文,不就能LIKE了吗?
实测下来,这条路也只是从"完全不行"变成"还是不行"。原因有两个。
第一,ECB模式有明显安全缺陷。它会把明文切成块分别加密,相同的明文块产生相同的密文块。对于短文本、结构化数据(手机号、姓名、地址),攻击者很容易通过密文块的重复模式推断出明文结构信息。等保和隐私合规审查时,这属于会被直接打回的安全设计。
第二,也是更关键的,确定性加密只解决了"等值比较"问题,没有解决"子串匹配"问题。你搜索"139",服务端把"139"加密得到一个密文块,但你数据库里手机号的完整密文是"13800138000"整个串加密后的结果,里面没有一个独立的密文块等于"139"的密文。除非你对每一个可能的子串单独加密并建索引,否则LIKE依然匹配不上。这就是后面要讲子串拆分索引的动机。
1.3 加密和检索的本质矛盾
把这个问题的本质说透:加密追求的是最小化信息泄露——攻击者拿到密文,不应该能推出任何关于明文的信息;检索需要的是可比较性——查询条件能定位到目标数据。这两个目标天然冲突。
业界所有方案,本质上都是在两者之间找一个折中点:要么牺牲一部分查询能力(只支持等值,不支持模糊),要么增加一些额外的索引数据(索引本身可能泄露部分信息),要么引入新的计算模型(同态加密、可信执行环境)。
所以以后再有人问"加密后怎么模糊查询",你要先反问回去:你说的模糊,到底要模糊到什么程度?查询频次多高?数据量多大?能接受多大的额外存储?不同答案对应完全不同的方案。
2. 动手前先分清:你要的到底是哪种"模糊"
我踩过的第一个坑,就是没把需求问清楚就急着设计方案。业务说"要支持模糊查询",但他嘴里的模糊和产品文档里的模糊、和DBA理解的模糊,往往不是一回事。
2.1 给"模糊查询"分个类
按我的经验,日常业务里的模糊查询至少可以分成五类:
| 查询类型 | 例子 | 典型字段 |
|---|---|---|
| 等值查询 | 手机号精确查 | 手机号、身份证号 |
| 前缀匹配 | 手机号前几位、邮箱前缀 | 手机号、邮箱 |
| 后缀匹配 | 手机号后四位找客户 | 手机号、银行卡号 |
| 中缀(任意包含) | 姓名含"张伟" | 姓名、地址 |
| 分词/组合查询 | 按姓+名组合、多条件AND | 姓名、昵称 |
每一类对方案的要求完全不同。
- 等值查询最简单,一个 HMAC 盲索引就能解决。
- 前缀匹配可以用"前缀编码"的思路,但HMAC下没法直接前缀查,需要设计成子串索引或者按长度拆分的索引。
- 后缀匹配是后台客服场景的高频需求,尤其手机号后四位找人,这个后面讲子串索引时会有个很省存储的做法。
- 中缀匹配是最难的,也是"真正意义上的模糊查询",需要子串拆分索引或者解密后过滤。
- 组合查询还得考虑多个条件的索引如何协同,不是单列索引能搞定的。
2.2 数据量和安全等级决定方案上限
第二个要问清的问题是数据量。
- 数据量在几千行以内:不用折腾索引,应用层把数据查出来解密,在内存里做模糊匹配就行,性能完全可接受。
- 数据量在几万到几十万行:子串索引、布隆过滤器都可以考虑,具体看查询频次和存储成本。
- 数据量在百万级以上:必须认真设计索引结构,而且要把回填、写放大这些问题都考虑进去,否则上线就是事故。
安全等级也要分级。你要防的是外部攻击者、内部运维DBA、还是国家级对手?如果是防外部拖库,HMAC盲索引+应用层解密的组合足够;如果DBA能看到密钥,那所有只存在数据库里的方案都有问题,得考虑KMS集中管理和应用层加解密;如果是最高等级,可能得上TEE。
2.3 三个先决问题
动工之前,先回答这三个问题:
- 这个查询能不能接受"不实时"?比如离线批量跑,那方案空间大很多。
- 能不能接受额外的索引列或者索引表?存成本是实打实的。
- 查询结果需要多准?布隆过滤器这种方案有假阳性,需要二次确认,你能接受吗?
把这些问完,再回头看那些"加密模糊查询方案",你会发现很多方案其实不适合你的场景,不是技术不行,而是需求根本不匹配。
3. 那些看起来能绕过去的弯路,为什么走不通
网上能搜到一些所谓的"加密后模糊查询方案",我把它们分成了四类,每一类我都实测过或者调研过,下面说说为什么大部分是坑。
3.1 明文冗余列:最省事,也最致命
最直接的想法是:加密字段旁边再存一列明文,或者干脆新建一张明文表。这样模糊查询在明文列上做,加密字段只是用来"展示"或"解密后回显"。
这个方案在POC阶段跑得飞快,代码也简单,但放到生产环境就是定时炸弹。一旦数据库被拖走,明文列等于把加密工作全部归零,攻击者不需要破解任何密钥就能拿到全部手机号。隐私合规审查时这条基本一票否决:加密字段旁边不允许存可逆的明文冗余。
现实中确实有团队这么干,他们想的是一张表存密文、另一张表存明文,认为"分开存放"就安全了。但拖库攻击者拿到的往往是整个数据库的备份,两张表一起端走。别自欺欺人了。
3.2 全量解密后在内存里过滤:只适合微型数据
另一种思路是:SQL先查出全部数据(或者按某个粗粒度条件缩小范围),应用层解密后在内存里做String.contains过滤。
这个方案在小规模场景下很实用。我做过一个管理后台,用户表只有不到两万条记录,后台管理员查询频次也不高,全量解密的内存过滤方案几十毫秒搞定,完全不需要引入复杂索引。
但数据量一上去就完了。百万条记录,每条AES解密大概需要几微秒到十几微秒,单线程全量解密就要几秒到几十秒,再加上网络传输、JSON序列化,接口直接超时。而且每一次模糊查询都是全表扫描CPU扎堆,数据库服务器和API服务器的CPU会被打满,其他正常请求全部遭殃。
3.3 改成固定IV或者ECB模式:牺牲安全换查询,得不偿失
原理在1.2节说过。这里再补一个实测结论:即使你牺牲安全用ECB,也只是能做到"相同明文块有相同密文块",子串依然没法匹配,除非你的查询条件恰好和某个明文块边界对齐。手机号"13800138000"按16字节分组,"1380"这种跨分组的子串根本匹配不上。
更严重的是,ECB模式会把明文的统计特征直接暴露出来。比如姓名加密后,姓"张"的人,密文第一个块几乎一样;手机号前三位是"139"的用户,密文前一段也高度雷同。这在安全审计里属于重大缺陷,还会招来"为什么不用GCM"的质疑。
3.4 数据库透明加密(TDE):防磁盘被盗,不防应用层
不少DBA会推荐数据库自带的透明加密(TDE)或者表空间加密。这个方案解决的是"数据库文件被拷贝走之后,攻击者无法直接读取明文"的问题,但对应用来说,数据库内部在查询时是会解密还原明文的,所以SQL里的LIKE依然在明文上执行,模糊查询天然可用。
但TDE的安全模型和应用层加密完全不同。在TDE下,数据库服务端拥有解密能力,DBA、运维只要能连上数据库,就能把明文查出来。如果业务需求是"数据部门/运维也不能看明文",TDE完全帮不上忙。另外TDE启用后,LIKE查询虽然可用,但索引会因为加密存储部分失效,查询性能会有明显下降。
一句话总结我的结论:需求如果是"防硬盘丢失",用TDE;需求如果是"防内部人员和外部拖库",必须应用层加密,那TDE就不沾边了。
4. 等值场景的兜底方案:HMAC盲索引
模糊查询是终极目标,但实际业务里很多"模糊框"的需求其实最后都会收敛成等值或者近似等值。先把HMAC盲索引讲清楚,因为它是后面所有方案的地基。
4.1 原理:把"查不出来的密文"转成一个可比较的索引值
既然密文不能比较,那我们就另建一列,存一个"可比较的索引值"。做法是:写入的时候,对明文字段计算一个带密钥的哈希(HMAC),把结果单独存一列。查询的时候,对用户输入做同样的HMAC计算,然后直接等于匹配这一列。因为 HMAC 结果是定长的、确定性的,所以可以建B+树索引,走精确匹配。
这个方案的适用范围是等值查询:手机号精确匹配、邮箱精确匹配、订单号精确匹配。很多后台"模糊搜索框"实际上只需要用户输入完整手机号来定位,这种情况用HMAC盲索引性能极好,查询延迟在个位数毫秒级。
4.2 为什么用HMAC而不是SHA256
这是我最想强调的点。很多初稿方案写成SHA256(phone)作为索引列,看起来也能用,但SHA256是无密钥哈希,攻击者拿到索引列之后,完全可以离线枚举所有可能的手机号,逐个计算SHA256,然后反推出明文。手机号空间只有11位数字,约1000亿种组合,对GPU集群来说不是不可逾越的障碍。
HMAC引入了一个服务端持有的密钥K,索引值是HMAC-SHA256(K, phone)。攻击者就算拖走数据库,没有K就没办法验证"某个候选明文是否对应某个索引值",离线爆破的复杂度完全不一样。密钥要放在KMS、加密机或者配置中心的高权限隔离区,不能硬编码在代码里。
4.3 代码示例:写入和查询
用一个简化的Java示例说明整个流程。加密用AES-256-GCM,索引用HMAC-SHA256。
// 写入:加密 + 生成索引 public void createUser(String phone) { byte[] encryptedPhone = aesGcmEncrypt(phoneKey, phone); // 随机IV模式 String phoneIdx = hmacSha256(indexKey, phone); User user = new User(); user.setMobileEnc(Base64.getEncoder().encodeToString(encryptedPhone)); user.setMobileIdx(phoneIdx); // 索引列 userMapper.insert(user); } // 查询:精确匹配 public User findUserByPhone(String phone) { String phoneIdx = hmacSha256(indexKey, phone); return userMapper.selectByMobileIdx(phoneIdx); }-- 索引列建普通B+树索引即可 CREATE INDEX idx_user_mobile ON user(mobile_idx); -- 查询走等值匹配 SELECT * FROM user WHERE mobile_idx = #{phoneIdx};要点:mobile_idx列的类型建议用BINARY(32)或者CHAR(64)存hex,别用TEXT,否则索引膨胀严重。写入和查询必须用同一个密钥派生逻辑,密钥轮换会导致所有旧索引失效,这个后面专门讲。
4.4 局限:它不是模糊查询,只是把精确匹配做快了
必须说清楚,HMAC盲索引只解决等值查询。有人拿它冒充模糊查询:手机号前三位+后四位分别建索引,然后做两个等值条件。这确实是工程里常见的变通,但可组合的模式有限,不是真正的模糊。
真正的中缀模糊查询,比如姓名含"张伟"、地址含"杭州",HMAC无能为力,需要下一种方案。
5. 真正支持模糊查询的方案:子串拆分加密索引
这是我在生产环境中最终采用的方案,思路其实朴素:既然密文不能做子串匹配,那就在写入的时候,提前把明文中所有可能被搜索的子串都枚举出来,分别计算HMAC存成索引。查询时,把用户输入也按同样的规则拆成子串、计算HMAC,然后用等值匹配找到候选主键。
5.1 原理:把模糊查询转换成等值查询的集合
拿"张伟"举例。假设我们规定索引覆盖1~2个字符的子串,那么写入时拆出:
- "张" ->
HMAC(K, "张") - "伟" ->
HMAC(K, "伟") - "张伟" ->
HMAC(K, "张伟")
这三条索引记录挂在这个用户名下。用户搜索"张伟"时,服务端计算HMAC(K, "张伟"),直接等值匹配索引表,找到所有包含"张伟"的用户。用户搜索"张"时,同理。
关键在于拆分规则必须一致:写入和查询用的是同一套规则。这不是什么高深理论,其实就是搜索引擎里"倒排索引"的简化版,也是学术界说的"可搜索对称加密"的一种朴素实现。
5.2 拆分规则怎么定:中文字符和手机号的差异
拆分规则直接决定索引大小和查询能力,是方案的核心参数。
中文字符串,比如姓名、地址,通常按字符做滑动窗口。假设最小子串长度minLen=2,最大maxLen=4,对"张伟明"拆分:
- 2字窗口:张伟、伟明
- 3字窗口:张伟明
这样查询"张伟"能命中,查询"伟明"也能命中。但如果用户只输入"张"想查姓张的所有人,就查不出来,因为索引里没有单字。这是刻意的:单字索引会让命中结果集爆炸(一个"张"可能匹配几十万人),还会泄露大量单字分布信息。建议中文场景minLen=2起步。
手机号这类固定长度的数字串,做全子串拆分非常费存储。一个11位手机号,按1~11位全拆,要拆11+10+...+1=66条索引。实际业务里手机号查询的高频模式是"完整手机号"和"后四位",所以只需要做后缀子串索引:对每个长度≥4的后缀建索引。"13800138000"的后缀索引就是38000、0138000、...、13800138000,一共8条。查后四位"8000"时,计算HMAC(K, "8000"),等值匹配即可。
| 字段类型 | 推荐拆分规则 | 参考索引行数 |
|---|---|---|
| 中文姓名 | 2~3字滑动窗口 | 每个姓名约L-1 + L-2条 |
| 手机后四位查询 | 4位以上后缀子串 | 每个手机号约8条 |
| 邮箱前缀 | 按前缀长度分段 | 每个邮箱约3~5条 |
| 地址 | 2~4字滑动窗口 | 每个地址几十条,谨慎 |
5.3 索引表设计和SQL
我用的索引表结构:
CREATE TABLE search_idx ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id BIGINT NOT NULL COMMENT '业务主键,如user_id', idx_type VARCHAR(32) NOT NULL COMMENT '索引类型:name_2, name_3, mobile_sfx4', idx_value VARBINARY(32) NOT NULL COMMENT 'HMAC-SHA256结果', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_type_value_biz (idx_type, idx_value, biz_id), KEY idx_biz (biz_id) ) COMMENT '密文检索索引表';写入时批量插入。查询时:
-- 用户输入"张伟",服务端算出的hash = hmacValue -- 先查候选biz_id SELECT biz_id FROM search_idx WHERE idx_type = 'name_3' AND idx_value = #{hmacValue}; -- 再用候选id查主表,解密确认 SELECT * FROM user WHERE id IN (...);注意idx_value用VARBINARY(32)存HMAC原始字节,比存hex字符串更省空间,索引体积也更小。idx_type区分同一字段不同窗口长度的索引,这样查询时可以只查需要的类型。
5.4 成本账:一张表要膨胀多少
这个方案最大的代价是存储。100万个用户,每个姓名平均3个字,按2~3字窗口拆,大约每个姓名产生2 + 1 = 3条索引,索引表300万行。如果手机号也做后缀索引,再加800万行。合计千万级行数,对MySQL来说完全能承受,但必须有合适的索引和分区策略。
写入放大也要算清楚:一次用户写入,除了主表insert,还要批量insert十几条索引。如果业务写入量很大,事务时间会变长,需要评估。
另一个代价是信息泄露。子串索引虽然是HMAC,但攻击者可以统计"某个索引值对应的记录数"来分析高频子串。比如姓"王"的索引行有几十万个,攻击者能推断出"王"是个高频姓氏,配合社会工程学可能进一步猜测。为了降低这个风险:
- 索引里不存单字(
minLen至少2)。 - 索引表单独授权,不能让所有服务都访问。
- 考虑对索引值再做一层加密,代价是查询时也要先解密再匹配,性能略降。
5.5 写入和查询的完整流程
写入流程:
- 明文加密,写入主表。
- 按拆分规则枚举所有子串。
- 对每个子串计算
HMAC(K, sub),组装索引行。 - 同一个事务里插入主表和索引表。
查询流程:
- 用户输入关键字。
- 按同样的拆分规则,把关键字拆成子串(和索引类型对应)。
- 每个子串计算HMAC,用
idx_type + idx_value查索引表。 - 多个子串条件之间按需AND/OR(比如输入"张伟"拆不出两个词,直接查"张伟"本身)。
- 拿到候选
biz_id集合后,查主表,有需要就解密确认。
这套流程我实际压测过,100万用户、300万索引行的场景,一个2字关键词的查询大概在20~50ms,完全可用。
6. 数据量大但又不想存天量索引:布隆过滤器召回加解密确认
子串拆分索引的缺点是索引行数大、写入放大明显。如果你的业务查询频率不高,但数据量有几百万甚至上千万,又不愿意维护一张巨型索引表,可以试试布隆过滤器方案。
6.1 思路:先用一个"大概率正确"的结构筛掉无关数据
布隆过滤器是一个空间效率极高的集合数据结构。它的特点是:判断"不在"一定正确,判断"在"可能有误报。
做法:
- 写入时,对每条记录的明文做n-gram拆分(比如3字窗口),把每个子串哈希后放入布隆过滤器,同时记录这条记录ID。
- 查询时,对用户输入做同样的n-gram拆分,先在布隆过滤器里判断"这个子串是否存在"。如果过滤器说"不在",直接返回空;如果说"在",取出候选记录ID集合。
- 最后对候选集合做主表查询,解密后用明文做精确的
contains确认,滤掉误报。
布隆过滤器相当于一个前置的粗筛,真正精确的匹配放在最后一步"解密确认"完成。这样省掉了海量子串索引行的存储,代价是查询时多一步解密验证,且可能随机捞回一些无关记录。
6.2 工程实现:Guava布隆过滤器还是Redis
数据量在百万级别、单机内存够的情况下,可以直接用Guava的BloomFilter。实现代码:
// 初始化:预期数据量100万,误判率1% BloomFilter<String> nameFilter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 1_000_000, 0.01 ); // 写入:把每个用户的n-gram子串放入过滤器 for (String sub : ngram("张伟明", 2, 3)) { nameFilter.put(sub + ":" + userId); // 子串+用户ID,避免子串相同但用户不同的冲突 } // 查询:判断候选 if (nameFilter.mightContain("张伟:" + candidateUserId)) { // 把这个userId加入候选集合 }这里有个关键细节:过滤器里存的是子串 + 用户ID的组合,而不是单纯子串。因为布隆过滤器只回答"是否存在",如果只放子串,查询时只能知道"这个子串存在",拿不到具体用户ID,还得再遍历所有用户才能确认,那等于没做筛选。把用户ID拼进去,过滤器就能帮我们直接筛出"哪些用户ID包含这个子串"这个大致的集合。
但这样做也有缺点:如果要支持子串 -> 所有用户ID的映射,就不能只靠单个布隆过滤器,还得配合"子串 -> 候选ID集合"的倒排结构。所以实际落地时,我更推荐用Redis的布隆过滤器做粗筛,再用一个轻量的倒排表(或者位图)存候选ID:
查询流程: 1. 用户输入"张伟" -> 服务端生成 ngram("张伟") = ["张伟"] 2. Redis BF判断"张伟"是否存在 -> 存在 3. 从倒排表或二级结构取出候选userId列表 4. 主表查询 + 解密确认Redis的BF命令在Redisson里有现成封装,配置好容量和误判率即可,不用自己写。
6.3 误判率和空间怎么定
布隆过滤器的参数:预期元素数量n,误判率p,位数组大小m,哈希函数个数k。公式是:
m = -n * ln(p) / (ln2)^2 k = (m / n) * ln2100万条记录、误判率1%,大约需要m = 1200万bit = 1.4MB左右。看着很小,但注意:如果每个用户有多个子串,过滤器的实际元素数量是子串数 * 用户数。一个3字姓名拆2~3字窗口,平均3个子串,100万用户就是300万元素,位数组大小要相应放大到300万 * 12bit = 4.2MB左右,仍然可控。
误判率过高会让"候选集合"很大,最后一步解密确认的CPU开销上升。建议线上从1%起步,实测候选集大小,如果用户总是搜"张"这种高频词,会导致候选集爆炸,这种场景就不适合布隆过滤器方案。
6.4 适用边界:低频查询、高写入、海量数据
布隆过滤器方案适合"写入频繁、查询低频"的场景。比如日志系统按用户ID检索某段文本,或者海量终端的配置项查询。写入时只往过滤器里加,不建索引;查询时接受一定误判,再用解密确认兜底。
真正高频的模糊查询,比如客服每天几千次按手机号找人,不建议用布隆过滤器,子串索引表反而更直接——每次查询走B+树等值,毫秒级返回,不需要解密确认那条链路。
7. 同态加密、可搜索加密、可信执行环境:离生产还有多远
每次聊加密检索,一定有人提同态加密。我把这几个"高级方案"的调研结论也放出来,省得大家再花时间踩一遍。
7.1 同态加密:理论上完美,实际没法用
同态加密允许在密文上直接做计算,得到的结果解密后等于对明文做相同计算的结正。理论上,可以在密文上跑一个"子串匹配"算法,实现真正的加密状态模糊查询。
但现实很骨感。全同态加密(FHE)单次密文乘法运算就要毫秒级甚至更慢,而一个子串匹配算法需要成百上千次乘法、比较、逻辑运算,一次查询算下来要几十秒甚至更久。我调研过一个开源FHE库在字符串搜索上的benchmark,同样是10万条数据,明文SQL几十毫秒,FHE跑了十几分钟。这还是纯计算时间,不算网络传输和密钥管理。
所以FHE目前只适合低频、高价值、小数据量的场景,比如医疗领域某些统计查询,离"用户管理后台的搜索框"这种交互式查询还很远。
7.2 可搜索对称加密(SSE):学术圈热词,工程落地还是得回到子串索引
可搜索对称加密(Searchable Symmetric Encryption,SSE)研究了很多年,核心思想就是:构造一些可搜索的加密索引,查询时服务器在密文索引上做检索而不泄露明文。前面说的子串拆分加密索引,本质上就是SSE里最朴素的一种实现。
真正工业级的SSE库很少,而且大多只支持单词等值查询,支持子串、通配符的很少。引入一个新框架的成本远高于自己维护一张search_idx表,这是很多团队最终选择自研的原因。选型的时候建议想清楚:你是不是已经有能力维护一套HMAC索引了?如果是,SSE真没必要上。
7.3 可信执行环境(TEE):把解密和查询都搬进安全区
TEE的思路和前面完全不同:数据在数据库里仍然是密文,但查询时把密文传入信任执行环境(如Intel SGX enclave),在里面解密、做模糊匹配,结果再加密返回给应用。数据库服务端全程接触不到明文。
这个方案性能损失比FHE小得多,查询还是在明文语义上执行,但依赖特定硬件(CPU支持SGX等),部署和运维门槛高,而且安全模型建立在信任CPU厂商和完整验证体系之上。我见过一些金融、政务项目采用这个方向,但一般公司没有对应的基础设施,不建议作为普通业务的优先方案。
7.4 方案对比
| 方案 | 查询能力 | 性能 | 存储/计算开销 | 安全模型 | 落地难度 |
|---|---|---|---|---|---|
| HMAC盲索引 | 等值 | 极快 | 极低 | 密钥托管KMS | 低 |
| 子串拆分加密索引 | 模糊(按拆分规则) | 快 | 索引膨胀明显 | 索引有统计泄露风险 | 中 |
| 布隆过滤器+解密确认 | 模糊 | 中 | 内存/Redis占用 | 误判暴露存在性 | 中 |
| 同态加密 | 理论上任意 | 极慢 | 极高 | 强 | 极高 |
| TEE | 任意明文查询 | 中高 | 依赖硬件 | 依赖厂商信任 | 高 |
8. 我落地的组合方案和踩坑记录
方案评审结束,最终给业务方交付的是一个组合方案,你可以直接抄作业。
8.1 需求和设计
需求场景:
- 用户表:手机号、姓名、邮箱。
- 手机号:支持完整 + 后四位查询,客服高频使用。
- 姓名:支持2字及以上模糊查询,单字查询给提示"请输入至少2个字"。
- 邮箱:支持前缀查询。
最终设计:
- 手机号完整:
user.mobile_hmac列,HMAC-SHA256,等值匹配。 - 手机号后四位:
search_idx表,idx_type='mobile_sfx4',存4位及以上后缀子串的HMAC。 - 姓名:
search_idx表,idx_type='name_2'和idx_type='name_3',分别存2字、3字窗口子串的HMAC。 - 邮箱前缀:
search_idx表,idx_type='email_pre',存前缀分段HMAC。
8.2 写流程和读流程
写入流程做了一个统一入口:
@Transactional public void createOrUpdateUser(UserDTO dto) { // 1. 加密主字段 User user = buildEncryptedUser(dto); userMapper.insert(user); // 2. 生成搜索索引行 List<SearchIdx> idxList = buildSearchIdx(user.getId(), dto); searchIdxMapper.batchInsert(idxList); }buildSearchIdx里做拆分和HMAC计算,注意所有子串的idx_type和idx_value都要和查询时完全一致。
查询流程按场景分支:
// 手机号后四位 String subKey = hmac(indexKey, inputPhoneSuffix); List<Long> ids = searchIdxMapper.findBizIds("mobile_sfx4", subKey); // 姓名模糊 List<Long> nameIds = searchIdxMapper.findBizIds("name_2", hmac(indexKey, inputName));拿到的候选ids再查主表,解密后按明文做一次最终过滤,防止索引规则和用户输入规则之间出现偏差。
8.3 压测数据
100万用户量级,索引表大约700万行。单台MySQL 8.0,查询一个2字姓名关键词,search_idx命中率在几十到几千个候选人之间,整体接口延迟稳定在30~80ms,客服满意度没有问题。写路径因为有批量索引插入,单条用户写入耗时从原来的毫秒级涨到几十毫秒,但用户注册本身不是高频操作,可以接受。
8.4 踩坑记录:这些坑比方案本身更值得看
第一,HMAC密钥轮换会让所有索引一夜作废。我们中途换过一次密钥,结果所有search_idx里的idx_value全部失配,线上搜索直接瘫痪。后来学乖了:老索引保留、新索引用新密钥生成,通过idx_version字段区分,灰度期双写,等老索引彻底废弃后再清理。
第二,索引回填容易遗漏。系统上线前,历史数据必须跑一遍全量回填脚本,给老用户生成search_idx。我们第一次上线漏了回填,导致新用户能搜到、老用户全部"查无此人"。回填要设计成可重入任务,每次重启从上次位置继续。
第三,日志和慢查询泄露明文。加密只做了存储层,但如果你在查询日志里打印了WHERE mobile_idx = ...,而日志系统又保存了对应的明文入参,那等于自己把明文送给了日志平台。我们在日志脱敏和入参过滤上也做了处理,明文手机号一律打码再进日志。
第四,单字查询需求要先掐死在产品评审阶段。业务方总是想要"输入一个字也匹配",但单字索引会让候选集数量爆炸,一个"张"字可能命中几十万用户,解密过滤的CPU开销直接把接口拖垮。我们在产品层面做了限制:姓名至少2个字,手机号至少4位。这个限制不是技术妥协,是合理的产品设计。
最后再说一点个人体会。那次被问懵之后,我把所有相关方案都过了一遍,最大的收获是:加密模糊查询没有银弹,任何方案都是在"安全、性能、成本、查询能力"四者之间做取舍。想清楚你需要的"模糊"到底是什么,再动手,比一上来就搜"最优方案"重要得多。这套子串索引方案不是最酷的,但它是目前能同时满足安全合规、查询性能和工程可维护性的组合,至少在我们项目里,它已经稳定跑了一年多。