对加密的手机号如何进行模糊查询

对加密的手机号如何进行模糊查询

这个问题的核心矛盾是:数据安全(加密存储)与查询效率(模糊匹配)的天然冲突 ✨ 本文结合安全与性能给出分层解决方案。

问题本质拆解

手机号加密是为了防止拖库泄露 (不可逆 / 强对称加密),但模糊查询(如138****1234*1234)需要知道明文的部分内容。直接解密全表匹配会导致性能灾难 (百万级数据查询超时)和安全风险(频繁解密敏感数据)。

5 种主流方案对比(按推荐度排序)

方案 核心原理 支持的查询类型 安全性 性能 存储开销 大厂使用度
前缀 / 后缀哈希法 预计算所有前缀 / 后缀的哈希值建索引 前缀匹配、后缀匹配 极快 95%+
⚠️ ES 字段加密法 ES 字段级加密 + 原生模糊查询 任意模糊 30%
❌ N-Gram 分词哈希法 按连续分词建哈希索引 任意模糊 极高 <5%
❌ 保序加密 (OPE) 加密后保持明文顺序 范围查询 几乎不用
❌ 同态加密 (FHE) 密文直接计算 任意查询 极高 极慢 理论阶段

关于这个问题的底层原理和更多实战细节,我整理了一份《大厂面试手册》,包含大厂高频面试题、源码解析和性能调优案例。

关注公众号【Rain的Java大神之路】,回复"Java"即可免费领取,持续更新中。


大厂首选:前缀 / 后缀哈希法(落地详解)

1. 核心设计思路

提前计算手机号的所有可能前缀 (前 3 位~前 11 位)和所有可能后缀(后 1 位~后 4 位)的加盐哈希值,单独建索引表。查询时先匹配哈希索引,再关联主表获取加密后的完整手机号。

2. 数据库表结构设计

