MyBatis-Plus字段自动加解密实践:无侵入保护敏感数据

1. 项目概述:为什么要在MyBatis-Plus中做字段加解密?

最近在重构一个老项目,涉及到用户敏感信息的处理,比如手机号、身份证号这些。直接明文存数据库?风险太高,一旦拖库就全完了。全表加密?性能开销太大,而且业务查询起来会非常麻烦。最后我们决定在数据持久层,也就是MyBatis-Plus这一层,对特定的敏感字段进行自动加解密。这个需求听起来简单,但真做起来,从方案选型到落地踩坑,还是有不少门道的。

简单来说,我们的目标就是:让程序代码(尤其是Service层)像往常一样读写实体对象,完全感知不到加解密的存在。数据在进入数据库之前,由MyBatis-Plus的插件机制自动加密;从数据库查询出来映射到实体对象时,再自动解密。这样一来,业务逻辑完全不受影响,而数据库里存的已经是密文,安全性得到了保障。这特别适合处理用户隐私数据、金融交易信息等场景,也是目前很多合规性要求(如等保、GDPR)下的常见实践。

2. 核心方案设计与技术选型

2.1 为什么选择MyBatis-Plus插件机制?

实现字段级别的加解密,有几个常见的切入点:在Service层手动调用加解密工具、使用AOP拦截DAO方法、或者利用数据库本身的功能(如MySQL的AES_ENCRYPT)。我们最终选择了MyBatis-Plus的插件机制,主要是基于以下几点考量:

  1. 对业务代码无侵入:这是最重要的原则。Service层和Controller层应该只关心业务逻辑,不应该充斥着encrypt()decrypt()的调用。MyBatis-Plus的TypeHandler(类型处理器)和Interceptor(拦截器)可以完美地嵌入到SQL执行的生命周期中,在数据进出数据库的“最后一公里”完成转换,业务层完全无感。
  2. 粒度可控,灵活度高:我们可以精确到实体类的某个字段进行加解密,而不是整个表或整个数据库。通过注解或自定义TypeHandler的方式,可以灵活地指定哪些字段需要处理,使用哪种算法。
  3. 与MyBatis-Plus生态无缝集成:项目本身就在用MyBatis-Plus,利用其提供的扩展点,开发成本低,稳定性也更有保障,不用担心和分页插件、乐观锁插件等产生冲突。
  4. 性能损耗相对可控:加解密计算发生在应用服务器,虽然比不加密有开销,但避免了所有查询都走数据库加密函数可能带来的索引失效等问题(例如,对加密字段的模糊查询会变得困难,这是另一个话题)。我们可以通过选择性能较好的算法(如AES/SM4)和合理的密钥管理来优化。

2.2 加解密算法选型:AES vs. SM4

确定了技术路线,接下来就是选算法。这不是一个纯技术问题,还得考虑合规性。

  • AES(高级加密标准):国际通用,速度快,安全性高,社区支持完善。JDK自带了实现(javax.crypto),开箱即用。如果你的项目没有特殊的国密合规要求,AES-256-GCM(带认证的加密模式)是非常稳妥和推荐的选择。
  • SM4(国密算法):我国商用密码标准算法,在金融、政务等有明确国密合规要求的场景下是必选项。JDK没有内置,需要引入Bouncy Castle或国密算法库的依赖。

注意:算法选择一旦确定,后续更改成本极高,因为涉及到存量已加密数据的迁移问题。务必在项目初期与安全、架构团队明确要求。

我们项目因为涉及部分金融属性,最终选择了SM4算法(ECB模式,填充方式PKCS5Padding)。这里简单提一下,ECB模式虽然对相同明文会生成相同密文,在某些场景下安全性不如CBC或GCM,但它一个很大的优点是不需要初始化向量(IV),这对于我们根据单个字段值进行加解密的场景简化了设计。当然,在安全性要求极高的场景,建议使用SM4-CBC或SM4-GCM模式并妥善管理IV。

密钥管理(Key Management)是另一个核心且敏感的话题。绝对不要把密钥硬编码在代码里或配置文件中。我们采用的是:

  1. 在应用启动时,从专用的、访问控制严格的密钥管理服务(KMS)或配置中心获取加密后的密钥密文。
  2. 使用环境变量或启动参数中传入的“密钥加密密钥(KEK)”在内存中解密出真正的数据加密密钥(DEK)。
  3. 将DEK缓存在内存中供加解密使用。 这个过程确保了密钥本身不以明文形式存在于任何静态存储中。

