数据库里有个手机号字段,安全要求一上来,改成 AES 加密存储。
改完查询详情、修改资料都没问题,直到产品丢过来一个需求:
手机号支持模糊搜索。
原来的 SQL 很简单:
select id, user_name, mobile
from customer
where mobile like '%1388%';
加密之后再这么查,直接歇菜。
表里的数据已经变成这样:
13888881234
↓ AES
T6qvX8c7QH6nYwY7mY...
你搜 1388,数据库面对的是一坨完全看不出规律的密文。
我第一次碰到这种需求时也想过一个很自然的办法:
String cipher = encrypt("1388");
select *
from customer
where mobile_cipher like '%cipher%';
但这条路根本走不通。
AES 这种正常的加密算法有个很重要的特征:明文只改一点点,整个密文都可能完全不同。
比如:
13888881234 -> lPp8hM...
13888881235 -> xR3zQ9...
两条明文前 10 位都一样,密文之间却不存在什么“公共前缀”。
所以有一句话得先记住:
密文本身基本没法直接做 LIKE。
别在 SQL 上折腾。
where aes_decrypt(mobile_cipher, 'key') like '%1388%'
这种 SQL 虽然理论上能跑,我一般不会让它进生产。
原因也很直接。
数据库得先把数据一条条解密,再做匹配,普通索引基本利用不上。数据量一上来,本来一个简单查询,很容易变成全表扫描加批量解密。
更麻烦的是,你把密钥、解密函数这些东西塞进数据库,密钥管理也跟着变复杂了。
所以这个问题真正难的地方不是“Java 怎么解密”,而是:
既不能直接查密文,又不想查询时把整张表解密一遍,那到底查什么?
我线上更愿意做一层额外的“检索索引”。
原始手机号还是正常加密:
mobile_cipher = AES(mobile)
但另外生成一套只用于搜索的数据。
比如手机号:
13888881234
假设业务只允许最少输入 4 位,我们可以拆成:
1388
3888
8888
8881
8812
8123
1234
不过这些字符串也不能直接明文落库。
否则你数据库里虽然把手机号加密了,旁边却躺着:
1388
3888
8888
...
这个加密基本等于白干。
我的做法一般是对这些检索片段再做一次带密钥的 HMAC:
1388 -> HMAC -> a8f31...
3888 -> HMAC -> c92d7...
8888 -> HMAC -> 71b0a...
最后数据库大概长这样。
用户表:
create table customer (
id bigint primary key,
user_name varchar(64) not null,
mobile_cipher varchar(256) not null
);
检索表:
create table customer_mobile_idx (
customer_id bigint not null,
token char(64) not null,
primary key (customer_id, token),
index idx_mobile_token(token)
);
保存用户时,手机号只保存密文。
Java 里我会把“加密”和“搜索索引”彻底分开,不混成一个工具方法。
public void saveCustomer(CustomerCmd cmd) {
long customerId = customerIdGenerator.nextId();
String mobile = normalizeMobile(cmd.mobile());
String cipherText = mobileCipher.encrypt(mobile);
customerRepository.insert(
customerId,
cmd.userName(),
cipherText
);
buildSearchTokens(mobile).forEach(token ->
mobileIndexRepository.insert(customerId, token)
);
}
生成检索 token 的代码也不需要搞得很花。
private Set<String> buildSearchTokens(String mobile) {
int width = 4;
Set<String> tokens = new HashSet<>();
for (int start = 0; start + width <= mobile.length(); start++) {
String piece = mobile.substring(start, start + width);
tokens.add(searchSigner.sign(piece));
}
return tokens;
}
searchSigner 我不会直接用普通 SHA-256。
像手机号这种数据取值空间并不大,如果只是:
sha256("1388")
攻击者拿到数据库以后完全可以提前把 0000 ~ 9999 全算一遍,然后反查。
所以这里至少用 HMAC,并且搜索密钥不能放数据库里。
public final class SearchSigner {
private final SecretKeySpec key;
public SearchSigner(byte[] secret) {
this.key = new SecretKeySpec(secret, "HmacSHA256");
}
public String sign(String text) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
byte[] digest = mac.doFinal(
text.getBytes(StandardCharsets.UTF_8)
);
return HexFormat.of().formatHex(digest);
} catch (GeneralSecurityException e) {
throw new IllegalStateException("build search token failed", e);
}
}
}
现在用户搜索:
1388
Java 不需要解密数据库里的所有手机号,只要先计算:
String token = searchSigner.sign("1388");
然后:
select c.id, c.user_name, c.mobile_cipher
from customer c
join customer_mobile_idx i
on i.customer_id = c.id
where i.token = ?;
token 上有索引,这个查询和:
where mobile_cipher like '%1388%'
已经完全不是一个路子了。
查出候选记录以后,再在 Java 里解密。
public List<CustomerView> searchByMobile(String keyword) {
String input = normalizeMobile(keyword);
if (input.length() < 4) {
throw new IllegalArgumentException("手机号至少输入4位");
}
String firstToken = searchSigner.sign(
input.substring(0, 4)
);
List<CustomerEntity> candidates =
customerRepository.findByMobileToken(firstToken);
return candidates.stream()
.map(entity -> {
String mobile = mobileCipher.decrypt(entity.mobileCipher());
return new CustomerView(
entity.id(),
entity.userName(),
mobile
);
})
.filter(view -> view.mobile().contains(input))
.limit(100)
.toList();
}
注意最后这个:
mobile.contains(input)
我一般不会省。
因为旁边那张索引表负责的是快速缩小候选范围,真正判断匹不匹配,还是应该拿解密后的原文再校验一次。
尤其需求从“搜索任意连续 4 位”变成“任意长度模糊搜索”以后,这一步很重要。
比如用户输入:
138888
可以切成多个 4 位片段:
1388
3888
8888
三个 token 都命中的用户,才进入候选集。
SQL 也可以写成:
select customer_id
from customer_mobile_idx
where token in (?, ?, ?)
group by customer_id
having count(distinct token) = 3;
然后再解密候选手机号做一次:
mobile.contains("138888")
这么干,我比较放心。
因为数据库负责粗筛,Java 负责最终确认。
还有一种方案经常有人提:
确定性加密。
也就是同一手机号:
13888881234
不管加密多少次,永远得到相同密文。
这样:
where mobile_cipher = ?
确实能用了。
但注意,是:
=
不是:
like
它只能解决精确查询。
你搜完整手机号:
13888881234
可以先加密,再查询密文。
但你只输入:
8888
还是没法从完整密文里匹配出来。
所以精确查询和模糊查询最好别混为一谈。
我见过还有一种做法更直接:把手机号加密以后,再额外存:
mobile_last4
mobile_prefix3
例如:
mobile_cipher = AES(13888881234)
mobile_prefix3 = 138
mobile_last4 = 1234
如果业务需求永远只是:
手机号后四位查询
这种办法反而很实用。
没必要为了四位尾号查询搞一套复杂索引。
但安全要求高一点,这两个字段也不要直接裸存,可以继续做 HMAC:
mobile_last4_token = HMAC(1234)
然后直接等值查。
我做这种需求时一般先问产品一句:
你这个“模糊查询”,到底模糊到什么程度?
这句话非常重要。
有的需求嘴上说模糊搜索,真正用起来其实只有:
完整手机号搜索
手机号后四位搜索
身份证后六位搜索
邮箱完整匹配
这种直接做几个固定 HMAC 索引就行。
如果真要求:
任意位置
任意长度
中文姓名
手机号
邮箱
地址
全部支持模糊匹配,那我就不会继续往 MySQL 里硬塞了。
数据量大之后,检索本来就是搜索系统干的活。
可以设计成:
MySQL
└── 保存业务数据 + 加密原文
搜索索引
└── 保存经过脱敏、分词或者专门处理后的检索字段
但这时要注意另一件事:
搜索能力越强,泄露的信息一般也越多。
这一点很容易被忽略。
你给一个加密字段建立:
前缀索引
后缀索引
2-gram
3-gram
4-gram
检索是爽了,但攻击者拿到索引以后,能分析出的信息也越来越多。
哪怕用了 HMAC,也不代表完全没有信息泄露。
比如同一个 token 重复出现多少次,仍然可能暴露频率特征。
所以这件事没有一个“既能 AES 强加密,又能随便 LIKE,而且性能还跟明文一样”的魔法方案。
至少常规业务系统里,我不会这么承诺。
真正落地时,我一般按查询需求拆:
完整值查询
-> HMAC / 确定性方案,等值查询
固定前后缀
-> 单独建立 HMAC 检索字段
有限长度的包含查询
-> n-gram + HMAC 检索索引 + 原文二次校验
复杂全文检索
-> 独立搜索系统,重新评估数据暴露范围
完全没有查询需求
-> 只保存随机化加密后的密文
尤其别为了方便写出这种东西:
where decrypt(mobile_cipher) like '%8888%'
测试库几千条数据的时候,你会觉得挺好。
等表里数据多起来,再有人从后台输入一个 88,数据库开始一行一行解密,那场面就不太好看了。
加密字段要支持查询,通常都得接受一个现实:
真正被查询的,往往不是那份密文本身,而是你专门为查询另外设计的一份索引。
至于这份索引到底建到什么程度,不是 Java 技术问题。
得先看你究竟需要搜什么,以及这些查询能力值不值得拿额外的信息暴露去换。