1. 项目概述为什么要在MyBatis-Plus中做字段加解密最近在重构一个老项目涉及到用户敏感信息的处理比如手机号、身份证号这些。直接明文存数据库风险太高一旦拖库就全完了。全表加密性能开销太大而且业务查询起来会非常麻烦。最后我们决定在数据持久层也就是MyBatis-Plus这一层对特定的敏感字段进行自动加解密。这个需求听起来简单但真做起来从方案选型到落地踩坑还是有不少门道的。简单来说我们的目标就是让程序代码尤其是Service层像往常一样读写实体对象完全感知不到加解密的存在。数据在进入数据库之前由MyBatis-Plus的插件机制自动加密从数据库查询出来映射到实体对象时再自动解密。这样一来业务逻辑完全不受影响而数据库里存的已经是密文安全性得到了保障。这特别适合处理用户隐私数据、金融交易信息等场景也是目前很多合规性要求如等保、GDPR下的常见实践。2. 核心方案设计与技术选型2.1 为什么选择MyBatis-Plus插件机制实现字段级别的加解密有几个常见的切入点在Service层手动调用加解密工具、使用AOP拦截DAO方法、或者利用数据库本身的功能如MySQL的AES_ENCRYPT。我们最终选择了MyBatis-Plus的插件机制主要是基于以下几点考量对业务代码无侵入这是最重要的原则。Service层和Controller层应该只关心业务逻辑不应该充斥着encrypt()和decrypt()的调用。MyBatis-Plus的TypeHandler类型处理器和Interceptor拦截器可以完美地嵌入到SQL执行的生命周期中在数据进出数据库的“最后一公里”完成转换业务层完全无感。粒度可控灵活度高我们可以精确到实体类的某个字段进行加解密而不是整个表或整个数据库。通过注解或自定义TypeHandler的方式可以灵活地指定哪些字段需要处理使用哪种算法。与MyBatis-Plus生态无缝集成项目本身就在用MyBatis-Plus利用其提供的扩展点开发成本低稳定性也更有保障不用担心和分页插件、乐观锁插件等产生冲突。性能损耗相对可控加解密计算发生在应用服务器虽然比不加密有开销但避免了所有查询都走数据库加密函数可能带来的索引失效等问题例如对加密字段的模糊查询会变得困难这是另一个话题。我们可以通过选择性能较好的算法如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是另一个核心且敏感的话题。绝对不要把密钥硬编码在代码里或配置文件中。我们采用的是在应用启动时从专用的、访问控制严格的密钥管理服务KMS或配置中心获取加密后的密钥密文。使用环境变量或启动参数中传入的“密钥加密密钥KEK”在内存中解密出真正的数据加密密钥DEK。将DEK缓存在内存中供加解密使用。 这个过程确保了密钥本身不以明文形式存在于任何静态存储中。3. 核心实现基于TypeHandler的字段加解密MyBatis-Plus提供了多种扩展方式对于字段级别的处理最优雅和匹配的就是自定义TypeHandler。它会处理Java类型String和JDBC类型VARCHAR之间的转换。3.1 创建通用的加解密TypeHandler我们创建一个通用的EncryptTypeHandler它继承自BaseTypeHandlerString意味着它专门处理String类型的字段。Slf4j public class EncryptTypeHandler extends BaseTypeHandlerString { 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; } } }关键点解析setNonNullParameter在执行INSERT或UPDATE语句设置参数时触发。这里将明文加密成密文再设置到PreparedStatement中。getNullableResult在从ResultSet中获取数据映射到实体类属性时触发。这里将数据库中的密文解密成明文。异常处理这是最容易出问题的地方。加密失败通常意味着系统配置错误应该立即失败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)时phoneNumber和idCard字段就会自动完成加密存储和解密读取。你的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根本没法查。解决方案通常有几种放弃模糊查询改用精确匹配这是最安全、性能最好的方式。如果业务允许引导用户输入完整信息进行查询。在应用层做过滤如果数据量不大可以先将所有数据查出来到内存中解密后然后在Java流中进行过滤。这仅适用于极小数据量的场景否则分页和性能都是灾难。使用数据库函数解密后查询写SQL时使用数据库的解密函数如SELECT * FROM user WHERE AES_DECRYPT(phone_number, ‘key’) LIKE ‘%5678’。但这会导致索引失效全表扫描性能极差且把密钥暴露在了SQL语句中不安全。保留明文“影子字段”这是比较实用的折中方案。新增一个phone_number_suffix字段在业务代码或数据库触发器中将手机号后四位或前三位等以明文形式存储。模糊查询时对这个影子字段使用LIKE。这样既满足了模糊查询需求又保护了完整的敏感信息。当然这需要额外的存储和更新逻辑。我们最终根据业务场景对手机号采用了“影子字段”方案对身份证号则严格要求精确匹配。4.2 密钥轮换与数据迁移密钥不能永远不变。出于安全最佳实践需要定期轮换密钥。这意味着旧密钥加密的数据需要用新密钥重新加密。这是一个线上高风险操作必须设计完善的方案双密钥支持期新版本代码支持同时用新、旧两个密钥进行解密。加密时统一使用新密钥。后台迁移任务编写一个离线的、可暂停、可重试的数据迁移任务逐批读取数据用旧密钥解密后立即用新密钥加密再写回数据库。这个过程要记录进度确保数据一致性和迁移完整性。数据验证与切换迁移完成后进行数据抽样验证。验证无误后将代码中的旧密钥移除进入单新密钥运行阶段。回滚预案必须准备好一键回滚到双密钥支持版本的预案。4.3 加解密性能监控与优化加解密是CPU密集型操作。当批量插入或查询大量数据时可能会对应用性能产生明显影响。监控在EncryptTypeHandler的加解密方法中使用Micrometer或SLF4J记录每次操作的耗时并汇总到监控系统如Prometheus观察P99、P95等延迟指标。优化算法层面确保使用硬件加速如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但处理器继承自BaseTypeHandlerInteger。1. 检查SQL日志看执行的SQL语句中该字段的参数值是否是密文一串乱码。2. 确认注解已添加且typeHandler类路径正确。3. 确认自定义的EncryptTypeHandler继承自BaseTypeHandlerString。查询时字段值为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重写其getSqlSegment和getParamNameValuePairs方法自动处理条件值的加密。但这比较复杂。与MyBatis-Plus其他插件如分页、乐观锁冲突插件执行顺序问题。MyBatis插件是通过InterceptorChain按配置顺序执行的TypeHandler的调用在更底层。通常不会冲突。如果出现问题检查Configuration类中插件的注册顺序确保核心插件先注册。也可以调试查看MappedStatement的执行过程。最后一点实操心得在上线前一定要用生产环境的密钥对测试环境的备份数据进行一次完整的加解密迁移演练。很多问题特别是密钥管理和数据兼容性问题只有在真实数据量下才会暴露。同时做好完备的日志记录和监控这样在出现问题时你才能快速定位到底是加密环节、存储环节还是解密环节出了差错。字段加解密是一个“基础设施”它的稳定性和正确性高于一切。