
数据库里存用户手机号、身份证号、银行卡号清一色明文这事儿搁谁心里都不踏实。前阵子安全同事做数据库抽查直接甩过来一张整改单写着“敏感字段必须加密存储限期一周”。我当时第一反应就是不能把业务代码从头改到尾吧那不动上百个 Mapper 也得改几十个排期根本来不及。后来冷静下来把思路理了一遍发现其实有一条非常顺的路——用 MyBatis-Plus 的 TypeHandler 机制做透明加解密。只需要在实体类的敏感字段上挂一个自定义 TypeHandler插入的时候自动加密查询的时候自动解密业务层和 SQL 层基本不需要动。一周后我不仅完成了整改还把这套逻辑做成了通用组件后续再遇到新敏感字段加一个注解就完事。这篇文章就聊聊我这次改造的完整思路和落地过程适合正在做数据安全整改、或者想给已有 SpringBoot 项目加字段级加密的开发者参考。内容不绕弯子全部是我实际操作过的东西。1. 需求背景与方案选型1.1 明文存储到底踩了哪些雷说句实在话很多项目上线初期设计表结构的时候压根没想过加密。手机号、身份证号、邮箱、地址直接 varchar 存进去查询倒是方便了Like、排序、分组想怎么用怎么用。但一旦数据量上去了、系统暴露面变大了问题就跟着来了。最典型的风险有三个。第一是数据库泄露这个不是小概率事件一旦库被拖走所有敏感信息等于裸奔。第二是内部越权开发、运维、DBA 能看到明文任何一个人终端被黑或者数据库账号泄露敏感数据就全出去了。第三是合规要求近几年的监管越来越严格很多行业都明确要求个人敏感信息必须加密存储。但整库加密比如用数据库自带的透明数据加密不是不行只是成本高、影响面大而且很多云数据库实例根本不支持或者要额外收费。对于大多数业务系统来说字段级加密才是投入产出比最高的方案——只需要针对个别敏感字段处理其余逻辑一概不动。1.2 三种主流的字段加解密实现路径做字段级加密行业内通常有三条路第一种是 ORM 层拦截也就是利用 MyBatis 的 TypeHandler。它作用于 JDBC 层的参数赋值和结果集读取拦截到 insert、update 时把字段值加密后再交给数据库select 时把密文解密后返回给应用层。对业务代码完全透明改动量最小。第二种是 MyBatis 拦截器自定义 Interceptor 拦截 Executor 或者 StatementHandler在 SQL 执行前后统一处理。这个方案灵活度高能处理复杂的动态 SQL 场景但实现起来比较繁琐而且要对 SQL 做解析处理不当容易踩坑维护成本高。第三种是 AOP 切面在 Service 层或者 DAO 层切入对入参和返回值做加解密。这个方案的问题在于只能处理方法级别的进出参一旦遇到分页、批量、复杂联查、子查询等场景漏掉的情况非常多很难做到彻底。三类方案放一块对比就很清楚了方案改造成本漏处理风险维护成本推荐度TypeHandler低加注解处理器低低高自定义拦截器高解析SQL中高低业务层AOP低但易遗漏高中低我最后选了 TypeHandler这是 MyBatis 的原生机制MyBatis-Plus 完全兼容而且生态成熟后续接 MyBatis-Plus 的分页插件、逻辑删除等功能都不冲突是最稳妥的选择。2. 核心实现一套通用的加解密组件2.1 自定义 TypeHandler 的完整代码MyBatis 提供了BaseTypeHandlerT抽象类我们只需要实现四个方法非空参数设置、三个结果集读取重载。下面是我写的一个通用的加密 TypeHandler核心逻辑都在里面。import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.symmetric.AES; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; /** * 通用字段加解密 TypeHandler * 存储时加密读取时自动解密 */ public abstract class AbstractCryptoTypeHandler extends BaseTypeHandlerString { /** 加密处理器由子类注入或通过全局配置 */ private final AES aes; public AbstractCryptoTypeHandler(AES aes) { this.aes aes; } Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { // 参数写入前加密 ps.setString(i, encrypt(parameter)); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { return decrypt(rs.getString(columnName)); } Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return decrypt(rs.getString(columnIndex)); } Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return decrypt(cs.getString(columnIndex)); } /** 加密空值直接返回不做多余动作 */ protected String encrypt(String plainText) { if (StrUtil.isBlank(plainText)) { return plainText; } return aes.encryptBase64(plainText); } /** 解密密文为空返回空否则解密 */ protected String decrypt(String cipherText) { if (StrUtil.isBlank(cipherText)) { return cipherText; } // 判断是否为密文格式防止误解密非加密数据 if (!isCipherFormat(cipherText)) { return cipherText; } return aes.decryptStr(cipherText); } /** 简单判断密文格式Base64 编码且包含特定前缀标记避免把明文当密文解 */ protected abstract boolean isCipherFormat(String text); }这里有几个细节必须说明白。第一加密用的是 Hutool 的 AES 封装底层是 JDK 自带的 AES 实现没有额外引入 Bouncy Castle 之类的重量级依赖。第二解密前为什么要判断isCipherFormat因为存量数据可能还有一部分是明文或者某些字段在测试阶段已经写了明文值如果不判断直接拿密文格式去解密明文根本解不回来反而会报错或者返回乱码。我给自己的实现加了一个前缀标记比如加密后的密文统一以ENC:开头这样无论是新老数据都能准确区分。然后是具体字段类型的子类比如手机号和身份证号未来可能还有邮箱、地址每个类型一个子类灵活性最好。import cn.hutool.crypto.symmetric.AES; import org.springframework.stereotype.Component; Component public class PhoneCryptoTypeHandler extends AbstractCryptoTypeHandler { public PhoneCryptoTypeHandler() { // 从配置中心或配置文件读取密钥 super(new AES(你的AES密钥.getBytes())); } Override protected boolean isCipherFormat(String text) { // 自定义加密密文统一带 ENC: 前缀 return text.startsWith(ENC:); } }但这个写法有点儿笨因为密钥写死在类里后续要换密钥就得改代码。更优雅的做法是把密钥放到配置文件中通过Value注入到 Handler 里。下面我会讲怎么优化。2.2 实体类绑定 TypeHandler 的正确姿势实体类的代码很简单核心就是两个注解import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; TableName(value user, autoResultMap true) public class User { private Long id; private String name; TableField(typeHandler PhoneCryptoTypeHandler.class) private String phone; TableField(typeHandler IdCardCryptoTypeHandler.class) private String idCard; // getter / setter 省略 }注意TableName上的autoResultMap true这个不能漏。它的作用是让 MyBatis-Plus 自动生成 resultMap 时带上 typeHandler 信息这样查询出的结果才会走解密逻辑。如果不加插入能加密但查询出来的就是密文这也是很多新手容易踩的坑。2.3 为什么选 AES/CBC 随机 IV既然要做加密算法选型就得认真对待。我对比过几组主流方案算法模式密钥长度特点AESCBC128/192/256需要 IV分组加密常见、通用DESECB56不安全已过时坚决不用SM4CBC/ECB128国密标准国内合规场景更香RSA非对称1024/2048性能差不适合大量字段对于字段级加解密这种场景对称加密 AES 是最合适的性能好、安全性够用。模式上我特意选了 CBC 而不是 ECB。ECB 模式最大的问题在于同样的明文块加密后得到的密文块完全相同这种情况下密文会暴露数据的统计特征比如代码里存的手机号前三位如果固定那么密文前几位也会雷同存在被重放攻击或分表分析的风险。CBC 模式每个分组加密前都会和前一个密文分组做异或配合随机 IV让同样的明文每次加密都产生不同的密文安全性要好得多。随机 IV 的处理方式是加密时生成 16 字节随机数作为 IV把 IV 和密文拼接在一起再整体 Base64 编码存库。解密时先 Base64 解码从密文头部取出固定的 IV 长度再交给 AES 解密。具体代码用 Hutool 的 AES 已经封装好了默认就会自动处理随机 IV不用自己拼。密钥长度至少 128 位也就是 16 个字符以上。我实际用的 32 字符密钥走 AES-256。密钥不放代码里而是放在application.yml或者配置中心后续做密钥轮换也方便。3. 实操过程与踩坑记录3.1 从集成到跑通的完整步骤如果你从零开始接这套方案完整的步骤大概是这样第一步引入依赖。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency如果你的项目已经用了 MyBatis-Plus这步可以跳过。额外还需要一个 hutool 工具包用来简化加密操作。第二步配置密钥。mybatis-plus: configuration: # 开启驼峰映射 map-underscore-to-camel-case: true app: crypto: aes-key: 32位密钥字符串建议从环境变量或配置中心读取 prefix: ENC: # 密文前缀标记用于区分明文数据第三步写加解密工具类和 TypeHandler。我把密钥配置封装成一个工具类在 TypeHandler 初始化时读取。Spring 管理 Bean 后TypeHandler 可以做成 Spring 的单例 Bean通过构造器注入配置。Component public class CryptoProperties { Value(${app.crypto.aes-key}) private String aesKey; Value(${app.crypto.prefix}) private String prefix; public String getAesKey() { return aesKey; } public String getPrefix() { return prefix; } }第四步在实体类上挂注解。这一步最容易出问题。除了常见的TableField(typeHandler Xxx.class)我强烈建议再开autoResultMap true否则查询结果的自动映射不会触发 TypeHandler 的解密逻辑。第五步测试验证。先跑插入再看库里的密文是不是正确写入最后跑查询看返回的明文是否正确。我测试的时候习惯写一个完整链路插入一条带手机号的记录从库里查出来确认是密文再用 Mapper 查回来确认能解密成明文。3.2 最常见的“解密失败”场景我在实际排查过程中发现解密失败通常逃不出下面几个原因。第一个是 TypeHandler 只生效了一半。插入时加密成功查询时返回的却是密文。这种情况十有八九是autoResultMap没开或者自己写了 XML 的自定义 resultMap 没指定对应的 typeHandler。我建议能用注解的就别手动写 resultMap减少出错概率。第二个是 JSON 序列化把密文返回给了前端。如果查询结果是一个实体对象直接返回给前端时实体里 phone 字段的值已经是被解密后的明文这是对的。但如果你为了某些查询需要用 Map 接收那 Map 里的值可能还是密文因为 Map 不经过实体类的 typeHandler 映射。所以涉及敏感字段的接口返回类型尽量用 DTO别用 Map。第三个是老数据解密不了。如果这张表之前已经存了明文加密上线后老数据在查询时会被 TypeHandler 当作密文解密结果就是解密报错或者乱码。我的做法是在解密方法里先判断是否带ENC:前缀不是前缀就直接返回原值。这种做法看起来很土但确实稳。对于老数据再写一个后台脚本在低峰期批量把所有明文老数据统一刷成密文。3.3 加密字段的业务约束模糊查询、索引、排序这是整套方案里最需要程序员理解的部分。一旦字段在数据库里存的是密文那么它就不再支持模糊查询、排序和分组了。为什么呢拿手机号举例明文状态下的查询语句可能是SELECT * FROM user WHERE phone LIKE %138%;这条 SQL 到了数据库拿密文去比对永远不可能命中因为密文完全不是固定长度的明文模式。同理ORDER BY phone按密文排序得到的结果没有业务意义GROUP BY phone也一样。我在改造的时候专门拉了一份代码清单把项目中所有涉及 phone、idCard 的查询条件都翻了一遍。总共发现三处用了模糊查询全部改成了先解密内存比对或者改用其他字段检索。这种活看起来琐碎但漏一处就是线上事故。我最终给团队的硬性约束是加密字段只支持等值查询且等值查询也要注意效率——因为数据库索引存的是密文无法走普通索引。这里有两个方案可以考虑。第一种在同一张表冗余一个明文影子字段专门建索引。但明文影子字段本身有泄露风险不太推荐。第二种用确定性加密算法比如 AES-SIV 或者保持 ECB 模式保证同样明文加密后密文一致这样密文上建索引也可以做等值查询。生态里成熟做法是用 MyBatis-Plus 自带的加密注解FieldEncrypt配合确定性加密但当时我们用的时候还不算稳定所以没上。如果你们团队评估后可以接受推荐优先用那边实现的方案比自己造轮子稳。我这边目前的做法是手机号、身份证号这种高频等值检索字段存储时密文不一致也没关系等值查询时先把入参加密成密文再拼 SQL然后把查询结果在应用层解密。这样虽然没法走索引但因为数据量不大百万级以内性能可以接受。如果数据量上来再考虑引入专门的加密索引方案或者上确定性加密。3.4 分页、批量插入、逻辑删除等边界情况改造过程中我还挨个验证了这些边界场景。分页查询MyBatis-Plus 的分页插件和 TypeHandler 是兼容的。分页插件生成 COUNT 和 LIMIT 语句字段加解密发生在 resultMap 映射阶段两者互不干扰。实测用Page分页查手机号能正确解密。批量插入MyBatis-Plus 的saveBatch内部循环调用 insert每个 insert 都走 TypeHandler所以批量插入也是自动加密的不需要额外处理。逻辑删除逻辑删除是 UPDATE 语句把deleted字段置位不涉及敏感字段的更新所以没有任何冲突。但如果逻辑删除字段本身也要加密那就把对应字段也挂上 TypeHandler 就行。更新操作如果你的 SQL 里写了UPDATE user SET phone #{phone} WHERE id #{id}那么入参的 phone 会被 TypeHandler 加密。但如果 UPDATE 语句里直接写死了某个字符串比如SET phone 12345678901那数据库存进去的就是明文这种情况很隐蔽改造时一定要用全局搜索扫一遍特殊 SQL。联表查询如果做联表查询A 表关联 B 表B 表加密字段在 JOIN 的 ON 条件里就会出现同样的问题——ON 条件里没法通过 TypeHandler 解密。所以加密字段只能作为普通结果列不能作为 JOIN 条件。我在项目里就遇到过一个用户表和订单表的联查订单表存了冗余的手机号做 JOIN 条件只能改成先用手机号密文查用户 id再用 id 做 JOIN。4. 性能、安全与工程化细节4.1 加解密性能开销到底有多大很多人担心加了加解密之后接口变慢、数据库压力变大。我先给个结论基本可以忽略但要注意批量场景。AES 加密本身非常快。我实测了一下单条字符串的 AES 加解密耗时在微秒级别单次接口调用里做三五次加解密根本感知不到。真正有影响的是批量场景比如一次性导入十万条数据每条都要加密这时候总耗时可能就是几秒钟到十几秒的量级。应对方案也很简单批量导入时除了用saveBatch分批提交还可以做一些优化加密线程池化把 TypeHandler 里的 AES 实例做成单例Hutool 的 AES 本身就是线程安全的可以直接复用。减少重复 new 实例不要在每次加密时都new AES(key)这样每次都会初始化密钥调度表浪费时间。数据量特别大的导入任务可以放到异步线程里执行完成后通知。实测结论百万级数据的批量加密在普通开发机上跑总耗时在 5 到 8 秒左右。对于日常业务接口完全够用。4.2 日志脱敏与前端展示加密存储完成之后别忘了日志和接口返回这两个面。数据库层面已经加密了但应用日志里如果直接打印了实体对象的 toString明文就可能出现在日志文件里。我对 User 实体做了自定义的toString()方法手机号只显示前三后四中间打星号。这样即使日志被脱库也不会暴露完整敏感信息。接口返回层面也有讲究。如果返回给前端的对象里包含加密字段TypeHandler 解密后就是明文。但很多场景前端只需要脱敏后的手机号比如列表页显示138****5678就不应该返回完整明文。我的方案是列表查询接口统一返回脱敏 DTO详情接口才返回完整字段并且完整字段通过接口鉴权控制访问范围。这个细节大家可以根据自己业务决定但别全部暴露。4.3 密钥轮换与存量数据迁移密钥轮换是加密系统绕不开的话题。一旦密钥泄露数据就相当于裸奔需要换新密钥并重新加密全部存量数据。我这里给一个比较轻量的轮换方案。第一步在配置中心增加app.crypto.aes-key-new新密钥第二步写一个迁移任务扫描全表逐条用老密钥解密再用新密钥加密写回第三步全部迁移完成后把配置中心的主密钥切换成新密钥老密钥只保留在 TypeHandler 里用于过渡解密。过渡期结束后删掉老密钥配置。这套方案的难题在迁移任务上。我在实际执行时是分批扫描的每批 2000 条用乐观锁控制并发避免在迁移过程中有新数据写入导致的不一致。如果你对一致性要求特别高建议在迁移期间做数据库主从切换在从库上跑迁移完成后再切回零停机。至于存量明文数据我上面提过加密工具类中已经做了前缀判断明文数据查询时原样返回。迁移任务把明文数据统一处理成密文后这个判断逻辑会在新数据上不生效但保留它依然是有意义的——防止有人往加密字段写入明文而查询解密失败。4.4 从普通字段到加密字段的流程建议如果你所在团队也想实施这个方案我给一个可以参考的落地检查清单确认敏感字段清单和业务方、安全方达成一致。在测试环境全量验证插入、查询、更新、删除、分页、批量、联查等路径。检查所有手写 SQL排除 Like、Order By、Group By、Join On 中涉及敏感字段的场景。数据库层面检查索引、唯一约束是否包含加密字段提前调整。上线前写好存量数据加密脚本准备回滚预案保留旧代码版本。这个流程走一圈基本就不会出大问题。5. 常见问题与排查技巧实录5.1 插入成功但查询返回密文可能原因解决方法TableName 缺少 autoResultMap true加上该属性让MP自动生成带typeHandler的resultMap自定义XML resultMap 没配置 typeHandler在result columnphone propertyphone typeHandler.../中指定查询使用了selectMaps返回Map改用实体接收或手动对Map中的敏感字段解密5.2 加密字段解密时报 IllegalBlockSizeException这种问题一般出现在类型判断不严谨的时候。密文字符串如果不带 Base64 标记或者经过中间环节被截断、被转换编码解密时就会报错。我的排查经验是先在数据库确认存进去的密文是不是完整 Base64 字符串再确认应用端拿到的值没有被日志框架截断或转义。如果确认没问题再看一下 AES 的密钥模式是否一致——加密时用的 256 位密钥解密时如果用了 128 位同样会报错。整体上把密钥和密文格式统一起来就能解决大部分异常。5.3 分页插件和 TypeHandler 冲突大部分情况下不冲突。有个隐藏坑是自定义拦截器顺序。如果你在 MP 里还加了其他拦截器比如数据权限、SQL 审计拦截器的执行顺序是严格按InterceptorIgnore和Order控制的。TypeHandler 是结果集映射阶段处理的所以拦截器阶段不碰它。不过有一种情况要留意如果你在拦截器里对查询结果做了强转或者二次封装并且把 ResultSet 提前消费了TypeHandler 就没办法正确执行结果集读取。我的习惯是拦截器只做 SQL 改写和参数改写不做结果处理——结果处理交给 MP 的结果映射机制职责分离清晰问题也少。5.4 单元测试怎么写加密逻辑最终要落测试我写了一套快速校验的测试模板保证每次改动都在可控范围SpringBootTest class UserMapperTest { Resource private UserMapper userMapper; Test void testEncryptAndDecrypt() { User user new User(); user.setName(测试用户); user.setPhone(13800138000); user.setIdCard(110101199003071234); userMapper.insert(user); User queryUser userMapper.selectById(user.getId()); Assertions.assertEquals(13800138000, queryUser.getPhone()); Assertions.assertEquals(110101199003071234, queryUser.getIdCard()); } }这个测试能覆盖插入加密 查询解密的核心链路。如果你想覆盖更细的场景可以再加批量插入、分页查询、逻辑删除后的查询等用例。这套测试在交给测试同事做回归之前能最大程度守住底线。5.5 在线排查小技巧线上出了问题最快的排查方式是打开 MyBatis 的 SQL 日志注意SQL 日志里打印的参数是加密后的密文还是明文。如果打印的是明文说明 TypeHandler 没生效如果打印的是密文那 TypeHandler 的加密分支已经走了这时候问题多半出在解密端。再配合一个简单的小 API 做验证明文正确性可以把某个密文复制到单独的校验接口里调用解密方法看看能否还原成预期的明文。这一招在联调阶段帮我定位了很多次问题。6. 写在最后的个人体会这次改造最大的体会是MyBatis-Plus 的 TypeHandler 是一套被低估的机制很多开发者用 MP 只是图它的 CRUD 封装但它在数据安全、多租户、字段处理这类横切能力上其实能做到几乎无侵入的扩展。加密存储这种看似复杂的改造借助这个机制可以达到业务层零代码改动这在一个存量项目落地时价值是非常高的。另外过程中的“不能模糊查询、不能排序、不能 JOIN”这些限制不是坏事。它逼着我们在设计阶段想清楚哪些字段真正需要加密、哪些字段更适合用业务 ID 关联、哪些数据应该拆分出独立的高安全存储表。加密不是把数据库锁死而是让我们重新审视一次数据建模。如果你也在做类似的数据安全整改建议从最简单的手机号字段开始跑通 TypeHandler 的加密解密链路再逐步扩展到身份证号、银行卡号这类高风险字段。项目上线后别忘了把密钥管理好定期演练一下密钥轮换流程——这玩意儿临时抱佛脚是真的会手忙脚乱。