1. 项目概述:为什么要在MyBatis层做数据加解密?
最近在做一个涉及用户敏感信息的项目,比如手机号、身份证号、邮箱这些,数据库里明文存储总觉得心里不踏实。虽然数据库本身有加密功能,但总觉得粒度不够细,而且有时候业务上需要针对特定字段进行灵活的加解密处理。这时候,在持久层,也就是MyBatis这一层动手脚,就成了一个很自然的选择。直接在Java对象和数据库之间插入一个“转换器”,数据入库时自动加密,出库查询时自动解密,对业务代码几乎透明,想想就挺优雅的。
我选择了AES算法,主要是因为它足够成熟、高效且安全,是业界对称加密的标配。结合MyBatis的插件机制,我们可以自定义一个TypeHandler或者拦截器,在数据进出数据库的“最后一公里”完成加解密操作。这比在每一个Service方法里手动调用加解密工具类要清爽得多,也避免了遗漏加密导致数据泄露的风险。这个方案特别适合那些对特定字段有强隐私保护需求,但又希望保持业务代码简洁性的场景。
2. 核心思路与架构设计
2.1 为什么是MyBatis插件/TypeHandler?
MyBatis作为ORM框架,其核心工作就是将Java对象(POJO)的属性与数据库表的字段进行映射和转换。这个转换过程主要发生在两个环节:
- 参数映射(Parameter Mapping):将Java方法的入参(如一个User对象的phone属性)设置到SQL语句的预编译参数(
PreparedStatement)中。 - 结果映射(Result Mapping):将SQL查询结果集(
ResultSet)中的值,提取并设置到返回的Java对象(如User对象的phone属性)中。
我们的目标就是“劫持”这两个环节。当MyBatis试图将phone这个字符串写入数据库时,我们拦截它,先将其加密成密文再写入;反之,当从数据库读取phone字段时,我们拦截读取到的密文,将其解密后再塞回Java对象。
实现这个“劫持”主要有两种主流方式:
- 自定义TypeHandler:这是最精准、最符合MyBatis设计哲学的方式。
TypeHandler专门用于处理特定Java类型与JDBC类型之间的转换。我们可以为需要加密的字段类型(如String)注册一个自定义的EncryptTypeHandler。这种方式侵入性低,配置清晰,一个TypeHandler只关心一种类型的加解密逻辑。 - 自定义插件(Interceptor):插件更加强大和灵活,它可以拦截MyBatis执行过程中的多个核心点,比如
Executor的update和query方法。我们可以在插件里遍历所有参数和结果,根据注解或字段名判断是否需要加解密。这种方式适合需要对大量字段或复杂规则进行加解密的场景,但实现起来稍复杂,对性能有细微影响。
对于字段级别的精准加解密,我强烈推荐使用TypeHandler。它逻辑纯粹,与MyBatis的映射机制完美契合,性能和可维护性都更好。下文也将以TypeHandler方案为主进行详解。
2.2 AES加解密方案选型
确定了拦截点,接下来要决定怎么加密。AES算法本身有几个关键点需要确定:
工作模式(Mode):常用的有ECB、CBC、GCM等。
- ECB:最简单,每个数据块独立加密。绝对不要用!因为相同的明文块会生成相同的密文块,无法隐藏数据模式,安全性很差。
- CBC:最常用的模式之一。它需要一个初始化向量(IV)来增加随机性,相同的明文每次加密结果都不同,更安全。我们需要将IV和密文一起存储或传输。
- GCM:一种认证加密模式,既能保密又能防篡改,还自带消息认证码(MAC)。性能好且更安全,是现代应用的首选,尤其推荐在TLS 1.3等场景中使用。Java 8及以上版本支持。
选择建议:如果运行环境是Java 8+,优先选择AES/GCM/NoPadding。它更安全,且不需要我们单独处理填充(Padding)。如果考虑更广泛的兼容性,
AES/CBC/PKCS5Padding是经过充分验证的可靠选择。本文示例将使用AES/CBC/PKCS5Padding,因为它更通用,原理也更易于理解。密钥管理:这是安全的核心。密钥绝不能硬编码在代码中。
- 环境变量/配置中心:将Base64编码后的密钥放在应用的环境变量、
application.yml或配置中心(如Nacos, Apollo)中。这是最常见和推荐的做法。 - KMS服务:在云环境中,可以使用阿里云KMS、AWS KMS等服务来生成和管理密钥,应用在运行时动态获取。安全性最高,但架构复杂。
- 文件系统:将密钥文件放在服务器特定目录,通过权限严格控制访问。
绝对禁止:将密钥提交到版本控制系统(如Git)。
- 环境变量/配置中心:将Base64编码后的密钥放在应用的环境变量、
IV(初始化向量)处理:CBC模式需要IV。为了保证每次加密结果不同且能正确解密,我们需要为每次加密生成一个随机的IV。通常有两种方式处理IV:
- 与密文拼接:将IV(16字节)和加密后的密文拼接在一起,存储在一个字段中。解密时,先取出前16字节作为IV,剩余部分作为密文进行解密。这是最常用的方式。
- 固定IV(不推荐):使用固定的IV会显著降低安全性,违背了CBC模式的初衷。
2.3 整体架构图(逻辑描述)
整个流程可以这样理解:
- 应用启动时,从安全渠道(如环境变量)加载AES密钥。
- 在MyBatis的Mapper XML文件中,为需要加密的字段(如
phone)指定我们编写的EncryptTypeHandler。 - 当执行
INSERT或UPDATE时,MyBatis会调用EncryptTypeHandler.setParameter()方法,该方法会使用AES密钥和随机生成的IV对原始字符串进行加密,并将“IV+密文”拼接后写入数据库。 - 当执行
SELECT查询时,MyBatis会调用EncryptTypeHandler.getResult()方法,该方法从数据库读取“IV+密文”组合字符串,分离出IV和密文,然后用相同的AES密钥进行解密,将明文返回给Java对象。
这样,对于业务开发人员来说,他们操作User对象的phone属性时,拿到的一直是明文,完全感知不到底层的数据是加密存储的。
3. 核心工具类:AES加解密实现
在实现TypeHandler之前,我们先要打造一个可靠、线程安全的AES加解密工具类。这是所有功能的基石。
3.1 密钥的生成与安全存储
首先,我们需要一个AES密钥。AES-256需要一个32字节(256位)的密钥。我们可以用以下命令(或在Java代码中)生成一个:
# 使用OpenSSL生成一个32字节的随机密钥,并用Base64编码 openssl rand -base64 32输出类似:aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefghij==
重要:生成的这个Base64字符串就是你的密钥。它必须被安全地存储。在生产环境中,你应该这样做:
- 将密钥存入服务器的环境变量,例如
APP_AES_KEY=aBcDeF...。 - 在Spring Boot的
application.yml中,通过环境变量引用:app: encrypt: aes-key: ${APP_AES_KEY} - 绝对不要将
application.yml中包含真实密钥的文件提交到代码仓库。可以使用application-dev.yml(加入.gitignore)或配置中心来管理。
3.2 AES工具类完整实现
下面是一个完整的、支持AES/CBC/PKCS5Padding并自动处理IV的工具类。我加上了详细的注释和关键步骤说明。
import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; /** * AES加解密工具类 (CBC模式示例) * 采用 AES/CBC/PKCS5Padding,自动处理IV(拼接在密文前) */ public class AesUtil { private static final String ALGORITHM = "AES"; private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final int IV_LENGTH = 16; // AES块大小是16字节,CBC模式IV长度需一致 private final SecretKeySpec secretKeySpec; /** * 构造函数 * @param base64Key Base64编码的AES密钥(256位需32字节原始密钥,Base64后更长) */ public AesUtil(String base64Key) { if (base64Key == null || base64Key.trim().isEmpty()) { throw new IllegalArgumentException("AES密钥不能为空"); } try { // 将Base64密钥解码为字节数组 byte[] decodedKey = Base64.getDecoder().decode(base64Key.trim()); // 根据解码后的字节长度确定是AES-128, 192还是256 // 这里我们期望是32字节(256位) if (decodedKey.length != 16 && decodedKey.length != 24 && decodedKey.length != 32) { throw new IllegalArgumentException("无效的AES密钥长度。必须是16(128位), 24(192位)或32字节(256位)。"); } this.secretKeySpec = new SecretKeySpec(decodedKey, ALGORITHM); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("无效的Base64密钥格式", e); } } /** * 加密 * @param plainText 明文 * @return Base64编码的字符串,格式为:Base64(IV + 密文) */ public String encrypt(String plainText) { if (plainText == null) { return null; } try { Cipher cipher = Cipher.getInstance(TRANSFORMATION); // 生成一个随机的初始化向量(IV) byte[] iv = new byte[IV_LENGTH]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(iv); IvParameterSpec ivSpec = new IvParameterSpec(iv); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivSpec); // 执行加密 byte[] cipherTextBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 将IV和密文拼接在一起: IV + CipherText byte[] combined = new byte[iv.length + cipherTextBytes.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(cipherTextBytes, 0, combined, iv.length, cipherTextBytes.length); // 返回Base64编码后的组合数据 return Base64.getEncoder().encodeToString(combined); } catch (Exception e) { throw new RuntimeException("AES加密失败", e); } } /** * 解密 * @param encryptedBase64Text Base64编码的加密字符串(格式为Base64(IV+密文)) * @return 明文 */ public String decrypt(String encryptedBase64Text) { if (encryptedBase64Text == null) { return null; } try { // 解码Base64字符串 byte[] combined = Base64.getDecoder().decode(encryptedBase64Text.trim()); // 分离IV和密文:前IV_LENGTH字节是IV,后面的是密文 if (combined.length < IV_LENGTH) { throw new IllegalArgumentException("加密文本太短,无法提取IV"); } byte[] iv = new byte[IV_LENGTH]; byte[] cipherTextBytes = new byte[combined.length - IV_LENGTH]; System.arraycopy(combined, 0, iv, 0, IV_LENGTH); System.arraycopy(combined, IV_LENGTH, cipherTextBytes, 0, cipherTextBytes.length); Cipher cipher = Cipher.getInstance(TRANSFORMATION); IvParameterSpec ivSpec = new IvParameterSpec(iv); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivSpec); // 执行解密 byte[] plainTextBytes = cipher.doFinal(cipherTextBytes); return new String(plainTextBytes, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("AES解密失败", e); } } /** * 生成一个随机的AES-256密钥(Base64编码) * 注意:此方法仅用于本地测试生成密钥,生产环境密钥应通过安全流程管理。 */ public static String generateRandomBase64Key() { try { KeyGenerator keyGen = KeyGenerator.getInstance(ALGORITHM); keyGen.init(256); // 指定密钥长度为256位 SecretKey secretKey = keyGen.generateKey(); return Base64.getEncoder().encodeToString(secretKey.getEncoded()); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } // 简单测试 public static void main(String[] args) { // !!! 警告:仅用于测试 !!! String testKey = generateRandomBase64Key(); System.out.println("测试密钥 (Base64): " + testKey); AesUtil aesUtil = new AesUtil(testKey); String originalText = "13800138000"; // 测试手机号 String encrypted = aesUtil.encrypt(originalText); System.out.println("加密后: " + encrypted); String decrypted = aesUtil.decrypt(encrypted); System.out.println("解密后: " + decrypted); System.out.println("匹配: " + originalText.equals(decrypted)); } }关键点解析与注意事项:
- 线程安全:
AesUtil本身是无状态的(除了final的secretKeySpec),Cipher实例在每次加密/解密时创建。因此,将AesUtil实例声明为Spring的@Component单例是线程安全的。 - IV处理:这是核心。
encrypt方法中,我们使用SecureRandom生成一个强随机的16字节IV。加密后,将IV和密文拼接,再整体做Base64编码。解密时,先Base64解码,然后切分出前16字节作为IV,剩余部分作为密文。这种方式确保了每次加密结果都不同,且解密时能拿到正确的IV。 - 异常处理:工具类中将
Exception包装为RuntimeException抛出。在实际的TypeHandler中,我们需要根据MyBatis的接口定义,决定是抛出SQLException还是进行其他处理。 - 密钥长度:代码中兼容了128、192、256位密钥。使用256位(32字节)密钥能提供更高的安全强度,但需要注意,如果使用256位,Java运行时可能需要安装“Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files”,否则可能抛出
Illegal key size异常。Java 8 Update 161及以上版本默认支持无限强度策略。
4. 实现MyBatis TypeHandler
有了强大的AES工具,现在我们来创建MyBatis的TypeHandler。它将负责在数据库字段(VARCHAR)和Java对象属性(String)之间进行加解密转换。
4.1 基础TypeHandler实现
我们创建一个通用的EncryptTypeHandler,它依赖上面实现的AesUtil。
import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.MappedJdbcTypes; import org.apache.ibatis.type.MappedTypes; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; /** * 用于字符串字段加解密的TypeHandler * 注意:此Handler假设数据库存储的是加密后的Base64字符串。 */ @Component // 让Spring管理,方便注入AesUtil @MappedTypes(String.class) // 指定处理的Java类型 @MappedJdbcTypes(JdbcType.VARCHAR) // 指定处理的JDBC类型,也可以是JdbcType.CLOB等 public class EncryptTypeHandler extends BaseTypeHandler<String> { @Autowired private AesUtil aesUtil; @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { // 当向SQL语句设置参数时,对明文进行加密 try { String encryptedText = aesUtil.encrypt(parameter); ps.setString(i, encryptedText); } catch (Exception e) { // MyBatis期望抛出SQLException throw new SQLException("字段加密失败", e); } } @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { // 从ResultSet中按列名获取数据时,对密文进行解密 String encryptedText = rs.getString(columnName); return decryptFromDatabase(encryptedText); } @Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { // 从ResultSet中按列索引获取数据时,对密文进行解密 String encryptedText = rs.getString(columnIndex); return decryptFromDatabase(encryptedText); } @Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { // 从CallableStatement(存储过程)中获取数据时,对密文进行解密 String encryptedText = cs.getString(columnIndex); return decryptFromDatabase(encryptedText); } /** * 统一的解密方法,处理数据库中的null值 */ private String decryptFromDatabase(String encryptedText) throws SQLException { if (encryptedText == null) { return null; } try { return aesUtil.decrypt(encryptedText); } catch (Exception e) { throw new SQLException("字段解密失败,列值可能不是有效的加密文本: " + encryptedText, e); } } }4.2 在Mapper XML中配置TypeHandler
现在,我们需要在MyBatis的Mapper XML文件中,为具体的字段指定使用这个EncryptTypeHandler。
假设我们有一个User实体和对应的t_user表,其中phone和id_card字段需要加密存储。
实体类User.java:
public class User { private Long id; private String name; private String phone; // 需要加密 private String idCard; // 需要加密 private String email; // ... getters and setters }Mapper XMLUserMapper.xml:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.UserMapper"> <resultMap id="BaseResultMap" type="com.example.entity.User"> <id column="id" property="id"/> <result column="name" property="name"/> <!-- 关键配置:对phone和id_card字段使用自定义的TypeHandler --> <result column="phone" property="phone" typeHandler="com.example.handler.EncryptTypeHandler"/> <result column="id_card" property="idCard" typeHandler="com.example.handler.EncryptTypeHandler"/> <result column="email" property="email"/> </resultMap> <insert id="insert" parameterType="com.example.entity.User"> INSERT INTO t_user (name, phone, id_card, email) VALUES ( #{name}, #{phone, typeHandler=com.example.handler.EncryptTypeHandler}, <!-- 插入时加密 --> #{idCard, typeHandler=com.example.handler.EncryptTypeHandler}, #{email} ) </insert> <select id="selectById" resultMap="BaseResultMap"> SELECT id, name, phone, id_card, email FROM t_user WHERE id = #{id} </select> <!-- 注意:在WHERE条件中使用加密字段进行精确查询会非常麻烦,通常不建议。 --> <!-- 如果必须查询,需要先对查询条件手动加密,再与数据库密文比较。 --> <select id="selectByPhone" resultMap="BaseResultMap"> SELECT id, name, phone, id_card, email FROM t_user WHERE phone = #{phone, typeHandler=com.example.handler.EncryptTypeHandler} <!-- 查询条件也需加密 --> </select> <update id="update" parameterType="com.example.entity.User"> UPDATE t_user SET name = #{name}, phone = #{phone, typeHandler=com.example.handler.EncryptTypeHandler}, id_card = #{idCard, typeHandler=com.example.handler.EncryptTypeHandler}, email = #{email} WHERE id = #{id} </update> </mapper>配置要点:
resultMap中定义:在映射结果集时,为加密字段指定typeHandler。这样,MyBatis从数据库取出数据后,会自动调用EncryptTypeHandler.getNullableResult进行解密。- SQL语句中定义:在
INSERT和UPDATE语句的#{}占位符中,同样为参数指定typeHandler。这样,MyBatis在设置参数时,会自动调用EncryptTypeHandler.setNonNullParameter进行加密。 - 查询条件的陷阱:注意
selectByPhone的例子。如果你想通过加密字段进行精确查询(WHERE phone = ?),那么传入的查询条件#{phone}也必须经过相同的加密处理,否则数据库里存的是密文,你用明文去对比,永远查不到。这带来了一个严重问题:失去了该字段的索引效率,且模糊查询(LIKE)变得不可能。这是字段级加密的一个通用痛点,通常的解决方案是:- 业务上避免直接查询:通过其他非加密字段(如用户ID)查询。
- 使用哈希摘要:额外存储一个不可逆的哈希值(如SHA-256)用于等值查询,但无法范围查询。
- 应用层过滤:查询出所有数据在内存中解密后过滤(数据量小的情况)。
4.3 在Spring Boot中集成与配置
为了让EncryptTypeHandler和AesUtil生效,我们需要进行一些Spring Boot的配置。
配置AES密钥:在
application.yml中,通过环境变量注入密钥。app: encrypt: aes-key: ${APP_AES_KEY:defaultTestKeyBase64EncodedHere} # 生产环境务必使用环境变量创建AesUtil Bean:
@Configuration public class EncryptConfig { @Value("${app.encrypt.aes-key}") private String aesKey; @Bean public AesUtil aesUtil() { // 这里可以增加更复杂的密钥校验逻辑 return new AesUtil(aesKey); } }确保TypeHandler被扫描:如果你的
EncryptTypeHandler使用了@Component注解,并且位于Spring Boot主应用类所在的包或其子包下,它会被自动扫描并注册到Spring容器。MyBatis-Spring-Boot-Starter通常会自动注册带有@MappedTypes注解的TypeHandler。如果未自动注册,可以在application.yml中指定:mybatis: type-handlers-package: com.example.handler # 你的TypeHandler所在包
5. 高级话题与生产级优化
基础的TypeHandler已经能跑通了,但要用于生产,还有几个关键问题需要解决。
5.1 如何支持“按需加解密”?—— 注解驱动方案
上面的方案要求我们在每个加密字段的XML中手动添加typeHandler,略显繁琐且容易遗漏。更优雅的方式是使用自定义注解来标记需要加密的字段,然后通过MyBatis的插件(Interceptor)在运行时动态处理。
第一步:定义注解
import java.lang.annotation.*; @Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD}) public @interface EncryptField { }第二步:在实体类上标记
public class User { private Long id; private String name; @EncryptField private String phone; @EncryptField private String idCard; private String email; // ... getters and setters }第三步:实现一个MyBatis插件(核心)这个插件会拦截Executor的update和query方法,遍历参数对象和结果对象,对带有@EncryptField注解的字段进行加解密。
import org.apache.ibatis.executor.Executor; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.plugin.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.lang.reflect.Field; import java.util.*; @Intercepts({ @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) @Component public class EncryptInterceptor implements Interceptor { @Autowired private AesUtil aesUtil; @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; String methodName = invocation.getMethod().getName(); // 1. 处理 UPDATE/INSERT (加密) if ("update".equals(methodName)) { processEncrypt(parameter); } // 2. 执行原始方法 Object result = invocation.proceed(); // 3. 处理 QUERY 结果 (解密) if ("query".equals(methodName) && result instanceof List) { for (Object item : (List<?>) result) { processDecrypt(item); } } else if ("query".equals(methodName) && result != null) { // 处理单个对象结果 processDecrypt(result); } return result; } // 加密对象中带有@EncryptField注解的字段 private void processEncrypt(Object parameter) throws IllegalAccessException { if (parameter == null) return; // 这里简化处理,实际可能需要处理Map、Collection等多种参数类型 encryptOrDecryptFields(parameter, true); } // 解密对象中带有@EncryptField注解的字段 private void processDecrypt(Object result) throws IllegalAccessException { if (result == null) return; encryptOrDecryptFields(result, false); } private void encryptOrDecryptFields(Object obj, boolean isEncrypt) throws IllegalAccessException { Class<?> clazz = obj.getClass(); // 遍历所有字段(包括父类,可按需调整) while (clazz != null && clazz != Object.class) { Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { if (field.isAnnotationPresent(EncryptField.class) && field.getType() == String.class) { field.setAccessible(true); String value = (String) field.get(obj); if (value != null) { String processedValue = isEncrypt ? aesUtil.encrypt(value) : aesUtil.decrypt(value); field.set(obj, processedValue); } } } clazz = clazz.getSuperclass(); } } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { // 可以从配置读取属性 } }这种方式的优缺点:
- 优点:对业务代码和Mapper XML无侵入,只需要在实体字段上加注解即可。可以集中管理加解密逻辑。
- 缺点:
- 性能开销:每次执行SQL都需要通过反射遍历对象字段,对性能有轻微影响。
- 复杂性:需要处理复杂的参数类型(如Map、Collection、多个参数等),上面的示例是简化版。
- 结果集处理:插件只能处理从Executor返回的最终结果对象。如果结果是通过
resultMap中的association或collection进行复杂映射的,插件可能无法触及嵌套对象内的加密字段。TypeHandler则没有这个问题,因为它工作在更底层的字段映射阶段。
生产建议:对于字段数量固定、模型清晰的场景,优先使用TypeHandler并在XML中显式配置,它更稳定、性能更好、与MyBatis映射机制结合更紧密。注解驱动插件更适合快速原型或字段加解密规则频繁变化的场景。
5.2 密钥轮换与数据迁移
密钥不能永远不变。出于安全最佳实践,需要定期轮换密钥。但这带来了一个难题:旧密钥加密的数据如何用新密钥解密?或者,如何将旧数据重新用新密钥加密?
常用方案:密钥版本化管理
- 为每个密钥附加一个版本号(如
key_v1,key_v2)。 - 在加密时,不仅存储密文,还将当前使用的密钥版本号一起存储(例如,在密文前加一个前缀
v1:,或者单独用一个字段key_version存储)。 - 解密时,先读取版本号,然后使用对应版本的密钥进行解密。
- 密钥轮换时:
- 新数据使用新密钥(
v2)加密。 - 旧数据可以逐步迁移:在后台任务中,读取用
v1加密的数据,用v1密钥解密,再用v2密钥重新加密,并更新版本号。这个过程可以异步、分批进行,不影响线上服务。
- 新数据使用新密钥(
这需要对我们的AesUtil和存储格式进行改造。例如,加密后的字符串格式变为:{version}:{base64(iv+ciphertext)}。
5.3 与MyBatis-Plus等增强框架的协作
如果你在使用MyBatis-Plus,它的@TableField注解有一个typeHandler属性,可以更方便地配置:
import com.baomidou.mybatisplus.annotation.TableField; public class User { // ... @TableField(typeHandler = EncryptTypeHandler.class) private String phone; // ... }这样,在MyBatis-Plus的insert,updateById,selectById等方法中,加解密会自动生效。但需要注意的是,如果你同时使用了自定义的XML Mapper,需要确保配置的一致性,有时XML的typeHandler会覆盖注解的配置。
6. 常见问题、排查技巧与性能考量
在实际落地过程中,你肯定会遇到一些坑。下面是我总结的一些常见问题和解决方法。
6.1 常见问题速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 插入数据成功,但查出来是乱码或解密失败 | 1. 加解密密钥不一致。 2. IV处理逻辑不一致,加密和解密时拼接/分离方式不对。 3. 数据库字段长度不足,密文被截断。 | 1.检查密钥:确认加解密使用的AesUtil实例是同一个,且密钥字符串完全一致(注意首尾空格)。2.Debug加解密过程:在 encrypt和decrypt方法内打印(或日志记录)IV和密文的Hex或Base64值,对比加密时生成的combined数组和解密时分离出的iv及cipherTextBytes是否完全一致。3.检查字段长度:加密后的Base64字符串会比原文长很多(大约~133%)。确保数据库表字段(如 VARCHAR)长度足够,建议预留4倍于明文的长度。 |
查询时返回null | 1.TypeHandler的getNullableResult方法中,对数据库NULL值处理不当。2. 插件(Interceptor)解密时误将非加密字段或 null值进行了处理。 | 1.检查TypeHandler:确保在rs.getString返回null时,直接返回null,而不是尝试解密。2.检查插件逻辑:在插件的 processDecrypt方法中,增加空值判断和字段类型判断。 |
启动报错:InvalidKeyException或Illegal key size | 1. Java默认限制了加密强度。 2. 密钥格式错误或长度不对。 | 1.安装JCE策略文件:对于Java 8,从Oracle官网下载并替换$JAVA_HOME/jre/lib/security/下的local_policy.jar和US_export_policy.jar。Java 8u161及以上版本默认已解除限制。2.检查密钥:确认密钥是合法的Base64字符串,且解码后的字节长度是16、24或32。 |
| 使用插件后,部分字段加解密不生效 | 1. 插件拦截的时机不对,可能被其他插件影响。 2. 实体类字段未正确添加 @EncryptField注解,或注解未被扫描到。3. 插件未处理复杂嵌套对象或集合类型。 | 1.调整插件顺序:通过@Intercepts的args或@Order注解调整插件执行顺序。2.检查注解:确认字段上有 @EncryptField,且插件能访问到该字段(非private或已setAccessible(true))。3.增强插件:完善插件的 processEncrypt/Decrypt方法,使其能递归处理对象内的集合、数组和嵌套对象属性。 |
| 模糊查询(LIKE)无法使用 | 这是字段加密的固有缺陷,密文失去了明文的顺序和模式。 | 业务侧解决: 1. 避免对加密字段进行模糊查询。 2. 如需搜索,考虑对明文建立分词索引(如Elasticsearch),或存储一个不可逆的哈希值(如SHA-256)用于精确匹配,但无法LIKE。 3. 极端情况下,可在内存中解密所有数据后过滤(仅适用于极小数据集)。 |
6.2 性能影响分析与优化建议
加解密操作是CPU密集型计算,必然会带来性能开销。我们需要评估并优化:
- 开销量化:一次AES-256 CBC加密/解密操作,对于手机号(11字节)这样的短文本,在主流CPU上耗时通常在微秒级(< 100μs)。对于单条数据操作,开销可忽略不计。但在批量插入/查询(如导入/导出10万条数据)时,累积耗时可能达到几十秒。
- 优化建议:
- 选择性加密:只加密真正的敏感字段(如身份证、手机号、银行卡号),不要滥用。
- 批处理优化:在批量操作时,可以考虑在业务层先批量加密好数据,再一次性提交给MyBatis,减少在
TypeHandler中频繁创建Cipher对象的开销。但要注意这破坏了透明性。 - 使用更快的模式:GCM模式通常比CBC略快一些。如果兼容性允许,可以考虑切换。
- 连接池监控:加解密会略微增加数据库操作的整体耗时,需关注数据库连接池的使用情况,避免因操作变慢导致连接被占满。
- 异步解密:对于大批量数据查询且实时性要求不高的场景,可以考虑先返回密文给前端或中间层,在需要展示时再异步解密。但这需要前后端协议配合。
6.3 安全加固建议
- 密钥分离:加解密密钥与数据库访问凭证、应用密钥等分离存储。
- 访问日志审计:记录所有对加密数据的访问日志,包括操作时间、用户、访问的数据ID等,便于事后追溯。
- 防御密码学攻击:确保使用CBC模式时,IV必须是密码学安全的随机数(
SecureRandom)。考虑使用认证加密模式(如GCM)来同时保证机密性和完整性。 - 定期安全评估:定期审查加解密方案,关注是否有新的密码学漏洞或更优的算法出现。
7. 总结与个人心得
折腾完这一套MyBatis隐私数据加解密的方案,最大的感受就是:安全、透明、便利,三者往往需要权衡。
关于TypeHandlervs 插件(Interceptor):我个人的项目里,最终选择了在XML中显式配置TypeHandler。虽然每个字段都要配有点麻烦,但它的行为最可预测,性能最优,而且和MyBatis的映射机制是“原生”配合的。注解驱动的插件看起来很美好,但在处理复杂映射、嵌套结果时容易有盲区,调试起来也更费劲。除非你的加密字段非常多且变动频繁,否则TypeHandler的明确性更值得信赖。
关于查询的痛点:这是字段级加密无法回避的问题。一旦加密,这个字段就几乎失去了数据库层面的查询能力(除了等值查询,且需要先加密参数)。在设计表结构初期,就必须想清楚哪些字段需要加密,以及它们是否需要被查询。一个常见的做法是,对于手机号,同时存储一个不可逆的哈希值(如SHA-256(手机号+盐))在一个单独的列,用于唯一性校验或精确查找,而加密的原文则用于业务展示和验证。
关于密钥管理:这比代码实现更重要。一定要杜绝硬编码。环境变量、配置中心、甚至专门的密钥管理服务(KMS)是必选项。密钥轮换方案也要在项目早期就设计好,否则数据量大了再迁移就是噩梦。
最后,没有银弹。这套方案很好地解决了“存储层透明加解密”的问题,但它只是数据安全链条中的一环。网络传输安全(HTTPS)、接口权限控制、操作日志审计等都同样重要。把MyBatis这一层的加密做好,相当于给数据保险箱又加上了一把牢固的锁,让整个应用的安全水位提升了一个台阶。