一、方案选型:常见敏感信息处理方案对比
在深入我们的核心方案之前,有必要对业界常见的几种处理方式进行横向对比,明确其优劣势,从而做出最适合自身业务场景的选择。
方案类型 | 核心逻辑 | 优势 | 劣势 | 适用场景 |
---|---|---|---|---|
明文匹配 | 数据库存储密文,查询时将全量或大量数据拉取到应用内存中,解密后进行匹配。 | 实现逻辑简单,无需引入额外组件依赖。 | 性能灾难。数据量稍大就会导致内存溢出(OOM),网络IO和CPU开销巨大。 | 仅限于小规模数据、内部后台或单节点测试环境。 |
数据库函数解密 | 利用数据库内置的解密函数(如 MySQL 的 AES_DECRYPT )在查询时进行解密和匹配。 | 开发速度快,对现有业务逻辑侵入性小。 | 无法利用索引,导致查询时全表扫描,性能随数据量增长而急剧下降。 | 数据量明确小于 10 万的小型数据表。 |
ES 分词检索 | 将敏感信息加密后,同步一份到 Elasticsearch 中,利用其强大的分词和检索引擎实现模糊查询。 | 检索性能极强,支持各种复杂的查询和聚合分析。 | 架构复杂化,引入新组件(ES),需要额外解决数据同步、一致性、运维等问题。 | 数据规模达到千万级甚至亿级的超大型业务。 |
分片存储(本文方案) | 原文滚动切片后生成 HMAC 索引,通过查询索引间接定位密文,“以密取密”。 | 无新增组件依赖,性能可控且易于维护,安全性高。 | 需要额外维护索引表,索引数据量会随分片长度的减小而增加。 | 数据量在 10 万到千万级之间的中大型业务。 |
综合来看,分片存储方案在性能、成本和安全性之间取得了最佳平衡,尤其适合绝大多数既要合规又不想引入复杂技术栈的中大型项目。
二、原理介绍
写入流程
-
两条并行存储路径:
-
一条走 AES-256-GCM → 主表存储密文。
-
一条走 切片 + HMAC → 索引表存储指纹映射。
-
-
索引表只存指纹,不存原文,攻击者即使拖库也无法逆推出手机号。
-
主表与索引表通过 biz_id 关联,保证查询时可以“索引先行,密文回表”。
查询流程
-
用户输入 → 服务端切片、HMAC 指纹生成
-
去 索引表 查找候选数据 → 得到 biz_id
-
去 主表 查询密文 → 解密还原原文
形成了一个 “索引先行,密文回表” 的闭环。
其根本思想可以概括为 “密文隔离,索引先行”。它巧妙地将 不可直接查询的强加密数据 与 可供间接查询的安全索引 分离开来,通过“以密查密”的方式,在不牺牲数据安全性的前提下,实现了高效的模糊查询。
整个原理可以拆解为两大核心机制:数据写入时的“分片建索” 和 数据查询时的“索引匹配”。
核心机制 1:数据写入时的“分片建索” (Divide and Index)
当一条新的敏感数据(例如手机号 19266889900
)需要被存储时,系统会执行以下两步操作:
1. 对原文进行强加密并存储(密文隔离)
- 目的:确保原始数据的最高安全性,防止数据库被拖库后信息直接泄露。
- 操作:使用如 AES-256-GCM 这类业界公认的强对称加密算法,将完整的手机号
19266889900
加密成一长串密文(例如Base64(IV+密文+认证标签)
)。 - 存储:将这串完整的、不可直接查询的密文存入主业务表(如
users
表的phone_ciphertext
字段)。
2. 对原文进行分片并建立安全索引(索引先行)
- 目的:为后续的模糊查询构建一个可供查找的“目录”,这个目录本身必须是安全的。
- 操作:
-
a. 滚动切片 (Sharding/Slicing):将原始手机号
19266889900
按照预设的固定长度(例如k=3
)进行“滚动”切割,生成一组连续的文本片段。19266889900
→["192", "926", "266", "688", "889", "899", "990", "900"]
这一步是实现“模糊”匹配的关键。任何包含在原文中的连续片段都会被提取出来。
-
b. 生成指纹 (HMAC Hashing):对上一步得到的每一个文本片段,使用一种特殊的哈希算法——HMAC(带密钥的哈希),例如
HMAC-SHA256
,来生成一个固定长度的、不可逆的“指纹”。HMAC("688", secret_key)
→a1b2c3d4...
(一个64位的十六进制字符串) -
c. 存储映射关系:将生成的每个指纹与主数据的业务ID(
user.id
)关联起来,存入专门的索引表(data_piece_ciphertext_mapping
)。
-
为什么索引必须用 HMAC 而不是普通哈希或对称加密?
这是一个关键的技术决策点:
- 对比普通哈希 (如 SHA-256):普通哈希虽然也是单向的,但如果攻击者获取了索引表,他们可以通过彩虹表攻击,轻松地反推出常见的、由数字组成的短分片(如 “668”, “888”)。HMAC因为加入了密钥(
secret_key
),相当于给哈希加了“盐”,使得彩虹表攻击变得不可行。 - 对比对称加密 (如 AES):AES 加密为了安全,通常会使用一个随机的初始向量(IV),导致相同的明文片段每次加密后得到的密文都不同。这就无法进行等值查询,也就失去了建立索引的意义。而 HMAC 的特性是确定性:只要明文片段和密钥不变,其输出的指纹就永远是相同的,这就保证了索引的可查询性。
核心机制 2:数据查询时的“索引匹配” (Match and Retrieve)
当用户输入一个关键词(例如 6688
)进行模糊查询时,系统会执行以下反向流程:
关键词分片与指纹生成
- 首先,对用户输入的关键词
6688
应用完全相同的滚动切片规则(k=3
),得到["668", "688"]
。 - 然后,使用完全相同的 HMAC 密钥和算法,计算出这两个分片的指纹:
HMAC("668")
和HMAC("688")
。
查询索引表
- 系统拿着计算出的指纹集合,去索引表中进行查询:
SELECT biz_id FROM data_piece_ciphertext_mapping WHERE piece_ciphertext IN ('hmac_of_668', 'hmac_of_688');
- 由于
piece_ciphertext
字段建立了数据库索引,这一步查询非常高效。数据库会快速返回所有包含这些指纹的记录所关联的业务ID(user.id
)。
回表查询与解密
- 拿到业务 ID 集合后,系统会回到主业务表(
users
表)中查询这些 ID 对应的完整记录。SELECT * FROM users WHERE id IN (...);
- 最后,取出
phone_ciphertext
字段中的强加密密文,使用 AES 密钥进行解密,得到原始的手机号,并将其展示给用户。
原理小结
总的来说,分片存储的原理就是 “化整为零,以索引规避直接解密查询”:
- 化整为零:将一个完整的、不可查的敏感信息,拆解成多个微小的、可索引的片段。
- 安全索引:使用 HMAC 算法为这些片段创建了一个既安全(不可逆、防破解)又确定(可用于等值匹配)的“指纹索引”。
- 查询流程:查询时,先通过查询关键词生成的“指纹”去索引表中快速定位,找到匹配项的“门牌号”(业务 ID),最后才根据“门牌号”找到真正的、被严密保管的原始数据并解密。
这个方案成功地将数据存储的安全性和数据检索的灵活性解耦,实现了一个既合规又实用的敏感信息处理闭环。
三、核心原理拆解:分片存储方案是如何工作的?
分片存储的核心思想是 “拆分敏感信息为片段,用片段索引间接匹配密文”。它巧妙地绕开了对密文直接进行模糊匹配的难题,具体可以分为以下三个步骤:
1. 数据存储:双表设计 + 双层加密
我们设计两张表来协同工作:主业务表和分片索引表。
-
主表 (
users
):负责存储完整的业务数据。其中,敏感信息字段(如phone_ciphertext
)存储的是经过 强加密算法(如 AES-256-GCM) 加密后的密文。这份数据仅在需要精确展示给用户时才进行解密,最大限度地保证了原始信息的安全。 -
索引表 (
data_piece_ciphertext_mapping
):这张表的关键是存储敏感信息原文的“分片 HMAC 指纹”。具体流程如下:- 滚动切片:将原文(如手机号
19266889900
)按照一个固定的长度k
(例如k=3
)进行滚动切片,得到一组片段:["192", "926", "266", "688", "889", "899", "990", "900"]
。 - 生成指纹:对每一个分片,使用 HMAC-SHA256 算法生成一个固定长度的、不可逆的哈希指纹。
- 存储映射:将生成的指纹与主表的业务 ID(如
user_id
)关联存储起来。
- 滚动切片:将原文(如手机号
2. 模糊查询:分片匹配 + 回表解密
当用户输入关键词(如 “6688”)进行模糊查询时,系统将执行以下流程:
- 关键词分片:按照数据存储时完全相同的规则(如
k=3
)对查询关键词 “6688” 进行滚动切片,得到["668", "688"]
。 - 生成 HMAC 指纹:计算每个关键词分片的 HMAC 值,得到一组用于查询的指纹。
- 索引表匹配:使用这组 HMAC 指纹去索引表(
data_piece_ciphertext_mapping
)中进行IN
查询,高效地找出所有匹配的记录,并获取关联的主表业务 ID 集合。 - 主表回表解密:利用获取到的业务 ID,去主表 (
users
) 中查询对应的记录,然后解密phone_ciphertext
字段,最终将清晰的原文信息返回给前端。
四、实操Code
下面我们基于 SpringBoot 3.3.2
+ JPA
+ MySQL
来实现这套方案。
1. 环境准备
(1)关键依赖 (pom.xml)
<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>
</dependencies>
(2)配置文件 (application.yml)
严重警告:生产环境中,密钥(aes-key
, hmac-key
)绝不能硬编码在配置文件中!必须通过环境变量、配置中心或专业的密钥管理服务(KMS)进行注入。
spring:
datasource:
url: jdbc:mysql://localhost:3306/security_demo?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root # Replace with your password
jpa:
hibernate:
ddl-auto: none # 生产环境建议使用 Flyway 或 Liquibase 管理表结构
properties:
hibernate:
format_sql: true
app:
crypto:
aes-key: "uE2mFq7nA1b4C7d9uE2mFq7nA1b4C7d9" # 必须是32字节 (256位) 的 AES-256-GCM 密钥
hmac-key: "HmacKey-ChangeMe-And-Keep-It-Secret" # 生产环境务必替换为强密钥
piece-length: 3 # 敏感信息默认分片长度
(3)表结构 (schema.sql)
我们创建 users
主表和 data_piece_ciphertext_mapping
索引表。注意,索引表需要对 piece_ciphertext
字段建立索引以保证查询性能。
-- 主表:存储用户基本信息和加密后的手机号
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
phone_ciphertext VARCHAR(512) NOT NULL COMMENT '手机号AES密文(Base64编码,含IV和认证标签)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 索引表:存储敏感信息分片的HMAC指纹
CREATE TABLE IF NOT EXISTS data_piece_ciphertext_mapping (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_id BIGINT NOT NULL COMMENT '关联的业务ID,即 users.id',
piece_ciphertext CHAR(64) NOT NULL COMMENT '分片的HMAC-SHA256指纹(十六进制)',
piece_len INT NOT NULL DEFAULT 3 COMMENT '分片时的长度',
INDEX idx_piece (piece_ciphertext), -- 核心查询索引!
UNIQUE KEY uk_biz_piece (biz_id, piece_ciphertext, piece_len), -- 防止同一业务ID的同一分片重复存储
FOREIGN KEY (biz_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2. 核心服务实现
(1)加密服务 (CryptoService
)
这个 Service 是所有加密操作的核心,封装了 AES-256-GCM 加密/解密 和 HMAC-SHA256 指纹生成。
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
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();
private static final int GCM_IV_LENGTH = 12; // 96 bits
private static final int GCM_TAG_LENGTH = 128; // bits
@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 加密
* @param plaintext 原始明文
* @return Base64编码的字符串,格式为:IV + 密文 + 认证标签
*/
public String encryptField(String plaintext) {
try {
byte[] iv = new byte[GCM_IV_LENGTH];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, aesKey, gcmSpec);
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// 合并 IV 和密文
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + ciphertext.length);
byteBuffer.put(iv);
byteBuffer.put(ciphertext);
return Base64.getEncoder().encodeToString(byteBuffer.array());
} catch (Exception e) {
throw new IllegalStateException("AES encryption failed", e);
}
}
/**
* AES-256-GCM 解密
* @param base64Ciphertext Base64编码的密文
* @return 原始明文
*/
public String decryptField(String base64Ciphertext) {
try {
byte[] decoded = Base64.getDecoder().decode(base64Ciphertext);
ByteBuffer byteBuffer = ByteBuffer.wrap(decoded);
byte[] iv = new byte[GCM_IV_LENGTH];
byteBuffer.get(iv);
byte[] ciphertext = new byte[byteBuffer.remaining()];
byteBuffer.get(ciphertext);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, aesKey, gcmSpec);
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (Exception e) {
// 在生产环境中应记录更详细的错误日志
throw new IllegalStateException("AES decryption failed", e);
}
}
/**
* HMAC-SHA256 计算分片指纹
* @param piece 明文分片
* @return 64位的十六进制字符串
*/
public String hmacPiece(String piece) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(hmacKey);
byte[] rawHmac = mac.doFinal(piece.getBytes(StandardCharsets.UTF_8));
// 转换为十六进制字符串
StringBuilder hexString = new StringBuilder(2 * rawHmac.length);
for (byte b : rawHmac) {
hexString.append(String.format("%02x", b));
}
return hexString.toString();
} catch (Exception e) {
throw new IllegalStateException("HMAC calculation failed", e);
}
}
}
(2)分片服务 (PieceMatchService
)
这个 Service 负责实现文本的滚动切片逻辑。
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class PieceMatchService {
@Value("${app.crypto.piece-length}")
private int defaultPieceLen;
/**
* 对给定的明文进行滚动分片
* @param plaintext 原始文本
* @param pieceLen 分片长度,如果为null或<=0,则使用默认值
* @return 分片列表
*/
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> pieces = new ArrayList<>();
for (int i = 0; i <= plaintext.length() - k; i++) {
pieces.add(plaintext.substring(i, i + k));
}
return pieces;
}
}
(3)业务服务 (UserService
)
这个 Service 负责业务流程的编排,整合加密和分片服务,实现用户的创建和模糊查询。
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
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) {
// 1. 加密手机号,并保存主表信息
String phoneCipher = cryptoService.encryptField(req.getPhone());
User user = User.builder()
.username(req.getUsername())
.phoneCiphertext(phoneCipher)
.createdAt(LocalDateTime.now())
.build();
user = userRepository.save(user);
// 2. 对手机号原文进行分片,并生成HMAC指纹存入索引表
List<String> pieces = pieceMatchService.rollingPieces(req.getPhone(), defaultPieceLen);
if (pieces.isEmpty()) { // 处理长度不足一个分片的情况
pieces = List.of(req.getPhone());
}
int pieceLength = Math.min(defaultPieceLen, req.getPhone().length());
final Long userId = user.getId();
List<DataPieceCiphertextMapping> mappings = pieces.stream()
.map(piece -> DataPieceCiphertextMapping.builder()
.bizId(userId)
.pieceCiphertext(cryptoService.hmacPiece(piece))
.pieceLen(pieceLength)
.build())
.collect(Collectors.toList());
mappingRepository.saveAll(mappings);
// 实际项目中,返回DTO时是否包含明文手机号需根据权限和场景决定
return UserView.fromEntity(user, req.getPhone());
}
@Transactional(readOnly = true)
public List<UserView> searchByKeyword(String keyword, Integer pieceLen) {
if (keyword == null || keyword.isBlank()) {
return List.of();
}
int k = (pieceLen == null || pieceLen <= 0) ? defaultPieceLen : pieceLen;
// 1. 对查询关键词进行分片,并计算HMAC指纹
List<String> keywordPieces = pieceMatchService.rollingPieces(keyword, k);
if (keywordPieces.isEmpty()) { // 处理关键词长度不足一个分片的情况
keywordPieces = List.of(keyword);
k = keyword.length();
}
List<String> keywordHmacs = keywordPieces.stream()
.map(cryptoService::hmacPiece)
.collect(Collectors.toList());
// 2. 通过指纹在索引表中查找匹配的业务ID
List<DataPieceCiphertextMapping> hits = mappingRepository
.findByPieceCiphertextInAndPieceLen(keywordHmacs, k);
if (hits.isEmpty()) {
return List.of();
}
Set<Long> userIds = hits.stream()
.map(DataPieceCiphertextMapping::getBizId)
.collect(Collectors.toSet());
// 3. 回到主表查询完整信息,解密并返回
return userRepository.findAllById(userIds).stream()
.map(user -> {
String decryptedPhone = cryptoService.decryptField(user.getPhoneCiphertext());
return UserView.fromEntity(user, decryptedPhone);
})
.collect(Collectors.toList());
}
// 省略更新手机号的逻辑,其核心是:删除旧手机号的所有索引,然后为新手机号创建新索引
}
3. 接口测试 (UserController
)
最后,提供一个简单的 Controller 来暴露 API 接口,并使用 curl
进行快速验证。
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
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 String keyword,
@RequestParam(required = false) Integer pieceLen) {
return userService.searchByKeyword(keyword, pieceLen);
}
// 示例:更新手机号接口(具体实现略)
@PutMapping("/{id}/phone")
public UserView updatePhone(@PathVariable Long id, @RequestParam String phone) {
// return userService.updatePhone(id, phone);
return null; // 待实现
}
}
测试命令示例:
# 1. 新增一个用户,手机号为 19266889900
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"username":"alice","phone":"19266889900"}'
# 2. 模糊查询包含 "6688" 的手机号
curl "http://localhost:8080/api/users/search?keyword=6688"
# 预期会返回 Alice 的用户信息
五、生产环境运维建议
将方案部署到生产环境时,还需要考虑以下几点以确保其健壮性和可扩展性:
-
片长选择 (k):
k=3
或k=4
是手机号、身份证号等场景下的一个均衡选择。k
值越小,匹配越灵敏(如k=2
可以匹配任意连续两位),但索引表会急剧膨胀。k
值越大,索引表越小,但查询灵活性降低。需要根据业务需求权衡。
-
密钥安全:再次强调,绝不能硬编码密钥。建议专业的密钥管理系统,通过应用启动时拉取或API调用获取。
-
索引表扩展:当索引表数据量超过千万时,单一索引的查询性能会下降。可以考虑:
- 数据库分区:使用 MySQL 的分区功能,例如按
piece_ciphertext
的前一两位字符进行 HASH 或 LIST 分区,将查询压力分散到不同分区。 - 分库分表:引入 Sharding-JDBC 等中间件,对索引表进行水平切分。
- 数据库分区:使用 MySQL 的分区功能,例如按
-
性能优化:
- 布隆过滤器:对于极高频的查询分片(如手机号中常见的“138”、“888”),可能会导致大量回表查询。可以引入布隆过滤器作为前置缓存,快速判断某个分片是否存在,从而拦截掉大量无效的数据库查询。
- 分页查询:查询接口必须实现分页逻辑,避免一次性返回大量数据导致服务内存压力。
六、总结
在敏感信息处理的实践中,不存在一招鲜的“银弹”,核心在于在安全合规与业务可用性之间找到平衡。明文匹配简单但不安全,ES 方案性能强大但运维复杂。我们这里提出的 “强加密主存 + HMAC 分片索引” 方案,在不引入新组件、不显著增加架构复杂度的前提下,通过巧妙的设计,实现了接近原生 LIKE
的模糊查询体验。