3. 核心实现:基于TypeHandler的字段加解密

MyBatis-Plus提供了多种扩展方式,对于字段级别的处理,最优雅和匹配的就是自定义TypeHandler。它会处理Java类型(String)和JDBC类型(VARCHAR)之间的转换。

3.1 创建通用的加解密TypeHandler

我们创建一个通用的EncryptTypeHandler,它继承自BaseTypeHandler<String>,意味着它专门处理String类型的字段。

@Slf4j public class EncryptTypeHandler extends BaseTypeHandler<String> { private final Sm4Util sm4Util; // 你的SM4/AES工具类 public EncryptTypeHandler() { // 在实际项目中,这里应该通过安全的方 // 式初始化你的加解密工具类,例如从Spring容器中注入 this.sm4Util = SpringContextHolder.getBean(Sm4Util.class); } /** * 设置参数:当Java对象属性值(明文)要写入数据库时,调用此方法将其转换为密文。 */ @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { try { // 对非空的参数进行加密 String encryptedText = sm4Util.encrypt(parameter); ps.setString(i, encryptedText); } catch (Exception e) { log.error("字段加密失败,参数索引: {}, 值: {}", i, parameter, e); // 这里有个重要决策:加密失败是抛异常中断操作,还是存明文? // 根据安全等级决定。我们选择抛异常,防止数据泄露。 throw new RuntimeException("数据加密失败", e); } } /** * 获取结果:从数据库ResultSet中取出密文字段,解密后返回明文。 */ @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { String encryptedText = rs.getString(columnName); return decryptString(encryptedText); } @Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String encryptedText = rs.getString(columnIndex); return decryptString(encryptedText); } @Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String encryptedText = cs.getString(columnIndex); return decryptString(encryptedText); } private String decryptString(String encryptedText) { if (encryptedText == null) { return null; } try { return sm4Util.decrypt(encryptedText); } catch (Exception e) { log.error("字段解密失败,密文: {}", encryptedText, e); // 解密失败如何处理?抛异常可能导致整个查询失败。 // 我们这里返回null并记录日志,业务上需要做好空值处理。 // 也可以根据列名判断重要性,决定是否抛异常。 return null; } } }

关键点解析:

  1. setNonNullParameter:在执行INSERTUPDATE语句,设置参数时触发。这里将明文加密成密文,再设置到PreparedStatement中。
  2. getNullableResult:在从ResultSet中获取数据,映射到实体类属性时触发。这里将数据库中的密文解密成明文。
  3. 异常处理:这是最容易出问题的地方。加密失败通常意味着系统配置错误,应该立即失败(Fail Fast)。解密失败可能因为数据被篡改或密钥轮换,需要根据业务重要性设计降级策略(如返回null、抛异常或返回原始密文)。我们这里对解密失败做了容错,返回null并记录错误日志,避免因单个字段解密失败导致整条数据无法使用。

3.2 在实体类上应用TypeHandler

定义了处理器后,需要在实体类的特定字段上通过MyBatis的@TableField注解来指定它。

@Data @TableName("user") public class User { private Long id; private String username; @TableField(typeHandler = EncryptTypeHandler.class) private String phoneNumber; // 手机号,入库自动加密 @TableField(typeHandler = EncryptTypeHandler.class) private String idCard; // 身份证号,入库自动加密 private String email; // ... 其他字段 }

这样,当你使用userMapper.insert(user)userMapper.selectById(1)时,phoneNumberidCard字段就会自动完成加密存储和解密读取。你的Service层代码完全不需要做任何改动。

3.3 配置TypeHandler到MyBatis-Plus

为了让MyBatis-Plus能识别并使用我们的EncryptTypeHandler,需要在配置类中将其注册为全局处理器,或者依赖字段上的注解。由于我们已经在字段注解中明确指定了typeHandler,所以通常不需要额外的全局注册。但为了确保万无一失,也可以在MybatisPlusProperties配置中扫描对应的类型包。

一个常见的坑:如果你同时使用了MyBatis-Plus的自动填充功能(如@TableField(fill = FieldFill.INSERT)),并且填充的字段也需要加密,你需要确保填充动作发生在TypeHandler加密之前。实际上,MyBatis的执行顺序是:先处理拦截器(如自动填充的MetaObjectHandler),再处理参数映射(调用TypeHandler)。所以这个顺序是符合预期的,自动填充的明文会被正确加密。

4. 进阶问题与深度优化

4.1 模糊查询与索引失效问题

这是字段加密后最头疼的业务问题。比如,业务上可能需要根据手机号后四位进行模糊查询。明文时一句LIKE ‘%5678’就搞定了,但现在数据库里存的是密文,同样的明文每次加密结果都不同(除非使用ECB模式且块对齐,但也不建议用密文LIKE),根本没法查。

解决方案通常有几种:

