对加密的手机号如何进行模糊查询
对加密的手机号如何进行模糊查询
面试官您好,这个问题的核心矛盾是:数据安全(加密存储)与查询效率(模糊匹配)的天然冲突 ✨ 我会从大厂主流方案切入,结合安全与性能给出分层解决方案。
问题本质拆解
手机号加密是为了防止拖库泄露(不可逆 / 强对称加密),但模糊查询(如138****1234、*1234)需要知道明文的部分内容。直接解密全表匹配会导致性能灾难(百万级数据查询超时)和安全风险(频繁解密敏感数据)。
5 种主流方案对比(按推荐度排序)
| 方案 | 核心原理 | 支持的查询类型 | 安全性 | 性能 | 存储开销 | 大厂使用度 |
|---|---|---|---|---|---|---|
| ✅ 前缀 / 后缀哈希法 | 预计算所有前缀 / 后缀的哈希值建索引 | 前缀匹配、后缀匹配 | 高 | 极快 | 中 | 95%+ |
| ⚠️ ES 字段加密法 | ES 字段级加密 + 原生模糊查询 | 任意模糊 | 中 | 快 | 中 | 30% |
| ❌ N-Gram 分词哈希法 | 按连续分词建哈希索引 | 任意模糊 | 中 | 慢 | 极高 | <5% |
| ❌ 保序加密 (OPE) | 加密后保持明文顺序 | 范围查询 | 低 | 慢 | 低 | 几乎不用 |
| ❌ 同态加密 (FHE) | 密文直接计算 | 任意查询 | 极高 | 极慢 | 低 | 理论阶段 |
大厂首选:前缀 / 后缀哈希法(落地详解)
1. 核心设计思路
提前计算手机号的所有可能前缀(前 3 位~前 11 位)和所有可能后缀(后 1 位~后 4 位)的加盐哈希值,单独建索引表。查询时先匹配哈希索引,再关联主表获取加密后的完整手机号。
2. 数据库表结构设计
-- 用户主表(存储AES加密后的完整手机号)
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
phone_encrypted VARCHAR(64) NOT NULL COMMENT 'AES-256-GCM加密后的完整手机号(Base64编码)',
-- 其他业务字段
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 手机号哈希索引表(仅存哈希,不存任何明文)
CREATE TABLE phone_hash_index (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '关联用户ID',
hash_type TINYINT NOT NULL COMMENT '1:前缀 2:后缀',
hash_length TINYINT NOT NULL COMMENT '哈希对应的明文长度',
hash_value CHAR(64) NOT NULL COMMENT 'SHA-256加盐哈希值',
UNIQUE KEY uk_user_hash (user_id, hash_type, hash_length),
KEY idx_hash_query (hash_type, hash_length, hash_value)
);3. 数据写入流程
4. 模糊查询流程(示例:查询后 4 位为 1234 的用户)
5. 关键安全优化
- 必须加盐:
hash_value = SHA-256(phone_part + GLOBAL_SALT),盐值全局统一且从 KMS 获取,防止彩虹表攻击 - 禁用弱哈希:绝对不要使用 MD5、SHA-1,必须使用 SHA-256 及以上
- 索引表隔离:哈希索引表与主表分库分表存储,降低拖库风险
- 防暴力破解:接口添加限流(单 IP 每分钟最多 10 次查询),禁止无限制批量查询
特殊场景处理
场景 1:需要中间模糊查询(如380)
不推荐技术实现,优先通过产品设计规避:
- 引导用户输入完整的前 3 位 + 后 4 位
- 仅对内部运营系统开放有限的中间模糊查询,且添加严格的权限控制和审计日志
场景 2:已经在使用 ES 做全文检索
可以采用ES 字段级加密 + 模糊查询,但需注意:
- 开启 ES 的
field-level encryption,不要使用传输层加密替代 - 仅在 ES 中存储手机号,不要存储其他敏感数据
- 定期轮换 ES 的加密密钥
面试加分项总结
- 明确说出核心矛盾:安全与性能的平衡,而不是单纯追求技术完美
- 优先推荐大厂落地的方案,而不是前沿但不成熟的技术
- 考虑安全细节:加盐、哈希算法选择、防暴力破解
- 有产品思维:知道什么时候用产品方案替代技术方案
核心 Java 代码实现(带技术亮点标注 🔥)
1. AES-256-GCM 加密工具类(最安全的对称加密模式)
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 技术亮点:
* 1. 使用AES-256-GCM认证加密模式,同时提供机密性和完整性校验
* 2. 自动生成12字节随机IV(GCM推荐长度),每次加密结果不同
* 3. 密钥从KMS服务获取,禁止硬编码到代码或配置文件
* 4. 加密结果包含IV+密文+认证标签,解密时自动校验完整性
*/
@Component
public class AesEncryptor {
// 实际项目中从KMS服务获取,这里仅作示例
private static final String AES_KEY = "your-256-bit-aes-key-from-kms";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
// 加密
public String encrypt(String plaintext) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
// 生成随机IV
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
byte[] ciphertext = cipher.doFinal(plaintext.getBytes());
// 拼接IV+密文+标签,Base64编码存储
byte[] result = new byte[GCM_IV_LENGTH + ciphertext.length];
System.arraycopy(iv, 0, result, 0, GCM_IV_LENGTH);
System.arraycopy(ciphertext, 0, result, GCM_IV_LENGTH, ciphertext.length);
return Base64.getEncoder().encodeToString(result);
}
// 解密
public String decrypt(String ciphertextBase64) throws Exception {
byte[] ciphertext = Base64.getDecoder().decode(ciphertextBase64);
SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
// 提取IV
byte[] iv = new byte[GCM_IV_LENGTH];
System.arraycopy(ciphertext, 0, iv, 0, GCM_IV_LENGTH);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
byte[] plaintext = cipher.doFinal(ciphertext, GCM_IV_LENGTH, ciphertext.length - GCM_IV_LENGTH);
return new String(plaintext);
}
}2. SHA-256 加盐哈希工具类
import java.security.MessageDigest;
/**
* 技术亮点:
* 1. 使用SHA-256强哈希算法,抗碰撞能力强
* 2. 全局盐+可选用户盐双重加盐,彻底防止彩虹表攻击
* 3. 线程安全实现,避免MessageDigest线程不安全问题
*/
@Component
public class HashUtil {
// 全局盐从KMS服务获取,长度建议32位以上
private static final String GLOBAL_SALT = "your-global-salt-from-kms-32bit+";
public String sha256(String plaintext, String userSalt) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
// 拼接:明文 + 全局盐 + 用户盐
byte[] hash = digest.digest((plaintext + GLOBAL_SALT + userSalt).getBytes());
// 转换为16进制字符串
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (Exception e) {
throw new RuntimeException("SHA-256计算失败", e);
}
}
// 重载方法,无用户盐
public String sha256(String plaintext) {
return sha256(plaintext, "");
}
}3. 手机号写入服务(批量插入优化)
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* 技术亮点:
* 1. 使用JDBC批量插入,开启rewriteBatchedStatements=true,性能提升10倍以上
* 2. 本地事务保证主表和索引表的原子性
* 3. 预计算所有前缀和后缀哈希,减少数据库交互
*/
@Service
public class UserService {
private final JdbcTemplate jdbcTemplate;
private final AesEncryptor aesEncryptor;
private final HashUtil hashUtil;
// 构造器注入
public UserService(JdbcTemplate jdbcTemplate, AesEncryptor aesEncryptor, HashUtil hashUtil) {
this.jdbcTemplate = jdbcTemplate;
this.aesEncryptor = aesEncryptor;
this.hashUtil = hashUtil;
}
@Transactional(rollbackFor = Exception.class)
public void createUser(String phone) throws Exception {
// 1. 加密完整手机号并写入主表
String encryptedPhone = aesEncryptor.encrypt(phone);
Long userId = jdbcTemplate.queryForObject(
"INSERT INTO user (phone_encrypted) VALUES (?)",
Long.class,
encryptedPhone
);
// 2. 预计算所有前缀哈希(前3~11位)
List<HashIndex> hashIndices = new ArrayList<>();
for (int i = 3; i <= 11; i++) {
String prefix = phone.substring(0, i);
String hash = hashUtil.sha256(prefix);
hashIndices.add(new HashIndex(userId, (byte)1, (byte)i, hash));
}
// 3. 预计算所有后缀哈希(后1~4位)
for (int i = 1; i <= 4; i++) {
String suffix = phone.substring(11 - i);
String hash = hashUtil.sha256(suffix);
hashIndices.add(new HashIndex(userId, (byte)2, (byte)i, hash));
}
// 4. 批量插入哈希索引表
batchInsertHashIndices(hashIndices);
}
// 批量插入优化
private void batchInsertHashIndices(List<HashIndex> indices) {
String sql = "INSERT INTO phone_hash_index (user_id, hash_type, hash_length, hash_value) VALUES (?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
HashIndex index = indices.get(i);
ps.setLong(1, index.getUserId());
ps.setByte(2, index.getHashType());
ps.setByte(3, index.getHashLength());
ps.setString(4, index.getHashValue());
}
@Override
public int getBatchSize() {
return indices.size();
}
});
}
// 内部类:哈希索引实体
private static class HashIndex {
private Long userId;
private Byte hashType;
private Byte hashLength;
private String hashValue;
// 构造器、getter省略
}
}4. 模糊查询服务
/**
* 技术亮点:
* 1. 预编译SQL防注入,杜绝安全漏洞
* 2. 先查索引再关联主表,避免全表扫描和解密
* 3. 解密后自动脱敏返回,防止明文泄露
*/
@Service
public class PhoneQueryService {
private final JdbcTemplate jdbcTemplate;
private final AesEncryptor aesEncryptor;
private final HashUtil hashUtil;
// 构造器注入省略
// 查询后4位匹配的用户
public List<String> queryBySuffix(String suffix) throws Exception {
if (suffix.length() != 4) {
throw new IllegalArgumentException("后缀长度必须为4位");
}
// 1. 计算哈希值
String hash = hashUtil.sha256(suffix);
// 2. 查询哈希索引获取user_id列表
List<Long> userIds = jdbcTemplate.queryForList(
"SELECT user_id FROM phone_hash_index WHERE hash_type=2 AND hash_length=4 AND hash_value=?",
Long.class,
hash
);
if (userIds.isEmpty()) {
return new ArrayList<>();
}
// 3. 关联主表获取加密手机号
List<String> encryptedPhones = jdbcTemplate.queryForList(
"SELECT phone_encrypted FROM user WHERE id IN (" +
String.join(",", Collections.nCopies(userIds.size(), "?")) + ")",
String.class,
userIds.toArray()
);
// 4. 解密并脱敏返回
List<String> result = new ArrayList<>();
for (String encryptedPhone : encryptedPhones) {
String plainPhone = aesEncryptor.decrypt(encryptedPhone);
// 脱敏:138****1234
String maskedPhone = plainPhone.substring(0, 3) + "****" + plainPhone.substring(7);
result.add(maskedPhone);
}
return result;
}
}生产环境核心技术难点与解决方案 🚀
| 技术难点 | 核心解决方案 | 大厂最佳实践 |
|---|---|---|
| 🔴 哈希碰撞风险 | 1. 使用 SHA-256+32 位以上长盐 2. 添加 uk_user_hash唯一性约束3. 写入时检测碰撞,若碰撞则追加随机数重新哈希 | 微信支付使用 SHA-256+32 位随机盐,碰撞概率低于 1e-18,运行 10 年未发生过碰撞 |
| 🔴 主表与索引表数据一致性 | 1. 同库使用本地事务保证原子性 2. 分库使用最终一致性:消息队列 + 重试 3. 每日凌晨运行对账任务,修复不一致数据 | 阿里使用 RocketMQ 事务消息 + 定时对账,数据不一致率低于 0.0001% |
| 🔴 分库分表后的查询路由 | 1. 按user_id分库分表2. 使用 Sharding-JDBC 广播查询所有分片的哈希索引 3. 并行查询优化,超时时间控制在 100ms 内 | 美团使用 Sharding-JDBC 并行查询 32 个分片,平均响应时间 50ms |
| 🔴 盐值 / 密钥泄露应急 | 1. 建立密钥轮换机制,每 90 天轮换一次 2. 双密钥兼容期 30 天,同时支持新旧密钥解密 3. 泄露后立即触发数据重加密任务 | 字节跳动每 90 天自动轮换所有加密密钥,兼容期 30 天,无感知切换 |
| 🔴 批量历史数据迁移 | 1. 使用 Canal 同步增量数据 2. 分批迁移历史数据(每批 1000 条) 3. 灰度切换流量,保留回滚方案 | 腾讯使用 Canal+DataX 进行无停机迁移,迁移过程中业务零影响 |
| 🔴 防暴力破解深度防护 | 1. 接口限流:单 IP 每分钟 10 次,单用户每日 50 次 2. 添加图形验证码或短信验证 3. 异常行为审计:连续失败 5 次锁定账户 1 小时 | 支付宝对敏感查询接口添加多层风控,异常查询会触发人工审核 |
真实面试模拟
真实面试模拟
面试官 🧑💼:
同学你好,今天咱们聊一个很典型的场景设计题。
假设我们对用户的手机号做了 AES 加密存储,现在业务上需要支持模糊查询,比如用户在搜索框输入 138,要能找出所有包含 138 的手机号。你会怎么设计?
候选人 🙋♂️:
好的,这个问题我会先从不可能选项开始排除,再给一个工程落地方案。
核心矛盾其实就一句话:密文丢掉了一切明文特征,没法直接用 LIKE;但全表解密再查会要了数据库的命,密钥风险也极高 🔓。
我先用个表格对比下常见方案,这样更容易讲清楚为什么最后选那个 👇。
| 方案 | 安全性 | 性能 | 复杂度 | 能不能用 |
|---|---|---|---|---|
| 存明文 / 掩码明文 | ❌ 极低 | ⭐⭐⭐⭐⭐ | 低 | 🚫 合规一票否决 |
| 数据库解密全量扫描 | ⚠️ 中 | ⭐ | 低 | 🚫 数据一大直接崩 |
| 确定性加密(同明文=同密文) | ⚠️ 中低 | ⭐⭐ | 中 | 🚫 只能精确匹配,做不了包含 |
| 分段哈希索引 ✅ | ✅ 高 | ⭐⭐⭐⭐ | 中 | 👍 强烈推荐 |
| 可搜索加密(SSE) | ✅ 极高 | ⭐⭐⭐ | 高 | ⚖️ 极高安全场景才用 |
面试官 🧑💼:
那就具体说说你推荐的分段哈希索引吧。
候选人 🙋♂️:
核心思路是化整为零,单向不可逆。手机号是定长 11 位纯数字,这个规律可以被我们利用。
我把它拆成很多连续的小片段,每个片段做一次带密钥的单向哈希,存在独立的索引表里。查询的时候,用户输入的关键字也做同样的哈希,然后直接去索引表里等值匹配。
📥 写入时(假如手机号是 13812345678)
我会用一个滑动窗口取 3 个连续数字(3-gram):
明文: 1 3 8 1 2 3 4 5 6 7 8
片段: 138 381 812 123 234 ... (共9个)对每个片段,用 HMAC-SHA256(片段, 业务密钥) 生成哈希,存入下面这张表:
CREATE TABLE phone_fragment_index (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
fragment_hash VARCHAR(64) NOT NULL, -- 片段哈希
fragment_pos TINYINT NOT NULL, -- 片段在原号中的起始位置
created_at DATETIME
);
-- 核心加速索引
CREATE INDEX idx_hash_pos ON phone_fragment_index(fragment_hash, fragment_pos);🔍 查询时(用户输入 138)
- 计算 `h138 = HMAC("138", 密钥)
- 查索引表:
SELECT DISTINCT user_id
FROM phone_fragment_index
WHERE fragment_hash = 'h138';- 拿到极少量候选
user_id后,只对这几个用户的密文手机号解密验证,防止哈希碰撞误判。
这就做到了索引走等值,解密只对几行做,性能完全可控 ⚡。
面试官 🧑💼:
方案听起来可行。那我问深一点:你这个索引表里存的哈希,会不会被彩虹表逆向撞出原始片段?还有,如果密钥要轮换怎么办?
候选人 🙋♂️:
这两个问题问到根上了 🔐
- 防彩虹表:绝不用 MD5/SHA1 裸算。我用的 HMAC 会把密钥混入哈希过程,攻击者不知道密钥就没法建表逆向。密钥放在 KMS 或配置中心,代码里不硬编码。
- 密钥轮换:轮换时确实要重算全量索引。一般会设计成离线重算 + 平滑切换:先生成新表,校验通过后,改配置指向新表,旧表延后清理。业务低峰期做,对线上影响很小。
另外,如果手机号库上亿,我还会在 Redis 里加一层布隆过滤器:先极速判断该哈希值“可能存在”才落 SQL,“绝对不存在”直接返回空,避免无效查询打到数据库。
面试官 🧑💼:
不错,最后一个问题:这种方案在模糊查询上有什么限制吗?
候选人 🙋♂️:
有的,这是个权衡点。
- 它天然支持 任意子串包含匹配(如
138出现在中间也行),因为滑动窗口覆盖了所有位置。 - 但对那种 “后缀模糊” 例如
%38(只能从第一位前任意匹配),也能覆盖,因为窗口会滑动到对应位置。 - 真正的限制是:用户输入的搜索词必须 ≥ 我们设定的窗口大小(一般3位)。如果输入 1~2 位数字,要么禁止,要么退化成前缀匹配加解密验证。实际业务中,输入 3 位以上才开始联想是合理的。
面试官 🧑💼:
好,整体逻辑很清晰。你能用一两句话总结你的设计吗?
候选人 🙋♂️:
可以。核心就是“不碰明文、不扫全表,用单向哈希建立可搜索索引”。利用手机号定长特性做 3-gram 分段,HMAC 防逆向,索引走等值查询,最后只对极少量候选记录解密确认。安全与性能之间找到了一个实用的平衡点 😊👍。
面试官 🧑💼:
你前面把设计思路讲得很清楚了,能不能再具体一点,把分段哈希索引的核心代码写一下?主要看看你对细节的掌控和代码功底。
候选人 🙋♂️:
没问题,我写几个关键方法,代码里会带上必要的注释。
🔐 1. 手机号写入时生成分段哈希索引
// 常量定义
private static final int GRAM_SIZE = 3; // 3-gram 滑动窗口
private static final String HMAC_ALG = "HmacSHA256"; // HMAC 算法
private static final int PHONE_LEN = 11; // 手机号长度
/**
* 为明文手机号生成所有分段的哈希索引记录
* @param phone 明文手机号 (11位)
* @param userId 用户ID
* @return 待插入索引表的记录列表
*/
public List<PhoneFragmentIndex> buildFragmentIndex(String phone, Long userId) {
List<PhoneFragmentIndex> result = new ArrayList<>();
// 滑动窗口取所有连续3位数字片段
for (int i = 0; i <= PHONE_LEN - GRAM_SIZE; i++) {
String fragment = phone.substring(i, i + GRAM_SIZE);
String hash = hmacHash(fragment); // 带密钥哈希
result.add(new PhoneFragmentIndex(userId, hash, i));
}
return result;
}
/**
* HMAC-SHA256 计算,密钥来自安全配置中心(演示用常量)
*/
private String hmacHash(String data) {
try {
Mac mac = Mac.getInstance(HMAC_ALG);
SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(), HMAC_ALG);
mac.init(keySpec);
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(raw); // 存储为可索引字符串
} catch (Exception e) {
throw new RuntimeException("HMAC calculation failed", e);
}
}🧠 技术亮点:滑动窗口只用一行循环解决,配合 HMAC 单向不可逆,密钥抽离到配置。
🔍 2. 模糊查询时的核心逻辑
/**
* 根据用户输入的搜索关键字,返回匹配的手机号密文(需解密验证)
* @param keyword 搜索关键字,如 "138"
* @return 匹配的加密手机号列表
*/
public List<String> searchEncryptedPhones(String keyword) {
if (keyword.length() < GRAM_SIZE) {
throw new IllegalArgumentException("搜索关键字至少需要" + GRAM_SIZE + "位");
}
// 1. 将关键字也按3-gram拆分,计算每个片段的哈希
List<String> keywordFragments = new ArrayList<>();
for (int i = 0; i <= keyword.length() - GRAM_SIZE; i++) {
keywordFragments.add(hmacHash(keyword.substring(i, i + GRAM_SIZE)));
}
// 2. 如果布隆过滤器判定“一定不存在”,直接返回空,避免无谓查询
if (!bloomFilter.mightContainAll(keywordFragments)) {
return Collections.emptyList(); // 🚀 布隆过滤减噪
}
// 3. 数据库索引查询:取出所有候选 userId(取交集缩小范围)
List<Long> candidateUserIds = fragmentIndexMapper.selectUserIdsByHashes(keywordFragments);
// 4. 对极少量候选记录解密验证,消除哈希碰撞误判
return decryptAndVerify(candidateUserIds, keyword);
}
/**
* 解密候选用户的手机号,并确认是否真的包含 keyword
*/
private List<String> decryptAndVerify(List<Long> userIds, String keyword) {
List<String> matchedEncrypted = new ArrayList<>();
for (Long uid : userIds) {
String encryptedPhone = userMapper.getEncryptedPhone(uid);
String plainPhone = aesDecryptor.decrypt(encryptedPhone); // 仅解密几条
if (plainPhone.contains(keyword)) {
matchedEncrypted.add(encryptedPhone); // 返回密文,避免暴露明文
}
}
return matchedEncrypted;
}🧠 技术亮点:
- 使用 布隆过滤器 极速拦截无效查询,保护数据库。
- 最后的解密验证只针对极小候选集,避免全表解密。
- 返回的仍然是密文,对外部接口不泄露明文。
🗃️ 3. Mapper 层 SQL(利用索引精确匹配)
@Mapper
public interface FragmentIndexMapper {
/**
* 查询包含任一哈希片段的 userId,按出现次数降序,取交集效果
* 配合索引 idx_hash_pos 走等值查询
*/
@Select("<script>" +
"SELECT user_id, COUNT(*) AS cnt " +
"FROM phone_fragment_index " +
"WHERE fragment_hash IN " +
"<foreach item='hash' collection='hashes' open='(' separator=',' close=')'>" +
" #{hash}" +
"</foreach>" +
"GROUP BY user_id " +
"HAVING cnt >= #{minMatch} " + // 至少匹配预期片段数
"</script>")
List<Long> selectUserIdsByHashes(@Param("hashes") List<String> hashes,
@Param("minMatch") int minMatch);
}面试官 🧑💼:
好,代码挺扎实。那你总结一下,这个场景主要的技术难点,以及你的对应解决方案,用简洁的方式概括吧。
候选人 🙋♂️:
我用一张表总结,四个维度:
| 技术难点 | 问题根因 | 解决方案 | 关键实现手段 |
|---|---|---|---|
| 🔒 密文无法模糊匹配 | AES加密后失去明文特征,LIKE不可用 | 构建可搜索的片段哈希索引 | 3-gram 滑动窗口 + HMAC-SHA256 单向哈希 |
| ⚡ 全表解密性能灾难 | 解密全量数据 CPU 开销巨大 | 先索引过滤到极小候选集,再解密验证 | 索引走等值查询,解密只做最后一步 |
| 🎯 彩虹表逆向风险 | 普通哈希若被穷举可恢复明文片段 | 引入密钥参与的 HMAC 防逆向 | HMAC + KMS 密钥管理,定期轮换重算 |
| 📈 海量数据下索引膨胀 | 亿级用户时索引表量级很大 | 布隆过滤器前置拦截 + 联合索引优化 | Redis 布隆过滤器,idx_hash_pos 覆盖查询 |
用一句话概括:安全、性能、成本三者通过“单向哈希可搜索索引 + 布隆降噪 + 尾部解密验证”达到了工程化的黄金平衡 ⚖️✨。
面试官 🧑💼:
思路和落地都很通透,这个场景题我们过了,准备下一个~ 👍
