数据库里存用户手机号、身份证号、银行卡号,清一色明文,这事儿搁谁心里都不踏实。前阵子安全同事做数据库抽查,直接甩过来一张整改单,写着“敏感字段必须加密存储,限期一周”。我当时第一反应就是:不能把业务代码从头改到尾吧?那不动上百个 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 提供了BaseTypeHandler<T>抽象类,我们只需要实现四个方法:非空参数设置、三个结果集读取重载。下面是我写的一个通用的加密 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 BaseTypeHandler<String> { /** 加密处理器,由子类注入或通过全局配置 */ 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
既然要做加密,算法选型就得认真对待。我对比过几组主流方案:
| 算法 | 模式 | 密钥长度 | 特点 |
|---|---|---|---|
| AES | CBC | 128/192/256 | 需要 IV,分组加密,常见、通用 |
| DES | ECB | 56 | 不安全,已过时,坚决不用 |
| SM4 | CBC/ECB | 128 | 国密标准,国内合规场景更香 |
| 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> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.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 column="phone" property="phone" 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 的加密解密链路,再逐步扩展到身份证号、银行卡号这类高风险字段。项目上线后别忘了把密钥管理好,定期演练一下密钥轮换流程——这玩意儿临时抱佛脚是真的会手忙脚乱。