sql 复制代码
-- 用户主表(存储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. 关键安全优化

  1. 必须加盐hash_value = SHA-256(phone_part + GLOBAL_SALT),盐值全局统一且从 KMS 获取,防止彩虹表攻击
  2. 禁用弱哈希:绝对不要使用 MD5、SHA-1,必须使用 SHA-256 及以上
  3. 索引表隔离:哈希索引表与主表分库分表存储,降低拖库风险
  4. 防暴力破解:接口添加限流(单 IP 每分钟最多 10 次查询),禁止无限制批量查询

特殊场景处理

场景 1:需要中间模糊查询(如380

不推荐技术实现,优先通过产品设计规避:

  • 引导用户输入完整的前 3 位 + 后 4 位
  • 仅对内部运营系统开放有限的中间模糊查询,且添加严格的权限控制和审计日志

场景 2:已经在使用 ES 做全文检索

可以采用ES 字段级加密 + 模糊查询,但需注意:

  • 开启 ES 的field-level encryption,不要使用传输层加密替代
  • 仅在 ES 中存储手机号,不要存储其他敏感数据
  • 定期轮换 ES 的加密密钥

面试加分项总结

  1. 明确说出核心矛盾:安全与性能的平衡,而不是单纯追求技术完美
  2. 优先推荐大厂落地的方案,而不是前沿但不成熟的技术
  3. 考虑安全细节:加盐、哈希算法选择、防暴力破解
  4. 有产品思维:知道什么时候用产品方案替代技术方案

核心 Java 代码实现(带技术亮点标注 🔥)

1. AES-256-GCM 加密工具类(最安全的对称加密模式)

java 复制代码
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 加盐哈希工具类

java 复制代码
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. 手机号写入服务(批量插入优化)

java 复制代码
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. 模糊查询服务

java 复制代码
/**
 * 技术亮点:
 * 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):

text 复制代码
明文:  1 3 8 1 2 3 4 5 6 7 8
片段: 138  381  812  123  234  ...  (共9个)

对每个片段,用 HMAC-SHA256(片段, 业务密钥) 生成哈希,存入下面这张表:

sql 复制代码
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

  1. 计算 `h138 = HMAC("138", 密钥)
  2. 查索引表:
sql 复制代码
SELECT DISTINCT user_id 
FROM phone_fragment_index 
WHERE fragment_hash = 'h138';
  1. 拿到极少量候选 user_id 后,只对这几个用户的密文手机号解密验证,防止哈希碰撞误判。

这就做到了索引走等值,解密只对几行做,性能完全可控 ⚡。

面试官 🧑‍💼:

方案听起来可行。那我问深一点:你这个索引表里存的哈希,会不会被彩虹表逆向撞出原始片段?还有,如果密钥要轮换怎么办?

候选人 🙋‍♂️:

这两个问题问到根上了 🔐

  1. 防彩虹表 :绝不用 MD5/SHA1 裸算。我用的 HMAC 会把密钥混入哈希过程,攻击者不知道密钥就没法建表逆向。密钥放在 KMS 或配置中心,代码里不硬编码。
  2. 密钥轮换 :轮换时确实要重算全量索引。一般会设计成离线重算 + 平滑切换:先生成新表,校验通过后,改配置指向新表,旧表延后清理。业务低峰期做,对线上影响很小。

另外,如果手机号库上亿,我还会在 Redis 里加一层布隆过滤器:先极速判断该哈希值"可能存在"才落 SQL,"绝对不存在"直接返回空,避免无效查询打到数据库。

面试官 🧑‍💼:

不错,最后一个问题:这种方案在模糊查询上有什么限制吗?

候选人 🙋‍♂️:

有的,这是个权衡点。

  • 它天然支持 任意子串包含匹配 (如 138 出现在中间也行),因为滑动窗口覆盖了所有位置。
  • 但对那种 "后缀模糊 " 例如 %38(只能从第一位前任意匹配),也能覆盖,因为窗口会滑动到对应位置。
  • 真正的限制是:用户输入的搜索词必须 ≥ 我们设定的窗口大小(一般3位)。如果输入 1~2 位数字,要么禁止,要么退化成前缀匹配加解密验证。实际业务中,输入 3 位以上才开始联想是合理的。

面试官 🧑‍💼:

好,整体逻辑很清晰。你能用一两句话总结你的设计吗?

候选人 🙋‍♂️:

可以。核心就是"不碰明文、不扫全表,用单向哈希建立可搜索索引"。利用手机号定长特性做 3-gram 分段,HMAC 防逆向,索引走等值查询,最后只对极少量候选记录解密确认。安全与性能之间找到了一个实用的平衡点 😊👍。

面试官 🧑‍💼:

你前面把设计思路讲得很清楚了,能不能再具体一点,把分段哈希索引的核心代码写一下?主要看看你对细节的掌控和代码功底。

候选人 🙋‍♂️:

没问题,我写几个关键方法,代码里会带上必要的注释。

🔐 1. 手机号写入时生成分段哈希索引

java 复制代码
// 常量定义
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. 模糊查询时的核心逻辑

java 复制代码
/**
 * 根据用户输入的搜索关键字,返回匹配的手机号密文(需解密验证)
 * @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(利用索引精确匹配)

java 复制代码
@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 覆盖查询

用一句话概括:安全、性能、成本三者通过"单向哈希可搜索索引 + 布隆降噪 + 尾部解密验证"达到了工程化的黄金平衡 ⚖️✨。

面试官 🧑‍💼:

思路和落地都很通透,这个场景题我们过了,准备下一个~ 👍


如果本文对你有帮助,欢迎关注我的公众号【Rain的Java大神之路】。

专注 Java 面试、源码、高并发实战,回复"Java"领取《大厂面试手册》,持续更新。

相关推荐
Rain的Java大神实战圈1 天前
如何自定义一个MyBatis插件
场景设计题
Rain的Java大神实战圈2 天前
高并发下的抽奖系统:不只是扣库存,还要防作弊与风控
场景设计题
Rain的Java大神实战圈5 天前
高并发下的秒杀系统架构全景:从单体到云原生的演进之路
场景设计题
Rain的Java大神实战圈6 天前
Spring事务失效的场景有哪些
场景设计题
Rain的Java大神实战圈7 天前
高并发下的排行榜系统:如何用Redis快速实现千万用户实时排名
场景设计题
Rain的Java大神实战圈8 天前
手写一个Spring容器
场景设计题
Rain的Java大神实战圈12 天前
JavaWeb开发如何解决跨域问题
场景设计题
Rain的Java大神实战圈13 天前
高并发下的热点账户余额扣减:Redis+Lua脚本实现无锁记账
场景设计题
Rain的Java大神实战圈14 天前
介绍一下常用的Token鉴权方案
场景设计题