一、问题本质

核心矛盾

  • 加密后数据是密文(如:aB3dF9...),无法直接LIKE查询
  • 业务需要支持模糊搜索(如:搜索手机号 138****1234

示例

-- 加密前(可模糊查询)
SELECT * FROM users WHERE phone LIKE '138%';

-- 加密后(无法查询)
SELECT * FROM users WHERE phone LIKE '138%';  -- phone存储的是密文,查不到

二、解决方案对比

方案 安全性 查询性能 实现复杂度 适用场景
部分加密 ⭐⭐ ⭐⭐⭐⭐⭐ 手机号、身份证前缀查询
哈希映射表 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐ 精确+模糊混合查询
全文分词索引 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 姓名、地址模糊搜索
可搜索加密(SSE) ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ 高安全要求场景
应用层解密过滤 ⭐⭐⭐⭐ 数据量小的场景

三、方案详解

方案1:部分加密(折中方案)

核心思路:保留前几位明文用于查询,后面部分加密。

实现:手机号加密

@Component
public class PartialEncryptor {
    
    @Autowired
    private AESEncryptor aesEncryptor;
    
    /**
     * 手机号加密:保留前3位和后4位,中间4位加密
     */
    public String encryptPhone(String phone) {
        if (phone == null || phone.length() != 11) {
            throw new IllegalArgumentException("无效的手机号");
        }
        
        String prefix = phone.substring(0, 3);   // 138
        String middle = phone.substring(3, 7);   // 1234
        String suffix = phone.substring(7);      // 5678
        
        // 只加密中间4位
        String encryptedMiddle = aesEncryptor.encrypt(middle);
        
        return prefix + "_" + encryptedMiddle + "_" + suffix;
        // 存储格式:138_a3bF9dK==_5678
    }
    
    public String decryptPhone(String encrypted) {
        String[] parts = encrypted.split("_");
        String prefix = parts[0];
        String encryptedMiddle = parts[1];
        String suffix = parts[2];
        
        String middle = aesEncryptor.decrypt(encryptedMiddle);
        
        return prefix + middle + suffix;  // 13812345678
    }
}

SQL查询

-- 前缀查询(可使用索引)
SELECT * FROM users WHERE phone LIKE '138%';

-- 后缀查询(需要全表扫描)
SELECT * FROM users WHERE phone LIKE '%5678';

-- 完整号码查询
SELECT * FROM users WHERE phone = '138_a3bF9dK==_5678';

表结构优化

CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100),
    phone VARCHAR(500),
    phone_prefix CHAR(3) GENERATED ALWAYS AS (LEFT(phone, 3)) STORED,  -- 虚拟列
    INDEX idx_phone_prefix (phone_prefix)
);

-- 查询优化
SELECT * FROM users WHERE phone_prefix = '138';

优势与劣势

  • ✅ 实现简单
  • ✅ 支持前缀查询,性能好
  • ✅ 适合手机号、身份证等结构化数据
  • ❌ 前缀明文存储,安全性降低
  • ❌ 不支持中间部分模糊查询

方案2:哈希映射表

核心思路:为常用查询条件建立哈希索引表。

实现:手机号搜索

@Component
public class HashIndexService {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    /**
     * 保存用户时,同时建立哈希索引
     */
    @Transactional
    public void saveUserWithIndex(User user) {
        // 1. 加密完整手机号
        String encryptedPhone = aesEncryptor.encrypt(user.getPhone());
        
        // 2. 插入用户表
        jdbcTemplate.update(
            "INSERT INTO users (name, phone) VALUES (?, ?)",
            user.getName(), encryptedPhone
        );
        Long userId = getLastInsertId();
        
        // 3. 建立前缀哈希索引
        List<String> prefixes = generatePrefixes(user.getPhone());
        for (String prefix : prefixes) {
            String hash = hash(prefix);
            
            jdbcTemplate.update(
                "INSERT INTO phone_index (hash, user_id) VALUES (?, ?)",
                hash, userId
            );
        }
    }
    
    /**
     * 生成所有可能的前缀
     */
    private List<String> generatePrefixes(String phone) {
        List<String> prefixes = new ArrayList<>();
        
        // 生成3-11位的所有前缀
        for (int i = 3; i <= phone.length(); i++) {
            prefixes.add(phone.substring(0, i));
        }
        
        return prefixes;
        // 例如:138, 1381, 13812, 138123, ..., 13812345678
    }
    
