加密也能模糊查?SpringBoot 玩轉(zhuǎn)敏感信息存儲(chǔ)新姿勢(shì)!
在金融、政務(wù)、醫(yī)療等對(duì)數(shù)據(jù)安全要求極高的行業(yè)里,“加密落盤”已經(jīng)是敏感信息(手機(jī)號(hào)、身份證號(hào)、銀行卡號(hào)等)的標(biāo)準(zhǔn)動(dòng)作。但僅存儲(chǔ)安全還不夠,真實(shí)業(yè)務(wù)里時(shí)常需要模糊檢索來(lái)提升用戶與運(yùn)營(yíng)效率,例如:輸入“6688”也能定位到某個(gè)用戶手機(jī)號(hào)。 問(wèn)題是:常規(guī)加密后,LIKE 模糊匹配天生“失效”。如果只允許精準(zhǔn)匹配,系統(tǒng)實(shí)現(xiàn)簡(jiǎn)單,但無(wú)法滿足大多數(shù)檢索訴求。于是我們需要在安全與可用之間找到“橋”。
本文在完整評(píng)估“明文匹配”“數(shù)據(jù)庫(kù)函數(shù)解密”“ES 分詞”之后,重點(diǎn)給出一套無(wú)需引入 ES、易維護(hù)、可擴(kuò)展的分片存儲(chǔ)方案落地實(shí)現(xiàn),并提供可直接運(yùn)行的 Spring Boot 代碼骨架,幫你把方案真正搬到生產(chǎn)環(huán)境。
目標(biāo)
讓加密落盤的字段,也能獲得接近 LIKE 的模糊查詢體驗(yàn):
- 數(shù)據(jù)庫(kù)存密文;
- 查詢支持“任意位置片段”匹配;
- 性能可控、架構(gòu)簡(jiǎn)單、易于水平擴(kuò)展。
思考路徑回顧
- 明文匹配(內(nèi)存解密 / 數(shù)據(jù)庫(kù)解密函數(shù)):實(shí)現(xiàn)簡(jiǎn)單,但在一致性、性能與擴(kuò)展性上有明顯短板。
- ES 分詞檢索:性能強(qiáng)、擴(kuò)展性好,但引入了新組件與一致性同步成本。
- 分片存儲(chǔ)(本文主角):把原文滾動(dòng)切片并按片加密/摘要建立反查索引,“以密取密”,保留了架構(gòu)簡(jiǎn)潔性,又兼顧性能與可運(yùn)維性。
分片存儲(chǔ)方案(核心設(shè)計(jì))
思路復(fù)述
- 將原文(如手機(jī)號(hào)
19266889900)按固定長(zhǎng)度k滾動(dòng)切片:k=3→192, 926, 266, 668, 688, 889, 899, 990, 900 - 為整字段存強(qiáng)加密密文(用于展示前解密);
- 為每個(gè)分片存確定性摘要(建議 HMAC-SHA256),這樣同一明文片段總能映射為同一“密文指紋”,便于等值匹配;
- 查詢時(shí),對(duì)關(guān)鍵詞按相同規(guī)則切片 → 計(jì)算每片 HMAC → 命中映射表 → 回表查主表 → 解密展示。
為何分片用 HMAC 而不是對(duì)稱加密? 傳統(tǒng)對(duì)稱加密(如 AES-GCM)會(huì)使用隨機(jī) IV,導(dǎo)致同樣的明文每次密文都不同,不利于等值匹配。而 HMAC(帶密鑰的哈希)穩(wěn)定、不可逆,非常適合用來(lái)做“可匹配的密文索引”。
表結(jié)構(gòu)(示例)
- 主表
users:存放強(qiáng)加密后的敏感字段(如phone_ciphertext) - 索引表
data_piece_ciphertext_mapping:存放每個(gè)分片的 HMAC 指紋與業(yè)務(wù) ID 的映射
項(xiàng)目結(jié)構(gòu)
/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.sqlPOM 依賴(示例)
<!-- /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>
<!-- 如需校驗(yàn)可加 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>配置文件
# /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字節(jié),用于AES-256-GCM(示例)
hmac-key: "HmacKey-ChangeMe-Prod-Safe" # HMAC-SHA256 密鑰(示例)
piece-length: 3生產(chǎn)環(huán)境請(qǐng)使用 KMS / 環(huán)境變量注入,不要把密鑰寫死在配置里。
建表 SQL(可直接執(zhí)行)
-- /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 '整字段強(qiáng)加密密文(含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十六進(jìn)制',
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;核心代碼實(shí)現(xiàn)
啟動(dòng)類
// /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);
}
}實(shí)體類
// /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;
}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);
}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; // 明文手機(jī)號(hào)
}
// /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; // 解密后的明文返回
}加密與分片服務(wù)
// /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(十六進(jìn)制小寫),用于分片“確定性密文索引”
*/
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;
/** 對(duì)明文進(jìn)行滾動(dòng)分片(窗口大小 = pieceLen),最少返回一次(若長(zhǎng)度不足則返回原文) */
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;
}
}業(yè)務(wù)服務(wù)
// /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);
// 構(gòu)建并保存分片映射(HMAC)
List<String> pieces = pieceMatchService.rollingPieces(req.getPhone(), defaultPieceLen);
if (pieces.isEmpty()) {
// 長(zhǎng)度不足片長(zhǎng):也建立一個(gè)分片
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()) // 返回明文(通常應(yīng)只對(duì)有權(quán)限的端點(diǎn)返回)
.build();
}
/**
* 關(guān)鍵詞模糊查詢:對(duì)關(guān)鍵詞滾動(dòng)分片 -> 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();
}
/**
* 更新手機(jī)號(hào):重建映射(示例)
*/
@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();
}
}控制器
// /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);
}
}接口示例(快速驗(yàn)證)
# 新增用戶
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"username":"alice","phone":"19266889900"}'
# 模糊搜索(默認(rèn)片長(zhǎng)=3)
curl "http://localhost:8080/api/users/search?keyword=6688"
# 指定片長(zhǎng)=2(更密集的匹配,索引體量更大)
curl "http://localhost:8080/api/users/search?keyword=2688&pieceLen=2"
# 更新手機(jī)號(hào)(自動(dòng)重建索引映射)
curl -X PUT "http://localhost:8080/api/users/1/phone?phone=13900006666"性能與運(yùn)維建議
- 片長(zhǎng)選擇:
k 越小,命中更“敏感”,但索引量線性增大(近似 len - k + 1)。
常見(jiàn)經(jīng)驗(yàn):手機(jī)號(hào)/證件號(hào)等定長(zhǎng)字段,k=3 比較均衡。
- 索引表擴(kuò)展:
量大時(shí)優(yōu)先考慮分表或表分區(qū),并對(duì) piece_ciphertext 建合適的前綴索引(本例為整值索引)。
- 安全邊界:
主表用強(qiáng)加密(AES-GCM);
分片索引用 HMAC(不可逆),即使索引泄露,也很難回推出明文(注意密鑰保護(hù))。
- 一致性:
在新增/更新時(shí),同步寫主表與索引表,確保在一個(gè)事務(wù)內(nèi)完成。
- 可觀測(cè)性:
監(jiān)控映射表膨脹速度與熱點(diǎn)分片(例如“000”“123”會(huì)更常見(jiàn)),必要時(shí)做去重優(yōu)化或增加布隆過(guò)濾以減少回表次數(shù)。
總結(jié)
敏感數(shù)據(jù)加密后的模糊檢索不是一道“單選題”。
- 小規(guī)模、單節(jié)點(diǎn):內(nèi)存明文匹配能最快上線,但擴(kuò)展性差;
- 小表:數(shù)據(jù)庫(kù)函數(shù)解密簡(jiǎn)單易懂,但性能天花板明顯;
- 超大規(guī)模:ES 分詞性能強(qiáng)悍,代價(jià)是運(yùn)維復(fù)雜度與一致性同步;
- 分片存儲(chǔ)方案:在不引入新組件的前提下取得性能、成本與工程復(fù)雜度的平衡,特別適合中大型業(yè)務(wù)在自有數(shù)據(jù)庫(kù)上演進(jìn)。
本文給出的 Spring Boot 代碼 將該方案拆解為強(qiáng)加密主存 + HMAC 分片索引兩條路徑: 查詢時(shí)按片找索引、回表解密展示,既不暴露明文,又能實(shí)現(xiàn)接近 LIKE 的體驗(yàn)。你可以據(jù)此直接集成到現(xiàn)有系統(tǒng),并根據(jù)數(shù)據(jù)規(guī)模靈活調(diào)參(如 piece-length、分庫(kù)分表策略)。






