  1. 放弃模糊查询,改用精确匹配:这是最安全、性能最好的方式。如果业务允许,引导用户输入完整信息进行查询。
  2. 在应用层做过滤:如果数据量不大,可以先将所有数据查出来到内存中(解密后),然后在Java流中进行过滤。这仅适用于极小数据量的场景,否则分页和性能都是灾难。
  3. 使用数据库函数解密后查询:写SQL时使用数据库的解密函数,如SELECT * FROM user WHERE AES_DECRYPT(phone_number, ‘key’) LIKE ‘%5678’。但这会导致索引失效,全表扫描,性能极差,且把密钥暴露在了SQL语句中,不安全。
  4. 保留明文“影子字段”:这是比较实用的折中方案。新增一个phone_number_suffix字段,在业务代码或数据库触发器中,将手机号后四位(或前三位等)以明文形式存储。模糊查询时,对这个影子字段使用LIKE。这样既满足了模糊查询需求,又保护了完整的敏感信息。当然,这需要额外的存储和更新逻辑。

我们最终根据业务场景,对手机号采用了“影子字段”方案,对身份证号则严格要求精确匹配。

4.2 密钥轮换与数据迁移

密钥不能永远不变。出于安全最佳实践,需要定期轮换密钥。这意味着旧密钥加密的数据需要用新密钥重新加密。

这是一个线上高风险操作,必须设计完善的方案:

  1. 双密钥支持期:新版本代码支持同时用新、旧两个密钥进行解密。加密时统一使用新密钥。
  2. 后台迁移任务:编写一个离线的、可暂停、可重试的数据迁移任务,逐批读取数据,用旧密钥解密后立即用新密钥加密,再写回数据库。这个过程要记录进度,确保数据一致性和迁移完整性。
  3. 数据验证与切换:迁移完成后,进行数据抽样验证。验证无误后,将代码中的旧密钥移除,进入单新密钥运行阶段。
  4. 回滚预案:必须准备好一键回滚到双密钥支持版本的预案。

4.3 加解密性能监控与优化

加解密是CPU密集型操作。当批量插入或查询大量数据时,可能会对应用性能产生明显影响。

