在金融 、加密政务、模糊医疗等对数据安全要求极高的查S储新行业里,“加密落盘”已经是转敏姿势敏感信息(手机号
、身份证号、感信银行卡号等)的息存标准动作。但仅存储安全还不够,加密真实业务里时常需要模糊检索来提升用户与运营效率,模糊例如
:输入“6688”也能定位到某个用户手机号。查S储新 问题是转敏姿势:常规加密后,LIKE 模糊匹配天生“失效”
。感信如果只允许精准匹配,息存系统实现简单,香港云服务器加密但无法满足大多数检索诉求
。模糊于是查S储新我们需要在安全与可用之间找到“桥”
。 本文在完整评估“明文匹配”“数据库函数解密”“ES 分词”之后 ,重点给出一套无需引入 ES、易维护 、可扩展的分片存储方案落地实现,并提供可直接运行的 Spring Boot 代码骨架 ,帮你把方案真正搬到生产环境。 让加密落盘的字段,也能获得接近 LIKE 的模糊查询体验 : 为何分片用 HMAC 而不是云计算对称加密? 传统对称加密(如 AES-GCM)会使用随机 IV ,导致同样的明文每次密文都不同
,不利于等值匹配。而 HMAC(带密钥的哈希)稳定
、不可逆
,非常适合用来做“可匹配的密文索引”
。 生产环境请使用 KMS / 环境变量注入
,不要把密钥写死在配置里 。 接口示例(快速验证) k 越小 ,命中更“敏感”,但索引量线性增大(近似 len - k + 1)。 常见经验 :手机号/证件号等定长字段
,k=3 比较均衡 。 量大时优先考虑分表或表分区
,并对 piece_ciphertext 建合适的前缀索引(本例为整值索引) 。高防服务器 主表用强加密(AES-GCM); 分片索引用 HMAC(不可逆),即使索引泄露,也很难回推出明文(注意密钥保护)。 在新增/更新时,同步写主表与索引表,确保在一个事务内完成。 监控映射表膨胀速度与热点分片(例如“000”“123”会更常见)
,必要时做去重优化或增加布隆过滤以减少回表次数
。 敏感数据加密后的模糊检索不是一道“单选题”。 本文给出的 Spring Boot 代码 将该方案拆解为强加密主存 + HMAC 分片索引两条路径
: 查询时按片找索引、回表解密展示,既不暴露明文 ,又能实现接近 LIKE 的体验。你可以据此直接集成到现有系统,并根据数据规模灵活调参(如 piece-length、分库分表策略)
。目标
思考路径回顾
明文匹配(内存解密 / 数据库解密函数)
:实现简单,但在一致性 、性能与扩展性上有明显短板。ES 分词检索 :性能强、扩展性好 ,但引入了新组件与一致性同步成本。分片存储(本文主角) :把原文滚动切片并按片加密/摘要建立反查索引,“以密取密”,保留了架构简洁性,又兼顾性能与可运维性。 分片存储方案(核心设计)
思路复述将原文(如手机号 19266889900)按固定长度 k 滚动切片 :k=3 → 192, 926, 266, 668, 688, 889, 899, 990, 900为整字段存强加密密文(用于展示前解密);为每个分片存确定性摘要(建议 HMAC-SHA256)
,这样同一明文片段总能映射为同一“密文指纹” ,便于等值匹配;查询时 ,对关键词按相同规则切片 → 计算每片 HMAC → 命中映射表 → 回表查主表 → 解密展示。 核心代码实现
启动类 复制// /src/main/java/com/icoderoad/security/SecurityApplication.java package com.icoderoad.security; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class SecurityApplication { public static void main(String[] args) { SpringApplication.run(SecurityApplication.class, args); } }1.2.3.4.5.6.7.8.9.10.11.12.13.14. 实体类 复制// /src/main/java/com/icoderoad/security/entity/User.java package com.icoderoad.security.entity; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; @Entity @Table(name = "users") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 64) private String username; @Column(name = "phone_ciphertext", nullable = false, length = 512) private String phoneCiphertext; @Column(name = "created_at") private LocalDateTime createdAt; } // /src/main/java/com/icoderoad/security/entity/DataPieceCiphertextMapping.java package com.icoderoad.security.entity; import jakarta.persistence.*; import lombok.*; @Entity @Table(name = "data_piece_ciphertext_mapping", uniqueConstraints = { @UniqueConstraint(name = "uk_biz_piece", columnNames = { "biz_id","piece_ciphertext","piece_len"}) }, indexes = { @Index(name = "idx_piece", columnList = "piece_ciphertext"), @Index(name = "idx_biz", columnList = "biz_id") }) @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor public class DataPieceCiphertextMapping { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "biz_id", nullable = false) private Long bizId; @Column(name = "piece_ciphertext", nullable = false, length = 64) private String pieceCiphertext; @Column(name = "piece_len", nullable = false) private Integer pieceLen; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66. Repository 复制// /src/main/java/com/icoderoad/security/repository/UserRepository.java package com.icoderoad.security.repository; import com.icoderoad.security.entity.User; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository<User, Long> { } // /src/main/java/com/icoderoad/security/repository/DataPieceCiphertextMappingRepository.java package com.icoderoad.security.repository; import com.icoderoad.security.entity.DataPieceCiphertextMapping; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; import java.util.List; public interface DataPieceCiphertextMappingRepository extends JpaRepository<DataPieceCiphertextMapping, Long> { List<DataPieceCiphertextMapping> findByPieceCiphertextInAndPieceLen(Collection<String> pieceCiphertexts, Integer pieceLen); List<DataPieceCiphertextMapping> findByBizId(Long bizId); void deleteByBizId(Long bizId); }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33. DTO 复制// /src/main/java/com/icoderoad/security/dto/UserCreateRequest.java package com.icoderoad.security.dto; import jakarta.validation.constraints.NotBlank; import lombok.Data; @Data public class UserCreateRequest { @NotBlank private String username; @NotBlank private String phone; // 明文手机号 } // /src/main/java/com/icoderoad/security/dto/UserView.java package com.icoderoad.security.dto; import lombok.*; @Data @AllArgsConstructor @NoArgsConstructor @Builder public class UserView { private Long id; private String username; private String phone; // 解密后的免费模板明文返回 }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33. 加密与分片服务 复制// /src/main/java/com/icoderoad/security/service/CryptoService.java package com.icoderoad.security.service; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.crypto.Cipher; import javax.crypto.Mac; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; @Service public class CryptoService { @Value("${ app.crypto.aes-key}") private String aesKeyStr; @Value("${ app.crypto.hmac-key}") private String hmacKeyStr; private SecretKey aesKey; private SecretKey hmacKey; private final SecureRandom random = new SecureRandom(); @PostConstruct public void init() { this.aesKey = new SecretKeySpec(aesKeyStr.getBytes(StandardCharsets.UTF_8), "AES"); this.hmacKey = new SecretKeySpec(hmacKeyStr.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); } /** * AES-256-GCM 加密
,返回 Base64(iv || ciphertext || tag) */ public String encryptField(String plaintext) { try { byte[] iv = new byte[12]; random.nextBytes(iv); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); GCMParameterSpec spec = new GCMParameterSpec(128, iv); cipher.init(Cipher.ENCRYPT_MODE, aesKey, spec); byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); ByteBuffer buf = ByteBuffer.allocate(iv.length + ct.length); buf.put(iv); buf.put(ct); return Base64.getEncoder().encodeToString(buf.array()); } catch (Exception e) { throw new IllegalStateException("encrypt failed", e); } } /** * AES-256-GCM 解密 ,输入 Base64(iv || ciphertext || tag) */ public String decryptField(String base64) { try { byte[] all = Base64.getDecoder().decode(base64); byte[] iv = new byte[12]; System.arraycopy(all, 0, iv, 0, 12); byte[] ct = new byte[all.length - 12]; System.arraycopy(all, 12, ct, 0, ct.length); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(128, iv)); return new String(cipher.doFinal(ct), StandardCharsets.UTF_8); } catch (Exception e) { throw new IllegalStateException("decrypt failed", e); } } /** * HMAC-SHA256(十六进制小写),用于分片“确定性密文索引” */ public String hmacPiece(String piece) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(hmacKey); byte[] raw = mac.doFinal(piece.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(raw.length * 2); for (byte b : raw) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (Exception e) { throw new IllegalStateException("hmac failed", e); } } } // /src/main/java/com/icoderoad/security/service/PieceMatchService.java package com.icoderoad.security.service; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.*; @Service public class PieceMatchService { @Value("${ app.crypto.piece-length}") private int defaultPieceLen; /** 对明文进行滚动分片(窗口大小 = pieceLen) ,最少返回一次(若长度不足则返回原文) */ public List<String> rollingPieces(String plaintext, Integer pieceLen) { int k = (pieceLen == null || pieceLen <= 0) ? defaultPieceLen : pieceLen; if (plaintext == null || plaintext.isEmpty()) return List.of(); if (plaintext.length() <= k) return List.of(plaintext); List<String> res = new ArrayList<>(); for (int i = 0; i + k <= plaintext.length(); i++) { res.add(plaintext.substring(i, i + k)); } return res; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.119.120.121.122.123.124.125.126.127.128.129.130.131.132.133.134.135. 业务服务 复制// /src/main/java/com/icoderoad/security/service/UserService.java package com.icoderoad.security.service; import com.icoderoad.security.dto.UserCreateRequest; import com.icoderoad.security.dto.UserView; import com.icoderoad.security.entity.DataPieceCiphertextMapping; import com.icoderoad.security.entity.User; import com.icoderoad.security.repository.DataPieceCiphertextMappingRepository; import com.icoderoad.security.repository.UserRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; private final DataPieceCiphertextMappingRepository mappingRepository; private final CryptoService cryptoService; private final PieceMatchService pieceMatchService; @Value("${ app.crypto.piece-length}") private int defaultPieceLen; @Transactional public UserView createUser(UserCreateRequest req) { String cipher = cryptoService.encryptField(req.getPhone()); User user = User.builder() .username(req.getUsername()) .phoneCiphertext(cipher) .createdAt(LocalDateTime.now()) .build(); user = userRepository.save(user); // 构建并保存分片映射(HMAC) List<String> pieces = pieceMatchService.rollingPieces(req.getPhone(), defaultPieceLen); if (pieces.isEmpty()) { // 长度不足片长:也建立一个分片 pieces = List.of(req.getPhone()); } int k = Math.min(defaultPieceLen, req.getPhone().length()); List<DataPieceCiphertextMapping> mappings = pieces.stream() .map(p -> DataPieceCiphertextMapping.builder() .bizId(user.getId()) .pieceCiphertext(cryptoService.hmacPiece(p)) .pieceLen(k) .build()) .toList(); mappingRepository.saveAll(mappings); return UserView.builder() .id(user.getId()) .username(user.getUsername()) .phone(req.getPhone()) // 返回明文(通常应只对有权限的端点返回) .build(); } /** * 关键词模糊查询 :对关键词滚动分片 -> HMAC -> 命中映射 -> 回表 -> 解密返回 */ @Transactional public List<UserView> searchByKeyword(String keyword, Integer pieceLen) { if (keyword == null || keyword.isBlank()) return List.of(); int k = (pieceLen == null || pieceLen <= 0) ? defaultPieceLen : pieceLen; // 分片 List<String> parts = pieceMatchService.rollingPieces(keyword, k); if (parts.isEmpty()) { parts = List.of(keyword); k = keyword.length(); } // HMAC List<String> hmacs = parts.stream().map(cryptoService::hmacPiece).toList(); // 命中映射 var hits = mappingRepository.findByPieceCiphertextInAndPieceLen(hmacs, k); if (hits.isEmpty()) return List.of(); // 聚合 bizId Set<Long> bizIds = hits.stream().map(DataPieceCiphertextMapping::getBizId).collect(Collectors.toSet()); var users = userRepository.findAllById(bizIds); // 解密并返回 return users.stream().map(u -> UserView.builder() .id(u.getId()) .username(u.getUsername()) .phone(cryptoService.decryptField(u.getPhoneCiphertext())) .build()).toList(); } /** * 更新手机号:重建映射(示例) */ @Transactional public UserView updatePhone(Long userId, String newPhone) { var user = userRepository.findById(userId).orElseThrow(); user.setPhoneCiphertext(cryptoService.encryptField(newPhone)); userRepository.save(user); // 清理旧映射,重建新映射 mappingRepository.deleteByBizId(userId); List<String> pieces = pieceMatchService.rollingPieces(newPhone, defaultPieceLen); if (pieces.isEmpty()) pieces = List.of(newPhone); int k = Math.min(defaultPieceLen, newPhone.length()); List<DataPieceCiphertextMapping> mappings = pieces.stream() .map(p -> DataPieceCiphertextMapping.builder() .bizId(userId) .pieceCiphertext(cryptoService.hmacPiece(p)) .pieceLen(k) .build()) .toList(); mappingRepository.saveAll(mappings); return UserView.builder() .id(user.getId()) .username(user.getUsername()) .phone(newPhone) .build(); } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.119.120.121.122.123.124.125.126.127.128.129.130.131.132.133.134.135.136.137.138.139.140.141.142.143.144.145.146. 控制器 复制// /src/main/java/com/icoderoad/security/controller/UserController.java package com.icoderoad.security.controller; import com.icoderoad.security.dto.UserCreateRequest; import com.icoderoad.security.dto.UserView; import com.icoderoad.security.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @PostMapping public UserView create(@Valid @RequestBody UserCreateRequest req) { return userService.createUser(req); } @GetMapping("/search") public List<UserView> search(@RequestParam("keyword") String keyword, @RequestParam(value = "pieceLen", required = false) Integer pieceLen) { return userService.searchByKeyword(keyword, pieceLen); } @PutMapping("/{ id}/phone") public UserView updatePhone(@PathVariable("id") Long id, @RequestParam("phone") String phone) { return userService.updatePhone(id, phone); } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43. 总结