1. 项目概述与核心价值
最近在重构一个老的后台管理系统,里面涉及到不少用户的身份证号、手机号、银行卡号这类敏感信息。之前这些数据都是明文存储在数据库里的,每次代码里要存要查,都得手动调用加解密工具类,不仅代码里散落着各种
AESUtil.encrypt(idCard)
,而且很容易遗漏,一不留神就“裸奔”了。更头疼的是,历史数据迁移也是个麻烦事。我就琢磨着,能不能把这些加解密的逻辑从业务代码里彻底抽离出来,让框架自动去干这个脏活累活。
自然而然地,就想到了Mybatis的拦截器。这玩意儿就像给SQL执行过程装了几个“监听器”,我们可以在SQL执行前、参数设置时、结果集返回后这些关键节点进行拦截和加工。结合自定义注解,我们可以优雅地标记出哪些实体类的字段是敏感数据。最终的目标是:开发者在实体类字段上加个
@SensitiveData
注解,后续所有通过Mybatis进行的插入、更新操作,这个字段的值会自动被加密后再入库;而在查询时,从数据库取出的密文又会自动解密成明文,填充回实体对象。整个过程对业务代码完全透明,开发者可以像操作普通字段一样读写这些敏感数据,极大地提升了开发效率和系统安全性。这个方案特别适合那些对数据安全有要求,但又不想让加解密逻辑侵入核心业务代码的场景。
2. 整体架构设计与核心思路拆解
2.1 为什么选择Mybatis拦截器+注解方案
面对敏感数据加解密的需求,通常有几种常见的方案。最简单粗暴的当然是在业务层的Service里,每次保存或查询前后手动调用加解密工具,但这样代码侵入性太强,重复劳动多,容易出错。另一种思路是利用数据库本身的功能,比如MySQL的AES_ENCRYPT/DECRYPT函数,但这将加解密逻辑绑定在了具体的SQL语句或数据库视图上,不够灵活,且更换数据库成本高。还有像使用JPA的
@Converter
注解或Hibernate的
@Type
注解,但这依赖于特定的ORM框架。
相比之下,Mybatis拦截器+自定义注解的方案优势明显。首先,Mybatis作为一款半自动化的ORM框架,其插件(拦截器)机制非常强大且标准,允许我们在
Executor
、
ParameterHandler
、
ResultSetHandler
、
StatementHandler
这四个核心组件的执行过程中插入自定义逻辑。其次,这个方案与业务代码完全解耦。加解密作为一个横切关注点,被独立到了拦截器中,实体类仅通过一个声明式的注解来标识敏感字段,符合“约定优于配置”的原则。最后,它的灵活性极高。我们可以自由选择加密算法(如AES、SM4)、密钥管理方式,并且可以精细控制拦截的SQL类型(INSERT、UPDATE、SELECT),甚至可以为不同的注解配置不同的加密策略。
2.2 核心组件与执行流程
整个方案主要包含三个核心组件,它们协同工作,形成一个完整的自动加解密管道。
-
自定义注解 (
@SensitiveData) :这是一个标记注解,它的唯一作用就是贴在实体类的字段上,声明“这个字段需要被自动加解密”。注解本身可以非常简洁,暂时不需要任何属性。未来如果需要扩展,比如支持不同的加密算法,可以增加一个algorithm()属性。 -
加密拦截器 (
EncryptInterceptor) :它主要拦截ParameterHandler。当Mybatis准备将Java实体对象(即Mapper方法的参数)设置到SQL语句的占位符(?)时,ParameterHandler的setParameters方法会被调用。我们的加密拦截器就在此刻介入,遍历实体对象的所有字段,检查是否带有@SensitiveData注解。如果发现,则获取该字段的原始值(明文),调用加密服务进行加密,然后再通过反射将加密后的密文设置回该字段(或一个临时的副本中),确保最终传入数据库的是密文。 -
解密拦截器 (
DecryptInterceptor) :它主要拦截ResultSetHandler。当Mybatis从数据库执行查询,拿到ResultSet结果集,并准备将其映射回Java实体对象时,ResultSetHandler的handleResultSets方法会被调用。解密拦截器在此刻介入,在Mybatis完成默认的结果集映射之后,遍历返回的实体对象,查找带有@SensitiveData注解的字段,获取其在数据库中的值(密文),调用解密服务进行解密,最后通过反射将解密后的明文设置回对象字段。
整个执行流程对于一次插入操作是这样的:
Mybatis Mapper调用
->
加密拦截器工作,将实体中注解字段加密
->
执行SQL,密文入库
。对于一次查询操作则是:
执行SQL,取出密文
->
Mybatis默认映射,对象字段值为密文
->
解密拦截器工作,将对象中注解字段解密
->
返回明文对象给业务层
。
注意 :这里有一个关键细节需要处理。加密拦截器直接修改了
ParameterHandler要处理的原始参数对象。为了避免对后续流程或同一对象在其他地方的使用造成不可预期的影响,一个更稳健的做法是,在拦截器内部创建参数对象的深拷贝或使用MetaObject工具进行安全的值替换。本文为简化核心逻辑,先采用直接修改的方式,但在“常见问题”章节会详细讨论更安全的实现。
3. 核心细节解析与实操要点
3.1 自定义注解的设计与使用
自定义注解
@SensitiveData
的设计追求极简和可扩展性。目前,它只是一个标记。
import java.lang.annotation.*;
/**
* 敏感数据注解。
* 被此注解标记的字段,在通过Mybatis持久化时会被自动加密,查询时会被自动解密。
*/
@Documented
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,这是关键!
@Target(ElementType.FIELD) // 只能用在字段上
public @interface SensitiveData {
// 未来可扩展属性,例如:
// String algorithm() default "AES";
// String keyId() default "default";
}
关键点解析 :
-
@Retention(RetentionPolicy.RUNTIME):这是生命线。必须设置为RUNTIME,注解信息才会在JVM运行期间保留,这样我们的拦截器才能通过反射机制在运行时获取到它。如果设置为CLASS或SOURCE,运行时是拿不到注解信息的。 -
@Target(ElementType.FIELD):明确指定该注解只能用于类的字段(成员变量)上,符合我们的设计意图。
在实体类中的使用示例 :
public class User {
private Long id;
private String name;
@SensitiveData
private String idCard; // 身份证号
@SensitiveData
private String mobile; // 手机号
private String email;
// ... getters and setters
}
使用起来非常简单直观。未来如果业务复杂,比如银行卡号需要用更强的SM4加密,而手机号用AES,我们就可以扩展注解,通过
@SensitiveData(algorithm = "SM4")
来指定。
3.2 Mybatis拦截器的实现原理与注册
Mybatis拦截器需要实现
org.apache.ibatis.plugin.Interceptor
接口。这个接口有三个方法:
-
intercept(Invocation invocation):这是核心方法,在这里编写你的拦截逻辑。 -
plugin(Object target):Mybatis会用这个方法来包装目标对象,生成代理对象。通常直接使用Plugin.wrap(target, this)即可。 -
setProperties(Properties properties):可以用来接收拦截器配置的参数。
但更关键的是
@Intercepts
和
@Signature
注解,它们用来声明你的拦截器要拦截哪个Mybatis组件、哪个方法。
加密拦截器签名示例 :
@Intercepts({
@Signature(type = ParameterHandler.class, // 拦截参数处理器
method = "setParameters", // 拦截设置参数的方法
args = {PreparedStatement.class}) // 该方法参数列表
})
@Component // 如果使用Spring,可加此注解方便管理
public class EncryptInterceptor implements Interceptor {
// ... 具体实现
}
解密拦截器签名示例 :
@Intercepts({
@Signature(type = ResultSetHandler.class, // 拦截结果集处理器
method = "handleResultSets", // 拦截处理结果集的方法
args = {Statement.class}) // 该方法参数列表
})
@Component
public class DecryptInterceptor implements Interceptor {
// ... 具体实现
}
拦截器的注册
:
如果你使用纯Mybatis,需要在
mybatis-config.xml
中配置:
<plugins>
<plugin interceptor="com.yourpackage.interceptor.EncryptInterceptor"/>
<plugin interceptor="com.yourpackage.interceptor.DecryptInterceptor"/>
</plugins>
如果你使用Spring Boot,通常更简单,只需要将拦截器类声明为Spring Bean(比如加上
@Component
注解),Mybatis-Spring-Boot-Starter在启动时会自动发现并注册这些实现了
Interceptor
接口的Bean。
3.3 加解密服务的设计与密钥管理
加解密逻辑应该被抽象成一个独立的服务,例如
SensitiveDataCryptoService
,然后在拦截器中注入并使用它。这样做有利于:
- 算法可替换 :今天用AES,明天想换国密SM4,只需修改这个服务的实现。
- 密钥管理集中化 :密钥的获取、存储、轮换等复杂逻辑被封装在此。
- 便于测试 :可以轻松Mock这个服务进行单元测试。
一个简单的AES加密服务实现示例:
@Service
public class AesCryptoService implements SensitiveDataCryptoService {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; // 使用CBC模式,需要IV
private SecretKeySpec secretKey;
private IvParameterSpec iv;
@PostConstruct
public void init() {
// !!!警告:以下仅为示例,生产环境绝不能将密钥硬编码在代码中!!!
String secretKeyStr = "Your32ByteLongSecretKey123!!"; // 32字节 for AES-256
String ivStr = "Your16ByteLongIV!!"; // 16字节
this.secretKey = new SecretKeySpec(secretKeyStr.getBytes(StandardCharsets.UTF_8), ALGORITHM);
this.iv = new IvParameterSpec(ivStr.getBytes(StandardCharsets.UTF_8));
}
@Override
public String encrypt(String plainText) {
try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv);
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encryptedBytes); // 输出Base64字符串,便于存储
} catch (Exception e) {
throw new RuntimeException("加密失败", e);
}
}
@Override
public String decrypt(String cipherText) {
try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv);
byte[] decodedBytes = Base64.getDecoder().decode(cipherText);
byte[] decryptedBytes = cipher.doFinal(decodedBytes);
return new String(decryptedBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("解密失败", e);
}
}
}
生产环境密钥管理要点(非常重要!) :
- 绝对禁止硬编码 :像上面示例那样把密钥写在代码里是严重的安全漏洞。
-
推荐方案
:
-
环境变量/启动参数
:通过
-D参数或系统环境变量传入。 - 配置中心 :从Apollo、Nacos等配置中心获取,支持动态刷新和权限控制。
- 密钥管理服务(KMS) :使用云服务商(如阿里云KMS、AWS KMS)或自建的HashiCorp Vault来管理密钥,实现密钥的安全生成、存储、轮换和访问审计。
- 分层加密 :使用一个主密钥(Master Key)加密数据密钥(Data Key),数据密钥再加密实际数据。主密钥妥善保管,数据密钥可与密文一起存储。
-
环境变量/启动参数
:通过
4. 实操过程与核心环节实现
4.1 加密拦截器(EncryptInterceptor)的完整实现
加密拦截器的核心任务是在SQL参数设置前,找到并加密所有标记了
@SensitiveData
的字段。
@Intercepts({
@Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class})
})
@Component
@Slf4j
public class EncryptInterceptor implements Interceptor {
@Autowired
private SensitiveDataCryptoService cryptoService;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 获取被拦截的目标对象,即ParameterHandler
ParameterHandler parameterHandler = (ParameterHandler) invocation.getTarget();
// 2. 通过MetaObject工具类,方便地操作对象的属性
// 这里获取的是Mapper方法传入的原始参数对象
MetaObject metaObject = SystemMetaObject.forObject(parameterHandler.getParameterObject());
// 3. 获取参数对象的类型。可能是单个实体,也可能是Map、集合等。
Object originalParameter = metaObject.getOriginalObject();
// 4. 处理加密逻辑
processEncryption(originalParameter);
// 5. 继续执行原方法(即设置参数到PreparedStatement)
return invocation.proceed();
}
private void processEncryption(Object parameter) {
if (parameter == null) {
return;
}
// 处理单个实体对象
if (!isCollectionOrMap(parameter)) {
encryptFields(parameter);
return;
}
// 处理集合(如List<User>)或数组
if (parameter instanceof Collection) {
for (Object item : (Collection<?>) parameter) {
encryptFields(item);
}
}
// 处理Map(通常用于@Param注解传递多个参数)
else if (parameter instanceof Map) {
Map<?, ?> paramMap = (Map<?, ?>) parameter;
for (Object value : paramMap.values()) {
if (value != null && !isBasicType(value.getClass())) {
encryptFields(value);
}
}
}
}
private void encryptFields(Object object) {
if (object == null) {
return;
}
Class<?> clazz = object.getClass();
// 遍历该类的所有字段(包括父类,按需)
for (Field field : clazz.getDeclaredFields()) {
// 检查字段是否被@SensitiveData注解标记
if (field.isAnnotationPresent(SensitiveData.class)) {
field.setAccessible(true); // 允许访问私有字段
try {
Object originalValue = field.get(object);
if (originalValue instanceof String) {
String plainText = (String) originalValue;
if (StringUtils.isNotBlank(plainText)) {
// 调用加密服务进行加密
String cipherText = cryptoService.encrypt(plainText);
// 将加密后的值设置回字段
field.set(object, cipherText);
log.debug("字段 [{}] 已加密,明文:{}, 密文:{}", field.getName(), plainText, cipherText);
}
} else {
log.warn("@SensitiveData注解只能用于String类型字段,字段 {} 类型为 {}", field.getName(), field.getType());
}
} catch (IllegalAccessException e) {
log.error("加密字段访问失败: {}", field.getName(), e);
}
}
}
}
// 辅助方法:判断是否为集合或Map
private boolean isCollectionOrMap(Object obj) {
return obj instanceof Collection || obj instanceof Map || obj.getClass().isArray();
}
// 辅助方法:判断是否为基本类型或包装类、String等
private boolean isBasicType(Class<?> clazz) {
return clazz.isPrimitive() ||
clazz == String.class ||
clazz == Integer.class ||
clazz == Long.class ||
// ... 其他包装类型
Number.class.isAssignableFrom(clazz) ||
clazz == Boolean.class ||
clazz == Date.class;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以从mybatis配置中读取属性,例如加密算法的选择
}
}
实操心得 :
-
MetaObject是Mybatis提供的一个非常强大的反射工具类,用它来操作对象属性比直接用Java原生反射更安全、更方便,它能很好地处理对象为null、获取泛型属性等情况。 -
对参数类型的判断(单个对象、集合、Map)至关重要。因为Mapper方法的参数可能是
User,可能是List<User>,也可能是@Param注解包装的Map。必须覆盖所有这些情况,否则会出现部分数据未加密的漏洞。 - 加密前判断字段值是否为空或空白字符串很有必要,可以避免不必要的加密操作和潜在错误。
4.2 解密拦截器(DecryptInterceptor)的完整实现
解密拦截器在结果集映射完成后执行,将对象中的密文字段解密。
@Intercepts({
@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})
})
@Component
@Slf4j
public class DecryptInterceptor implements Interceptor {
@Autowired
private SensitiveDataCryptoService cryptoService;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 先执行原方法,让Mybatis完成默认的结果集映射
// 此时返回的List<Object>中,对象的敏感字段值还是数据库中的密文
Object result = invocation.proceed();
// 2. 如果查询结果为空,直接返回
if (result == null) {
return null;
}
// 3. 处理解密逻辑
processDecryption(result);
// 4. 返回解密后的结果
return result;
}
@SuppressWarnings("unchecked")
private void processDecryption(Object result) {
// handleResultSets可能返回单个对象,也可能是List
if (result instanceof List) {
List<Object> resultList = (List<Object>) result;
for (Object item : resultList) {
decryptFields(item);
}
} else {
// 返回单个对象的情况,比如selectOne
decryptFields(result);
}
}
private void decryptFields(Object object) {
if (object == null || isBasicType(object.getClass())) {
return;
}
Class<?> clazz = object.getClass();
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(SensitiveData.class)) {
field.setAccessible(true);
try {
Object currentValue = field.get(object);
if (currentValue instanceof String) {
String cipherText = (String) currentValue;
if (StringUtils.isNotBlank(cipherText)) {
// 调用解密服务进行解密
String plainText = cryptoService.decrypt(cipherText);
field.set(object, plainText);
log.debug("字段 [{}] 已解密,密文:{}, 明文:{}", field.getName(), cipherText, plainText);
}
}
} catch (IllegalAccessException e) {
log.error("解密字段访问失败: {}", field.getName(), e);
} catch (Exception e) {
// 解密过程可能出错(如密文被篡改、密钥错误)
log.error("字段 [{}] 解密失败,密文值: {}", field.getName(), field.get(object), e);
// 根据业务需求决定:抛出异常、置空或保留密文
// field.set(object, null);
}
}
}
}
// ... plugin和setProperties方法同上,以及isBasicType辅助方法
}
关键点与避坑指南 :
-
执行顺序
:一定要先
invocation.proceed(),让Mybatis把数据库数据映射到对象里,我们再进行解密。顺序反了就没数据可解了。 -
结果类型判断
:
handleResultSets方法返回的是Object,但它可能是List(查询多条),也可能是单个实体对象(查询单条,即使接口声明返回List,Mybatis内部也可能优化)。所以必须做类型判断。 -
解密异常处理
:解密过程可能失败(例如密文格式错误、密钥不匹配)。这里需要谨慎处理。直接抛出异常会导致整个查询失败;静默吞掉异常可能导致业务逻辑错误。一个折中的方案是记录错误日志,并将该字段值设为
null或一个特定的错误标识,同时触发告警,让开发者能及时发现数据或密钥问题。 -
性能考量
:解密操作涉及反射和密码学计算,如果一次查询返回成千上万条记录,每个记录又有多个加密字段,可能会对性能产生影响。可以考虑对解密过程进行优化,比如缓存Field对象、使用更高效的反射库(如Spring的
ReflectionUtils),或者对于大批量导出等场景,提供绕过自动解密的开关。
4.3 处理复杂场景:嵌套对象与类型处理器(TypeHandler)
上面的实现假设了加密字段是实体类的直接
String
类型属性。但在实际项目中,你可能会遇到更复杂的场景。
场景一:嵌套对象中的敏感字段
例如,
User
对象里有一个
BankInfo
类型的字段
bankInfo
,而
BankInfo
对象里的
cardNumber
字段需要加密。
public class User {
private Long id;
private String name;
private BankInfo bankInfo; // 嵌套对象
}
public class BankInfo {
@SensitiveData
private String cardNumber;
private String bankName;
}
对于这种情况,我们需要修改
encryptFields
和
decryptFields
方法,使其能够递归处理对象字段。当发现某个字段不是基本类型且不为
null
时,递归调用
encryptFields
方法处理这个嵌套对象。
场景二:非String类型的敏感字段
我们的注解和拦截器目前只处理了
String
类型。但如果敏感数据是
BigDecimal
(金额)或
LocalDate
(生日)呢?一种思路是扩展
@SensitiveData
注解,支持指定序列化/反序列化方式,或者在加密前将对象转换为
String
(如JSON),解密后再转换回来。但这会引入复杂性。
一个更Mybatis风格的优雅解决方案是结合
自定义类型处理器(TypeHandler)
。你可以为需要加密的特定类型(如
EncryptedString
)创建一个
TypeHandler
。在
TypeHandler
的
setParameter
和
getResult
方法中实现加解密。然后在实体类中,敏感字段的类型使用这个自定义类型。
public class User {
private Long id;
// 使用自定义类型
private EncryptedString idCard;
}
// 在mybatis配置或字段上通过@MappedTypes、@MappedJdbcTypes指定TypeHandler
这种方案将加解密逻辑完全封装在类型转换层,与拦截器解耦,更加清晰。但它的缺点是需要在实体类中使用特定的类型,而不是通用的
String
。你可以根据项目的复杂度和团队偏好进行选择。对于大多数场景,拦截器处理
String
类型已经足够。
5. 常见问题与排查技巧实录
在实际开发和上线过程中,我踩过不少坑,也总结了一些排查技巧。
5.1 拦截器不生效的排查步骤
这是最常见的问题。明明配置了拦截器,但加解密就是没执行。
-
检查拦截器是否被Spring管理/Mybatis配置加载 :
-
Spring Boot项目
:确认拦截器类上有
@Component或其他Spring注解,并且所在包在Spring的组件扫描路径下。可以启动时查看日志,或通过ApplicationContext的getBean方法检查Bean是否存在。 -
纯Mybatis项目
:检查
mybatis-config.xml中<plugins>配置是否正确,路径是否写对。确保配置文件被正确加载。
-
Spring Boot项目
:确认拦截器类上有
-
检查
@Intercepts签名 :-
type:必须是Mybatis的四大接口之一(Executor.class,ParameterHandler.class,ResultSetHandler.class,StatementHandler.class)。大小写、拼写不能错。 -
method:必须是目标接口中存在的方法名。例如ParameterHandler的setParameters。 -
args:必须是目标方法参数类型的Class数组。仔细核对,比如PreparedStatement.class不能写成Statement.class。可以查看Mybatis源码确认方法签名。
-
-
检查Mapper方法执行路径 :
-
拦截器只对通过Mybatis
SqlSession执行的SQL生效。如果你在方法中直接使用了JdbcTemplate或者其他的数据库操作方式,拦截器是不会触发的。 - 确保你的操作(增删改查)是通过Mybatis的Mapper接口调用执行的。
-
拦截器只对通过Mybatis
-
启用Mybatis日志 : 在
application.yml中设置logging.level.com.your.mapper.package=DEBUG,查看执行的SQL语句和参数。观察参数是否已经是密文(对于插入),或结果是否还是密文(对于查询)。这能帮你判断是加密没生效还是解密没生效。
5.2 加解密过程中遇到的典型异常与处理
| 异常现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 加密失败:IllegalBlockSizeException / BadPaddingException |
1. 密钥长度与算法不匹配(如AES-128需16字节密钥,你用了15字节)。
2. 待加密文本编码问题。 3. 加密模式/填充方式配置错误。 |
1. 确认密钥字节长度正确。AES-128:16字节,AES-192:24字节,AES-256:32字节。
2. 加密前统一使用
UTF-8
编码获取字节。
3. 确认
Cipher.getInstance(“AES/CBC/PKCS5Padding”)
中的算法字符串正确。
|
| 解密失败:BadPaddingException |
1.
密钥错误
(最常见)。用于解密的密钥与加密时不同。
2. 密文在存储或传输中被篡改或损坏。 3. IV(初始化向量)不一致,特别是在CBC模式下。 |
1.
核对密钥来源
。确保加解密服务获取的是同一个密钥。检查环境变量、配置中心的值。
2. 检查数据库字段长度是否足够,Base64编码后的密文是否被截断。 3. 确保加解密使用相同的IV。IV可以固定,或随机生成后与密文一起存储(前16字节为IV)。 |
| 字段值为null,加解密拦截器未处理 |
1. 拦截器逻辑中未对
null
值做判断。
2. 实体类字段为基本类型(如
int
),无法为
null
,但数据库值为
NULL
,导致映射出错。
|
1. 在加密/解密前,先判断字段值是否为
null
或空字符串。
2. 实体类中敏感字段建议使用包装类型(
Integer
,
String
)而非基本类型。
|
| 批量插入/更新时,只有第一条数据被加密 | 拦截器中处理参数逻辑有误,可能只处理了参数对象本身,没有遍历其内部的集合。 |
回顾
processEncryption
方法,确保对
Collection
、
Array
、
Map
等类型的参数进行了递归或遍历处理。
|
| 解密后,字段值仍是密文 |
1. 解密拦截器未生效(参考5.1排查)。
2. 结果对象类型判断错误,解密逻辑未执行到该对象。 3. 字段名不匹配或反射访问失败。 |
1. 在
decryptFields
方法开始处打日志,确认方法被调用。
2. 调试查看
handleResultSets
返回的
result
对象的具体类型。
3. 检查
@SensitiveData
注解是否加在了正确的字段上,字段名是否拼写正确(区分大小写)。
|
5.3 性能优化与生产环境注意事项
-
反射性能 :拦截器中大量使用了反射
Field.get/set。虽然单次操作开销不大,但在高并发、大数据量场景下仍需关注。可以考虑使用缓存,将Class与需要加密/解密的Field[]数组缓存起来,避免每次拦截都通过getDeclaredFields()和遍历、判断注解。private static final Map<Class<?>, List<Field>> SENSITIVE_FIELD_CACHE = new ConcurrentHashMap<>(); private List<Field> getSensitiveFields(Class<?> clazz) { return SENSITIVE_FIELD_CACHE.computeIfAbsent(clazz, key -> Arrays.stream(clazz.getDeclaredFields()) .filter(field -> field.isAnnotationPresent(SensitiveData.class)) .peek(field -> field.setAccessible(true)) // 在这里一次性设置accessible .collect(Collectors.toList()) ); } -
密钥轮换与数据重加密 :为了安全,密钥需要定期轮换。但旧密钥加密的数据需要用旧密钥解密。这引入了密钥版本管理问题。一个通用的做法是在密文中嵌入密钥版本号或密钥ID。例如,将加密后的数据格式化为
{keyId}:{cipherText}。加解密服务根据keyId去查找对应的密钥。这样,新数据用新密钥加密,旧数据仍可用旧密钥解密。定期有一个后台任务,读取旧数据,用旧密钥解密后再用新密钥加密,完成数据重加密。 -
模糊查询与索引失效 :这是数据加密带来的一个经典难题。一旦对数据(如手机号、姓名)加密后存储,原本基于该字段的
LIKE模糊查询和精确查询索引都将失效,因为数据库存储的是无规律的密文。解决方案有:- 业务侧妥协 :放弃模糊查询,或改为先精确查询其他条件,再在内存中解密后过滤(数据量不能大)。
- 保留明文哈希 :额外存储一个字段,保存敏感数据的哈希值(如SHA256),用于精确匹配查询。但哈希无法支持模糊查询。
- 使用可搜索加密方案 :这是一个高级密码学领域,如确定性加密(同一明文始终加密成同一密文)可以支持等值查询,但会降低安全性。通常需要专业的密码学库支持,不建议自行实现。
-
历史数据迁移 :上线前,数据库中已有大量明文历史数据。需要编写一个一次性迁移脚本,遍历相关表,读取明文,调用与拦截器相同的加密逻辑进行加密,再写回。 务必注意 :迁移过程应在业务低峰期进行,并做好完整的数据备份和回滚方案。迁移完成后,需全面验证数据一致性。
最后,我个人在实际使用中的体会是,这套“Mybatis拦截器+注解”的方案,在清晰度、维护性和开发效率上取得了很好的平衡。它成功地将安全关注点从业务代码中剥离,让开发者能更专注于业务逻辑本身。最大的挑战往往不在于拦截器本身,而在于密钥的安全管理和加密后带来的查询复杂度上升。因此,在项目设计初期,就需要和产品、DBA充分沟通,明确哪些字段需要加密,以及加密后对应的查询需求该如何满足,这样才能让技术方案更好地服务于业务。



240

被折叠的 条评论
为什么被折叠?