  1. 监控:在EncryptTypeHandler的加解密方法中,使用MicrometerSLF4J记录每次操作的耗时,并汇总到监控系统(如Prometheus),观察P99、P95等延迟指标。
  2. 优化
    • 算法层面:确保使用硬件加速(如AES-NI指令集)。对于Java,可以尝试使用SunJCEProvider。
    • 缓存:对于频繁访问的、解密后的热点数据(如用户自己的手机号),可以考虑在应用层缓存(如Redis)中缓存解密后的明文,并设置较短的过期时间。但这会引入缓存一致性问题,需谨慎。
    • 批处理优化:对于批量操作,可以研究是否能在数据库连接层面或驱动层面进行批量的加解密,减少上下文切换。但这通常比较困难,更实际的是优化批量操作的逻辑本身。

5. 集成测试与常见问题排查

5.1 编写集成测试

测试至关重要,要覆盖各种边界情况。

@SpringBootTest public class UserEncryptTest { @Autowired private UserMapper userMapper; @Test public void testInsertAndSelect() { User user = new User(); user.setUsername("testUser"); user.setPhoneNumber("13800138000"); user.setIdCard("110101199001011234"); // 插入,理论上数据库存的是密文 int insert = userMapper.insert(user); Assertions.assertEquals(1, insert); Assertions.assertNotNull(user.getId()); // 清除本地缓存(如果是MyBatis一级缓存) // sqlSession.clearCache(); // 根据ID查询 User dbUser = userMapper.selectById(user.getId()); Assertions.assertNotNull(dbUser); // 验证查询出来的数据是解密后的明文 Assertions.assertEquals("13800138000", dbUser.getPhoneNumber()); Assertions.assertEquals("110101199001011234", dbUser.getIdCard()); } @Test public void testUpdate() { // 先查询一个用户 User user = userMapper.selectById(1L); String newPhone = "13912345678"; user.setPhoneNumber(newPhone); // 更新 userMapper.updateById(user); // 再次查询验证 User updatedUser = userMapper.selectById(1L); Assertions.assertEquals(newPhone, updatedUser.getPhoneNumber()); } @Test public void testNullAndEmptyString() { User user = new User(); user.setUsername("emptyTest"); user.setPhoneNumber(""); // 空字符串 user.setIdCard(null); // null值 userMapper.insert(user); User dbUser = userMapper.selectById(user.getId()); Assertions.assertEquals("", dbUser.getPhoneNumber()); // 空字符串应能处理 Assertions.assertNull(dbUser.getIdCard()); // null值应保持为null } }

5.2 常见问题排查清单

在实际开发和上线后,我们遇到了不少问题,这里列一个速查表:

问题现象可能原因排查步骤与解决方案
插入/更新成功,但数据库字段是明文1.TypeHandler未生效。
2. 字段注解@TableField(typeHandler=…)未添加或拼写错误。
3. 实体类字段类型与TypeHandler泛型不匹配(如字段是String,但处理器继承自BaseTypeHandler<Integer>)。
1. 检查SQL日志,看执行的SQL语句中该字段的参数值是否是密文(一串乱码)。
2. 确认注解已添加且typeHandler类路径正确。
3. 确认自定义的EncryptTypeHandler继承自BaseTypeHandler<String>
查询时字段值为null1. 解密失败,getNullableResult方法返回了null。
2. 数据库该字段本来就是NULL。
3. 密钥错误或密钥版本不对。
1. 查看应用日志,寻找“字段解密失败”的ERROR日志。
2. 直接连接数据库查看该字段值,确认是否为NULL或异常密文。
3. 核对当前使用的密钥与加密该数据时使用的密钥是否一致。检查KMS或配置中心。
加解密性能慢,CPU占用高1. 批量操作数据量太大。
2. 加解密算法模式选择不当(如用软件模拟的RSA)。
3. 密钥加载频繁。
1. 优化批量操作,分批次处理。
2. 确认使用的是AES/SM4等对称加密,并检查JVM是否使用了硬件加速。
3. 确保密钥在应用启动后只加载一次并缓存,而不是每次加解密都去远程获取。
模糊查询功能失效数据库存储的是密文,密文相同不代表明文相同(反之亦然)。参考上文4.1节,采用“影子字段”或其他业务折中方案。这是加密带来的必然限制,需要在需求评审阶段明确。
使用QueryWrapper进行条件查询时,条件值未加密QueryWrapper中设置的条件值,不会走实体类映射的TypeHandler需要手动加密条件值。例如:
String encryptedPhone = sm4Util.encrypt(“13800138000”);
queryWrapper.eq(“phone_number”, encryptedPhone);
或者,更优雅的方式是自定义一个Wrapper,重写其getSqlSegmentgetParamNameValuePairs方法,自动处理条件值的加密。但这比较复杂。
与MyBatis-Plus其他插件(如分页、乐观锁)冲突插件执行顺序问题。MyBatis插件是通过InterceptorChain按配置顺序执行的,TypeHandler的调用在更底层。通常不会冲突。如果出现问题,检查@Configuration类中插件的注册顺序,确保核心插件先注册。也可以调试查看MappedStatement的执行过程。

最后一点实操心得:在上线前,一定要用生产环境的密钥对测试环境的备份数据进行一次完整的加解密迁移演练。很多问题(特别是密钥管理和数据兼容性问题)只有在真实数据量下才会暴露。同时,做好完备的日志记录和监控,这样在出现问题时,你才能快速定位到底是加密环节、存储环节还是解密环节出了差错。字段加解密是一个“基础设施”,它的稳定性和正确性高于一切。