MyBatis-Plus数据加密实战:基于TypeHandler的透明化敏感信息保护方案

📅 2026/8/7 9:01:39
MyBatis-Plus数据加密实战:基于TypeHandler的透明化敏感信息保护方案
1. 项目概述为什么要在MyBatis-Plus中关注数据加密在当下的应用开发里数据安全已经从一个“加分项”变成了“必选项”。特别是涉及到用户隐私、商业机密等敏感信息时比如用户的身份证号、手机号、银行卡号如果直接以明文形式躺在数据库里无异于在保险柜上贴了张写着密码的便利贴。一旦发生数据泄露后果不堪设想。合规性要求例如等保2.0、GDPR等也明确了对敏感数据加密存储的强制规定。那么加密的责任应该放在哪里是在业务代码里每个insert和select的地方手动调用加密解密工具吗这种做法不仅繁琐容易遗漏更致命的是它会严重污染业务逻辑让代码变得难以维护。我们理想中的状态是业务开发者只需要关心“存什么数据”和“取什么数据”至于数据在传输和存储过程中如何被保护应该由底层框架透明地完成。这就是MyBatis-Plus简称MP可以大显身手的地方。作为一个强大的MyBatis增强工具它提供了插件Interceptor机制允许我们在SQL执行的生命周期中插入自定义逻辑。通过实现一个自定义的TypeHandler或利用MP的MetaObjectHandler、SqlInjector等扩展点我们可以以一种非常优雅、非侵入式的方式实现数据在入库前自动加密、在出库后自动解密。整个过程对业务代码完全透明开发者写的依然是清晰的CRUD但底层的数据已经是安全的密文。这种方案的核心价值在于“关注点分离”。安全策略由架构或核心模块统一管控和迭代业务开发团队无需感知加密解密的细节既能保障安全又能提升开发效率与代码质量。接下来我们就深入拆解如何利用MyBatis-Plus的特性一步步构建这套自动化数据加密存储方案。2. 核心设计思路与方案选型在动手写代码之前我们需要把设计思路理清楚。数据加密存储不是简单调用一个AES.encrypt()就完事了它涉及到算法选择、密钥管理、字段粒度、查询妥协等一系列架构级决策。2.1 加密方案的核心考量点首先我们要明确几个关键问题的答案加密算法选型对称加密还是非对称加密对于存储场景对称加密如AES是主流选择因为其加解密速度快适合大数据量。非对称加密如RSA通常用于加密密钥或签名。我们需要确定使用AES的哪种模式如CBC、GCM和密钥长度如128位、256位。密钥如何管理这是安全的核心。密钥绝不能硬编码在代码或配置文件中。常见的做法是使用环境变量、配置中心如Nacos、Apollo或者专门的密钥管理服务KMS。在本地开发时可以使用配置文件但务必纳入.gitignore。加密的粒度是全表加密还是字段级加密显然字段级加密更灵活、性能更好。我们需要决定哪些实体类Entity的哪些字段需要加密通常可以通过自定义注解如EncryptField来标记。查询如何支持数据加密后一个直接的挑战是模糊查询LIKE和范围查询BETWEEN, , 将完全失效因为数据库无法对密文进行这些运算。这是一个为了安全而必须做出的妥协。如果需要此类查询可以考虑方案外的技术如盲索引、保序加密但会降低安全性或业务侧调整查询方式。2.2 MyBatis-Plus的介入点分析MyBatis-Plus提供了多个扩展点我们需要选择最合适的一个来实现“自动”加解密。TypeHandler类型处理器这是最自然、最MyBatis原生的一种方式。它为特定Java类型和数据库JDBC类型之间的转换提供了桥梁。我们可以为需要加密的字段类型如String编写一个自定义的TypeHandler在setParameter方法中加密在getResult方法中解密。优点与MP结合良好声明式配置在字段或mybatis-plus.type-handlers-package中配置即可。缺点在批量操作或复杂查询映射时需要确保配置正确覆盖所有场景。MetaObjectHandler元对象处理器MP提供的用于自动填充createTime,updateTime等字段的接口。虽然它主要不是用于加解密但其insertFill和updateFill方法能在数据插入和更新时被调用。我们可以在这里面遍历实体对象的字段对有加密注解的字段进行处理。优点逻辑集中一处处理所有实体。缺点它处理的是实体对象对于通过UpdateWrapper进行的字段更新操作可能无法覆盖且执行时机在TypeHandler之后。自定义SqlInjector与AbstractMethod这是一种更底层的扩展方式可以完全自定义Mapper方法的行为。理论上可以注入一个全新的“加密插入”方法。优点功能强大控制力极强。缺点实现复杂需要对MP内部机制有很深理解且容易破坏MP的标准API体验。综合对比TypeHandler方案在优雅性、非侵入性和易用性上取得了最佳平衡。它完美契合了“数据转换”这一职责通过简单的配置即可将加密逻辑绑定到特定字段对业务代码零侵入。因此本方案将采用“自定义注解 自定义TypeHandler”作为核心实现路径。2.3 整体架构流程图为了让思路更清晰我们可以用文字描述一下核心数据流业务层调用Mapper.save(user) - MyBatis执行引擎准备参数 - 发现phone字段配置了自定义的EncryptTypeHandler - 调用EncryptTypeHandler.setParameter()将明文“13800138000”加密为“xY7g...” - 加密后的密文被设置到PreparedStatement中执行SQL入库。查询时流程相反执行Mapper.selectById(1) - 数据库返回结果集 - MyBatis映射结果时发现phone字段对应EncryptTypeHandler - 调用EncryptTypeHandler.getResult()将密文“xY7g...”解密为“13800138000” - 解密后的明文被设置到返回的User实体对象中。整个过程中业务层的User对象始终持有明文完全感知不到底层的数据形态变化。3. 核心模块实现详解理论清晰后我们开始动手实现。我们将创建三个核心组件加密工具类、标记注解和类型处理器。3.1 加密解密工具类EncryptUtil这是整个方案的基石负责具体的加密解密算法。这里我们选用AES/CBC/PKCS5Padding算法因为它平衡了安全性和通用性。GCM模式虽然更好提供了认证加密但某些旧环境支持可能不完善。import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; /** * AES加密解密工具类 * 注意密钥和IV需要安全管理此处仅为示例。 */ public class EncryptUtil { // 算法/模式/填充 private static final String ALGORITHM AES/CBC/PKCS5Padding; private static final String KEY_ALGORITHM AES; /** * 加密 * param data 明文 * param key 密钥必须为16/24/32字节 * param iv 初始向量必须为16字节 * return Base64编码的密文 */ public static String encrypt(String data, String key, String iv) { try { Cipher cipher Cipher.getInstance(ALGORITHM); SecretKeySpec keySpec new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), KEY_ALGORITHM); IvParameterSpec ivSpec new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encryptedBytes cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } catch (Exception e) { throw new RuntimeException(加密失败, e); } } /** * 解密 * param encryptedData Base64编码的密文 * param key 密钥必须为16/24/32字节 * param iv 初始向量必须为16字节 * return 明文 */ public static String decrypt(String encryptedData, String key, String iv) { try { Cipher cipher Cipher.getInstance(ALGORITHM); SecretKeySpec keySpec new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), KEY_ALGORITHM); IvParameterSpec ivSpec new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decodedBytes Base64.getDecoder().decode(encryptedData); byte[] decryptedBytes cipher.doFinal(decodedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException(解密失败, e); } } }关键安全提示上述代码将密钥和IV写死在工具类中这是极其危险的做法仅用于演示。在生产环境中你必须通过环境变量、配置中心或KMS动态获取这些敏感信息。一个常见的做法是在应用启动时从安全的源头加载密钥并放入Spring的Environment或一个单例Bean中供EncryptUtil调用。3.2 自定义加密字段注解EncryptField这个注解的作用是“标记”。它像一个标签贴在需要加密的实体字段上为后续的TypeHandler提供识别依据。import java.lang.annotation.*; /** * 标记字段需要加密存储 * 可以贴在实体类Entity的字段上 */ Documented Retention(RetentionPolicy.RUNTIME) Target(ElementType.FIELD) public interface EncryptField { }注解本身很简单它的威力在于与TypeHandler的配合。你也可以扩展这个注解比如增加一个algorithm()属性来指定不同的加密算法实现更灵活的加密策略。3.3 自定义类型处理器EncryptTypeHandler这是连接MyBatis和加密逻辑的桥梁。它继承自BaseTypeHandlerString因为我们主要处理String类型的字段加密。import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import org.springframework.util.StringUtils; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; /** * 加密类型处理器 * 注意需要从安全的地方获取密钥和IV此处使用静态变量仅为示例。 */ public class EncryptTypeHandler extends BaseTypeHandlerString { // 生产环境务必从外部安全获取 private static final String SECRET_KEY Your32ByteLongSecretKey1234567890; // 32字节 private static final String IV Your16ByteLongIV!!; // 16字节 /** * 设置参数在SQL执行前将Java对象的明文字段值加密并设置到PreparedStatement中 */ Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { if (StringUtils.hasText(parameter)) { // 对非空且非空字符串的参数进行加密 String encryptedText EncryptUtil.encrypt(parameter, SECRET_KEY, IV); ps.setString(i, encryptedText); } else { // 空值直接设置 ps.setString(i, parameter); } } /** * 获取结果从ResultSet中取出密文字段值解密后返回明文 */ Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { String encryptedText rs.getString(columnName); return decryptIfNeeded(encryptedText); } Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String encryptedText rs.getString(columnIndex); return decryptIfNeeded(encryptedText); } Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String encryptedText cs.getString(columnIndex); return decryptIfNeeded(encryptedText); } /** * 统一的解密方法 */ private String decryptIfNeeded(String encryptedText) throws SQLException { if (encryptedText null) { return null; } // 这里可以增加一个简单的判断逻辑比如检查字符串是否看起来像Base64密文 // 或者是否有特定前缀以避免对非加密字段误解密。 // 简单起见这里假设该字段所有值都是加密的。 try { return EncryptUtil.decrypt(encryptedText, SECRET_KEY, IV); } catch (Exception e) { // 解密失败可能是数据损坏或该字段原本就不是密文 // 记录日志并根据业务需求决定是抛出异常还是返回原值 throw new SQLException(字段解密失败数据可能已损坏或格式错误, e); } } }这个TypeHandler完成了核心的转换工作。在setNonNullParameter中它将程序员设置的明文参数加密成密文交给数据库。在getNullableResult系列方法中它从数据库拿到密文解密后返回明文给MyBatis由MyBatis填充到实体对象中。4. 整合配置与实战应用组件都准备好了现在需要把它们组装起来让MyBatis-Plus认识并使用我们的EncryptTypeHandler。4.1 实体类定义我们定义一个User实体其中idCard身份证号和phone手机号是敏感字段需要用EncryptField标记。import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; Data TableName(sys_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private String email; EncryptField // 标记此字段需要加密存储 private String idCard; EncryptField // 标记此字段需要加密存储 private String phone; // ... 其他字段 }4.2 MyBatis-Plus 配置配置有两种主要方式推荐使用第一种更清晰直观。方式一在application.yml中指定类型处理器包路径推荐mybatis-plus: type-handlers-package: com.yourproject.handler # 指定你的EncryptTypeHandler所在的包 configuration: default-enum-type-handler: org.apache.ibatis.type.EnumOrdinalTypeHandler # 可选处理枚举MyBatis-Plus启动时会自动扫描该包下的所有TypeHandler并注册。但是它如何知道哪个字段用哪个TypeHandler呢这需要结合第二种方式。方式二在字段上通过TableField注解直接指定必须修改User实体类在加密字段上增加typeHandler属性。import com.baomidou.mybatisplus.annotation.TableField; Data TableName(sys_user) public class User { // ... 其他字段 EncryptField TableField(typeHandler EncryptTypeHandler.class) // 关键配置 private String idCard; EncryptField TableField(typeHandler EncryptTypeHandler.class) // 关键配置 private String phone; }两种方式的关系方式一包扫描是“发现机制”告诉MyBatis-Plus有哪些可用的TypeHandler。方式二TableField是“绑定机制”明确指定某个实体字段使用哪个TypeHandler。两者结合才能正确生效。4.3 业务层使用配置完成后业务层的使用方式和普通MP开发没有任何区别这就是“优雅”二字的体现。Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { Override public boolean saveUser(User user) { // 此时user对象的idCard和phone是明文 // 例如user.setIdCard(110101199001011234); user.setPhone(13800138000); return this.save(user); // save方法内部MP会通过我们配置的TypeHandler自动将明文加密成密文再入库。 } Override public User getUserById(Long id) { User user this.getById(id); // 此时从数据库查询返回的user对象其idCard和phone字段已经被TypeHandler自动解密为明文。 // 业务代码可以直接使用这些明文数据无需任何额外操作。 return user; } Override public PageUser pageQuery(PageUser page, String keyword) { QueryWrapperUser wrapper new QueryWrapper(); if (StringUtils.hasText(keyword)) { // 注意这里不能对加密字段进行模糊查询 // wrapper.like(phone, keyword); // 这是错误的数据库里phone是密文无法LIKE wrapper.like(username, keyword); // 只能对非加密字段进行查询 } return this.page(page, wrapper); } }可以看到Service和Controller层的代码完全干净没有任何加密解密的痕迹。数据安全的职责被完美地转移到了数据持久化层。5. 高级场景与深度优化基础功能跑通后我们会遇到一些更复杂的场景需要对这个方案进行加固和优化。5.1 密钥的动态管理与轮转硬编码密钥是死穴。一个生产级的方案必须支持密钥动态获取和轮转。从配置中心获取在Spring Boot中我们可以创建一个ConfigurationProperties类或直接使用Value从Nacos、Apollo等配置中心读取密钥。为了安全配置中心本身需要有严格的权限控制和加密存储功能。使用KMS密钥管理服务对于更高安全要求的场景可以使用云厂商如阿里云KMS、AWS KMS或自建的HashiCorp Vault来管理主密钥。应用启动时或每次加解密前向KMS请求数据密钥或执行加解密操作信封加密模式。密钥轮转定期更换密钥是安全最佳实践。但这带来了历史数据解密的问题。一个常见的策略是“密钥版本化”。在加密后的密文中不仅包含加密数据还包含一个密钥版本号或密钥ID。解密时根据版本号去查找对应的历史密钥进行解密。这需要在EncryptUtil和数据库存储格式上做文章。优化后的EncryptUtil示例伪代码Component public class DynamicEncryptUtil { Autowired private KeyManagementService keyService; // 模拟密钥服务 public String encrypt(String data) { // 1. 从密钥服务获取当前活跃的密钥ID和密钥内容 KeyInfo currentKey keyService.getCurrentKey(); // 2. 执行加密 String cipherText aesEncrypt(data, currentKey.getContent()); // 3. 将密钥ID与密文一起存储格式如 “keyId:version:cipherText” return currentKey.getId() : currentKey.getVersion() : cipherText; } public String decrypt(String combinedText) { // 1. 解析出密钥ID、版本和密文 String[] parts combinedText.split(:, 3); String keyId parts[0]; // 2. 根据密钥ID去查找对应的密钥可能是历史密钥 KeyInfo key keyService.getKeyById(keyId); // 3. 使用正确的密钥解密 return aesDecrypt(parts[2], key.getContent()); } }对应的数据库字段需要设计得更长以存储这个组合字符串。TypeHandler中的加解密调用则改为使用这个DynamicEncryptUtil。5.2 处理非String类型字段与复杂对象我们的EncryptTypeHandler目前只处理String类型。如果要加密BigDecimal金额、LocalDateTime时间呢为每种类型创建TypeHandler创建EncryptBigDecimalTypeHandler、EncryptLocalDateTimeTypeHandler等。这种方式类型安全但类会增多。使用泛型或Jackson序列化创建一个通用的EncryptTypeHandlerT在内部使用Jackson的ObjectMapper将对象序列化为JSON字符串然后加密这个字符串解密时再反序列化回来。这种方式更通用但会带来额外的序列化开销且要求字段类型是可序列化的。public class GenericEncryptTypeHandlerT extends BaseTypeHandlerT { private final ObjectMapper objectMapper new ObjectMapper(); private final ClassT type; public GenericEncryptTypeHandler(ClassT type) { this.type type; } Override public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { try { String json objectMapper.writeValueAsString(parameter); String encrypted EncryptUtil.encrypt(json, KEY, IV); ps.setString(i, encrypted); } catch (JsonProcessingException e) { throw new SQLException(对象序列化失败, e); } } Override public T getNullableResult(ResultSet rs, String columnName) throws SQLException { String encrypted rs.getString(columnName); return decryptToObject(encrypted); } // ... 其他get方法 private T decryptToObject(String encryptedText) throws SQLException { if (encryptedText null) return null; try { String json EncryptUtil.decrypt(encryptedText, KEY, IV); return objectMapper.readValue(json, type); } catch (Exception e) { throw new SQLException(字段解密或反序列化失败, e); } } }在实体字段上使用时需要指定具体的类型TableField(typeHandler GenericEncryptTypeHandler.class)并且MyBatis配置可能需要更复杂的处理来实例化这个带泛型参数的处理器。5.3 加解密性能考量与缓存策略加解密是CPU密集型操作频繁调用可能成为性能瓶颈。性能测试在实际数据量下对save和query操作进行压测评估加解密带来的额外耗时。通常对于单条或少量数据的操作AES加解密的开销可以忽略不计。但在批量导入成千上万条或大数据量查询时影响会显现。选择性加密并非所有数据都需要加密。严格评估数据敏感性只对真正必要的字段PII个人身份信息进行加密。连接池与预处理语句确保数据库连接池和MyBatis的预处理语句缓存配置合理避免因TypeHandler的调用而重复创建加密器对象。我们的EncryptUtil使用静态方法本身没有状态是线程安全的这一点很好。热点数据缓存对于查询极其频繁且很少变动的加密数据如用户的基础信息可以考虑在解密后将明文结果放入应用层缓存如Redis。但要注意缓存的安全性和一致性缓存时间不宜过长并且当数据更新时必须清除或更新缓存。6. 常见问题排查与实战心得在实际落地过程中你肯定会遇到一些坑。下面是我总结的几个典型问题和解决思路。6.1 问题排查清单问题现象可能原因排查步骤与解决方案插入数据成功但数据库里存的是明文TypeHandler未生效1. 检查TableField(typeHandler ...)注解是否添加正确。2. 检查mybatis-plus.type-handlers-package配置路径是否正确能否扫描到处理器类。3. 检查实体类字段类型与TypeHandler泛型类型是否匹配如字段是String处理器是BaseTypeHandlerString。查询时抛出解密失败异常1. 密钥不一致2. 数据被篡改或非密文3. 加密算法/模式/填充不匹配1.核对密钥和IV确保加解密使用的密钥、IV完全一致且没有多余的空格或换行。2.检查数据直接查看数据库中该字段的值看是否是合法的Base64编码密文。可能之前有部分数据是明文插入的。3.验证算法确保加密工具类EncryptUtil中的算法字符串与加密时使用的完全一致包括模式、填充。对加密字段进行like查询无效设计如此这是预期行为。加密后数据失去原文字符串的特征数据库无法进行模糊匹配。解决方案1. 放弃模糊查询改用精确查询eq。2. 业务侧调整在另一非加密字段如拼音缩写字段上建立索引进行模糊查询。3. 考虑专门的密文检索技术如盲索引但这超出了本文范围且实现复杂。使用QueryWrapper的set/update方法更新加密字段更新后变明文UpdateWrapper的set方法可能绕过TypeHandlerUpdateWrapper.set(“phone”, “新号码”)这种方式设置的SQL参数可能不会经过实体类字段上配置的TypeHandler。解决方案1.优先使用实体对象更新UpdateWrapperUser wrapper new UpdateWrapper(); wrapper.eq(“id”, 1); User updateEntity new User(); updateEntity.setPhone(“新号码”); mapper.update(updateEntity, wrapper);这样phone会通过实体对象的setter方法并被TypeHandler处理。2. 如果必须用set(String, Object)需要手动加密后再传入wrapper.set(“phone”, EncryptUtil.encrypt(“新号码”));批量插入saveBatch时只有第一条数据加密了MyBatis默认的ExecutorType与批处理确保在批量操作时TypeHandler对每一条数据的每个字段都生效。通常saveBatch方法内部会使用MyBatis的批处理执行器。检查你的EncryptTypeHandler在setParameter方法中是否正确处理了每一条数据。通常逻辑没问题此问题较少见更多可能是配置或映射问题。6.2 实操心得与注意事项测试务必全面不仅要测试增删改查还要测试批量操作、联表查询如果加密字段在关联条件中、事务回滚场景下的数据一致性。确保在各种边界条件下空值、超长字符串、特殊字符加解密都能正常工作且不报错。数据库字段长度加密后的密文特别是经过Base64编码后通常会比原文长。务必相应增加数据库表字段的长度VARCHAR避免数据截断导致解密失败。AES加密后Base64长度大约会增加约33%。数据迁移方案如果是在已有项目中引入加密存量明文数据怎么办需要编写一个数据迁移脚本分批读取明文数据通过新的加密逻辑加密后写回。这个过程必须在维护窗口进行并做好充分备份和回滚预案。日志与监控在EncryptTypeHandler的decryptIfNeeded方法中如果解密失败记录详细的错误日志但不要记录密钥和原始密文并接入监控告警。这能帮助你快速发现数据损坏或密钥错误等问题。注解的威力EncryptField注解可以进一步扩展。比如增加一个encrypt()属性默认为true这样可以在某些特殊场景如数据导出下通过注解配置临时关闭某个字段的加密逻辑提供了更大的灵活性。这套基于MyBatis-PlusTypeHandler的数据加密方案将安全能力下沉到了数据持久层让业务开发真正做到了“开箱即用无需关心”。它可能不是银弹在应对模糊查询等场景时需要做出妥协但在绝大多数需要保护敏感数据的业务场景下它提供了一种足够优雅、安全、低成本的实现路径。