    /**
     * 哈希函数(使用HMAC-SHA256)
     */
    private String hash(String input) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec keySpec = new SecretKeySpec(
                "hash_secret_key".getBytes(), "HmacSHA256"
            );
            mac.init(keySpec);
            
            byte[] hash = mac.doFinal(input.getBytes());
            return Base64.getEncoder().encodeToString(hash);
        } catch (Exception e) {
            throw new RuntimeException("哈希失败", e);
        }
    }
    
    /**
     * 模糊查询
     */
    public List<User> searchByPhone(String phonePrefix) {
        // 1. 计算查询前缀的哈希
        String queryHash = hash(phonePrefix);
        
        // 2. 从索引表查询user_id
        List<Long> userIds = jdbcTemplate.queryForList(
            "SELECT user_id FROM phone_index WHERE hash = ?",
            Long.class, queryHash
        );
        
        if (userIds.isEmpty()) {
            return Collections.emptyList();
        }
        
        // 3. 查询用户详情
        String sql = "SELECT * FROM users WHERE id IN (" +
            userIds.stream().map(String::valueOf).collect(Collectors.joining(",")) +
            ")";
        
        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
        
        // 4. 解密并过滤(防止哈希碰撞)
        return rows.stream()
            .map(this::mapToUser)
            .filter(user -> user.getPhone().startsWith(phonePrefix))
            .collect(Collectors.toList());
    }
    
    private User mapToUser(Map<String, Object> row) {
        User user = new User();
        user.setId((Long) row.get("id"));
        user.setName((String) row.get("name"));
        user.setPhone(aesEncryptor.decrypt((String) row.get("phone")));
        return user;
    }
}

表结构

-- 用户表
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100),
    phone VARCHAR(500)  -- 存储加密后的完整手机号
);

-- 哈希索引表
CREATE TABLE phone_index (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    hash VARCHAR(100),   -- 前缀的哈希值
    user_id BIGINT,
    INDEX idx_hash (hash),
    INDEX idx_user (user_id)
);

优势与劣势

  • ✅ 完整加密,安全性高
  • ✅ 支持任意长度前缀查询
  • ✅ 查询性能好(索引支持)
  • ❌ 存储空间增加(每个手机号存9个哈希)
  • ❌ 实现复杂
  • ❌ 哈希可能碰撞(需二次过滤)

方案3:全文分词索引(适合中文姓名/地址)

核心思路:对敏感数据进行脱敏分词,建立全文索引。

实现:姓名模糊搜索

@Component
public class TokenizedSearchService {
    
    /**
     * 姓名分词
     */
    public List<String> tokenizeName(String name) {
        List<String> tokens = new ArrayList<>();
        
        // 单字分词
        for (int i = 0; i < name.length(); i++) {
            tokens.add(String.valueOf(name.charAt(i)));
        }
        
        // 双字分词
        for (int i = 0; i < name.length() - 1; i++) {
            tokens.add(name.substring(i, i + 2));
        }
        
        // 全名
        tokens.add(name);
        
        return tokens;
        // 例如:"张三丰" → ["张", "三", "丰", "张三", "三丰", "张三丰"]
    }
    
    /**
     * 保存用户
     */
    @Transactional
    public void saveUser(User user) {
        // 1. 加密姓名
        String encryptedName = aesEncryptor.encrypt(user.getName());
        
        jdbcTemplate.update(
            "INSERT INTO users (name, name_encrypted) VALUES (?, ?)",
            user.getName(), encryptedName
        );
        Long userId = getLastInsertId();
        
        // 2. 生成搜索tokens(脱敏处理)
        List<String> tokens = tokenizeName(user.getName());
        
        for (String token : tokens) {
            // 对token进行哈希(防止明文泄露)
            String tokenHash = hash(token);
            
            jdbcTemplate.update(
                "INSERT INTO name_tokens (token_hash, user_id) VALUES (?, ?)",
                tokenHash, userId
            );
        }
    }
    
    /**
     * 模糊查询
     */
    public List<User> searchByName(String keyword) {
        String keywordHash = hash(keyword);
        
        List<Long> userIds = jdbcTemplate.queryForList(
            "SELECT DISTINCT user_id FROM name_tokens WHERE token_hash = ?",
            Long.class, keywordHash
        );
        
        // 查询并解密
        return getUsersByIds(userIds);
    }
}

表结构

CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100),           -- 明文或脱敏(如"张**")
    name_encrypted VARCHAR(500), -- 完整加密
    INDEX idx_name (name)        -- 脱敏字段可建索引
);

CREATE TABLE name_tokens (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    token_hash VARCHAR(100),
    user_id BIGINT,
    INDEX idx_token (token_hash),
    INDEX idx_user (user_id)
);

使用ElasticSearch增强

@Document(indexName = "users_search")
public class UserSearchDoc {
    @Id
    private String id;
    
    private String nameHash;  // 姓名哈希(用于精确匹配)
    
    @Field(type = FieldType.Text, analyzer = "ik_smart")
    private String nameTokens;  // 姓名分词(用于模糊搜索)
}

// 搜索
public List<User> search(String keyword) {
    // 1. 从ES搜索
    List<String> userIds = elasticsearchTemplate.search(
        NativeSearchQueryBuilder
            .withQuery(QueryBuilders.matchQuery("nameTokens", keyword))
            .build(),
        UserSearchDoc.class
    ).stream()
        .map(SearchHit::getContent)
        .map(UserSearchDoc::getId)
        .collect(Collectors.toList());
    
    // 2. 从数据库查询完整数据并解密
    return getUsersByIds(userIds);
}

方案4:应用层全表解密过滤(小数据量)

@Service
public class BruteForceSearchService {
    
    /**
     * 适合数据量 < 10万的场景
     */
    public List<User> searchByPhoneSuffix(String suffix) {
        // 1. 查询所有用户
        List<Map<String, Object>> rows = jdbcTemplate.queryForList(
            "SELECT id, name, phone FROM users"
        );
        
        // 2. 并行解密并过滤
        return rows.parallelStream()
            .map(row -> {
                User user = new User();
                user.setId((Long) row.get("id"));
                user.setName((String) row.get("name"));
                user.setPhone(aesEncryptor.decrypt((String) row.get("phone")));
                return user;
            })
            .filter(user -> user.getPhone().contains(suffix))
            .collect(Collectors.toList());
    }
}

优化:分页查询

public List<User> searchByPhoneSuffixPaged(String suffix) {
    Long lastId = 0L;
    int batchSize = 1000;
    List<User> results = new ArrayList<>();
    
    while (results.size() < 100) {  // 最多返回100条
        List<Map<String, Object>> rows = jdbcTemplate.queryForList(
            "SELECT * FROM users WHERE id > ? ORDER BY id LIMIT ?",
            lastId, batchSize
        );
        
        if (rows.isEmpty()) {
            break;
        }
        
        // 解密并过滤
        List<User> batch = rows.parallelStream()
            .map(this::mapToUser)
            .filter(user -> user.getPhone().endsWith(suffix))
            .collect(Collectors.toList());
        
        results.addAll(batch);
        lastId = (Long) rows.get(rows.size() - 1).get("id");
    }
    
    return results.stream().limit(100).collect(Collectors.toList());
}

方案5:可搜索加密(SSE)

原理:使用确定性加密(Deterministic Encryption)。

@Component
public class DeterministicEncryptor {
    
    /**
     * 确定性加密:相同明文 → 相同密文
     */
    public String encryptDeterministic(String plainText, String searchKey) {
        try {
            // 使用ECB模式(不推荐用于一般场景,但适合可搜索加密)
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
            SecretKeySpec keySpec = new SecretKeySpec(
                searchKey.getBytes(), "AES"
            );
            cipher.init(Cipher.ENCRYPT_MODE, keySpec);
            
            byte[] encrypted = cipher.doFinal(plainText.getBytes());
            return Base64.getEncoder().encodeToString(encrypted);
            
        } catch (Exception e) {
            throw new RuntimeException("加密失败", e);
        }
    }
    
    /**
     * 模糊查询实现
     */
    public List<User> searchByPhonePrefix(String prefix) {
        // 生成前缀的所有可能补全(如果可枚举)
        List<String> candidates = generateCandidates(prefix);
        
        // 对每个候选进行确定性加密
        List<String> encryptedCandidates = candidates.stream()
            .map(c -> encryptDeterministic(c, "search_key"))
            .collect(Collectors.toList());
        
        // 批量查询
        String sql = "SELECT * FROM users WHERE phone IN (" +
            encryptedCandidates.stream()
                .map(c -> "'" + c + "'")
                .collect(Collectors.joining(",")) +
            ")";
        
        return jdbcTemplate.query(sql, new UserRowMapper());
    }
    
