加密也能模糊查?SpringBoot 玩转敏感信息存储新姿势!

在金融、政务、医疗等对数据安全要求极高的行业里,“加密落盘”已经是敏感信息(手机号、身份证号、银行卡号等)的标准动作。但仅存储安全还不够,真实业务里时常需要模糊检索来提升用户与运营效率,例如:输入“6688”也能定位到某个用户手机号。 问题是:常规加密后,LIKE 模糊匹配天生“失效”。如果只允许精准匹配,系统实现简单,但无法满足大多数检索诉求。于是我们需要在安全与可用之间找到“桥”。

本文在完整评估“明文匹配”“数据库函数解密”“ES 分词”之后,重点给出一套无需引入 ES、易维护、可扩展分片存储方案落地实现,并提供可直接运行的 Spring Boot 代码骨架,帮你把方案真正搬到生产环境。

目标

让加密落盘的字段,也能获得接近 LIKE 的模糊查询体验

数据库存密文;查询支持“任意位置片段”匹配;性能可控、架构简单、易于水平扩展。

思考路径回顾

明文匹配(内存解密 / 数据库解密函数):实现简单,但在一致性、性能与扩展性上有明显短板。ES 分词检索:性能强、扩展性好,但引入了新组件与一致性同步成本。分片存储(本文主角):把原文滚动切片并按片加密/摘要建立反查索引,“以密取密”,保留了架构简洁性,又兼顾性能与可运维性。

分片存储方案(核心设计)

思路复述将原文(如手机号 19266889900)按固定长度 k 滚动切片:k=3 → 192, 926, 266, 668, 688, 889, 899, 990, 900整字段强加密密文(用于展示前解密);每个分片确定性摘要(建议 HMAC-SHA256),这样同一明文片段总能映射为同一“密文指纹”,便于等值匹配;查询时,对关键词按相同规则切片 → 计算每片 HMAC → 命中映射表 → 回表查主表 → 解密展示。

为何分片用 HMAC 而不是对称加密? 传统对称加密(如 AES-GCM)会使用随机 IV,导致同样的明文每次密文都不同,不利于等值匹配。而 HMAC(带密钥的哈希)稳定、不可逆,非常适合用来做“可匹配的密文索引”。

表结构(示例)主表 users:存放强加密后的敏感字段(如 phone_ciphertext索引表 data_piece_ciphertext_mapping:存放每个分片的 HMAC 指纹与业务 ID 的映射项目结构
复制
/src └── /main ├── /java │ └── /com │ └── /icoderoad │ └── /security │ ├── controller │ │ └── UserController.java │ ├── dto │ │ ├── UserCreateRequest.java │ │ └── UserView.java │ ├── entity │ │ ├── User.java │ │ └── DataPieceCiphertextMapping.java │ ├── repository │ │ ├── UserRepository.java │ │ └── DataPieceCiphertextMappingRepository.java │ ├── service │ │ ├── CryptoService.java │ │ ├── PieceMatchService.java │ │ └── UserService.java │ └── SecurityApplication.java └── /resources ├── application.yml └── schema.sql1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.
POM 依赖(示例)
复制
<!-- /pom.xml --> <project> <properties> <java.version>17</java.version> <spring-boot.version>3.3.2</spring-boot.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 如需校验可加 Hibernate Validator --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>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.
配置文件
复制
# /src/main/resources/application.yml spring: datasource: url: jdbc:mysql://localhost:3306/security_demo?useSSL=false&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: root jpa: hibernate: ddl-auto: none properties: hibernate: format_sql: true open-in-view: false app: crypto: aes-key: "uE2mFq7nA1b4C7d9uE2mFq7nA1b4C7d9" # 32字节,用于AES-256-GCM(示例) hmac-key: "HmacKey-ChangeMe-Prod-Safe" # HMAC-SHA256 密钥(示例) piece-length: 31.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.

生产环境请使用 KMS / 环境变量注入,不要把密钥写死在配置里。

建表 SQL(可直接执行)
复制
-- /src/main/resources/schema.sql CREATE TABLE IF NOT EXISTS users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, phone_ciphertext VARCHAR(512) NOT NULL COMMENT 整字段强加密密文(含IV), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE IF NOT EXISTS data_piece_ciphertext_mapping ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id BIGINT NOT NULL COMMENT 指向users.id, piece_ciphertext CHAR(64) NOT NULL COMMENT 分片HMAC-SHA256十六进制, piece_len INT NOT NULL DEFAULT 3, INDEX idx_piece (piece_ciphertext), INDEX idx_biz (biz_id), UNIQUE KEY uk_biz_piece (biz_id, piece_ciphertext, piece_len), CONSTRAINT fk_piece_user FOREIGN KEY (biz_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.

核心代码实现

启动类
复制
// /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.

接口示例(快速验证)

复制
# 新增用户 curl -X POST http://localhost:8080/api/users \ -H "Content-Type: application/json" \ -d {"username":"alice","phone":"19266889900"} # 模糊搜索(默认片长=3) curl "http://localhost:8080/api/users/search?keyword=6688" # 指定片长=2(更密集的匹配,索引体量更大) curl "http://localhost:8080/api/users/search?keyword=2688&pieceLen=2" # 更新手机号(自动重建索引映射) curl -X PUT "http://localhost:8080/api/users/1/phone?phone=13900006666"1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.
性能与运维建议片长选择

k 越小,命中更“敏感”,但索引量线性增大(近似 len - k + 1)。

常见经验:手机号/证件号等定长字段,k=3 比较均衡。

索引表扩展

量大时优先考虑分表表分区,并对 piece_ciphertext 建合适的前缀索引(本例为整值索引)。

安全边界

主表用强加密(AES-GCM)

分片索引用 HMAC(不可逆),即使索引泄露,也很难回推出明文(注意密钥保护)。

一致性

新增/更新时,同步写主表与索引表,确保在一个事务内完成。

可观测性

监控映射表膨胀速度与热点分片(例如“000”“123”会更常见),必要时做去重优化或增加布隆过滤以减少回表次数。

总结

敏感数据加密后的模糊检索不是一道“单选题”。

小规模、单节点:内存明文匹配能最快上线,但扩展性差;小表:数据库函数解密简单易懂,但性能天花板明显;超大规模:ES 分词性能强悍,代价是运维复杂度与一致性同步;分片存储方案:在不引入新组件的前提下取得性能、成本与工程复杂度的平衡,特别适合中大型业务在自有数据库上演进。

本文给出的 Spring Boot 代码 将该方案拆解为强加密主存 + HMAC 分片索引两条路径: 查询时按片找索引、回表解密展示,既不暴露明文,又能实现接近 LIKE 的体验。你可以据此直接集成到现有系统,并根据数据规模灵活调参(如 piece-length、分库分表策略)。

THE END