在金融、政务、医疗等对数据安全要求极高的行业里,“加密落盘”已经是敏感信息(手机号、身份证号、银行卡号等)的标准动作。但仅存储安全还不够,真实业务里时常需要模糊检索来提升用户与运营效率,例如:输入“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、分库分表策略)。