    private List<String> generateCandidates(String prefix) {
        // 示例:13812 → 138120, 138121, ..., 138129, 1381200, ...
        // 实际场景需根据业务规则限制枚举范围
        return IntStream.range(0, 10000)
            .mapToObj(i -> prefix + String.format("%04d", i))
            .collect(Collectors.toList());
    }
}

优势与劣势

  • ✅ 支持等值查询
  • ✅ 安全性较高
  • ❌ 仍存在频率分析攻击风险
  • ❌ 模糊查询需要枚举候选(开销大)

四、方案选型建议

场景1:手机号查询

推荐方案:部分加密 或 哈希映射表

// 示例:保留前3位+后4位明文
String phone = "13812345678";
String stored = "138_" + encrypt("1234") + "_5678";

// 查询
SELECT * FROM users WHERE phone LIKE '138%' AND phone LIKE '%5678';

场景2:身份证号查询

推荐方案:部分加密(保留前6位地区码)

-- 存储格式:110101_encrypted_1234
SELECT * FROM users WHERE id_card LIKE '110101%';  -- 按地区查询

场景3:姓名模糊搜索

推荐方案:全文分词索引 + ElasticSearch

// 1. 数据库存储加密数据
// 2. ES存储姓名哈希的分词
// 3. ES搜索 → 返回ID → 数据库查询 → 解密

场景4:地址模糊搜索

推荐方案:分级存储

@Data
public class Address {
    private String province;   // 明文(可查询)
    private String city;       // 明文(可查询)
    private String district;   // 明文(可查询)
    private String detail;     // 加密(详细地址)
}

// 查询
SELECT * FROM addresses 
WHERE province = '北京' AND city = '海淀区'
AND detail LIKE ...;  // 应用层解密后过滤

场景5:极高安全要求

推荐方案:完全加密 + 应用层解密过滤

// 不支持数据库层模糊查询
// 通过其他维度(时间、类型)缩小范围后,应用层解密过滤
public List<User> searchUsers(String keyword, Date startDate, Date endDate) {
    // 1. 按时间范围查询
    List<User> users = getUsersByDateRange(startDate, endDate);
    
    // 2. 应用层解密并过滤
    return users.stream()
        .map(this::decryptUser)
        .filter(user -> user.getName().contains(keyword) || 
                        user.getPhone().contains(keyword))
        .collect(Collectors.toList());
}

五、性能优化

1. 混合索引策略

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    phone_encrypted VARCHAR(500),   -- 完整加密
    phone_prefix CHAR(3),           -- 前缀明文(用于查询)
    phone_hash VARCHAR(100),        -- 完整哈希(用于精确匹配)
    
    INDEX idx_prefix (phone_prefix),
    INDEX idx_hash (phone_hash)
);

2. 缓存热点解密结果

@Cacheable(value = "user_phone", key = "#userId", ttl = 300)
public String getDecryptedPhone(Long userId) {
    String encrypted = getEncryptedPhoneFromDb(userId);
    return aesEncryptor.decrypt(encrypted);
}

3. 异步预加载

@Scheduled(fixedRate = 60000)
public void preloadFrequentData() {
    // 预加载热点数据并解密,缓存到Redis
    List<Long> hotUserIds = getHotUserIds();
    
    for (Long userId : hotUserIds) {
        User user = getUserAndDecrypt(userId);
        cacheService.set("user:" + userId, user, 600);
    }
}

六、答题总结

面试回答框架

  1. 问题识别
    “加密后数据是密文,无法直接使用LIKE模糊查询。需要根据业务场景选择合适方案”

  2. 常用方案
    “手机号等结构化数据推荐部分加密,保留前3位明文用于前缀查询,中间部分加密;或使用哈希映射表,为常用前缀建立哈希索引”

  3. 高安全方案
    “若要求完全加密,可建立分词哈希索引表,查询时先从索引表找ID,再查主表解密;或应用层解密后过滤,适合小数据量”

  4. 生产实践
    “实际项目中手机号使用部分加密+前缀索引,姓名使用ES分词搜索+数据库存完整加密,兼顾安全和性能”

关键点

  • 理解加密与查询的矛盾
  • 掌握部分加密、哈希映射等多种方案
  • 能根据数据类型选择合适方案
  • 强调安全性与性能的平衡