做后端开发的朋友应该都有过这种经历:线上的用户表里手机号、身份证号直接明文存储,每次被安全审计扫到都要写整改报告。改呢,涉及面广,尤其是老系统,到处都在 select *,牵一发动全身;不改呢,风险摆在那里,真出了事就是事故。我之前在一个项目里就被这个问题缠了很久,最后用 MyBatis-Plus 的 TypeHandler 做了单列加解密,实现了“代码层面透明加解密”,业务代码几乎零改动,效果很理想。
这篇文章就把这套方案完整拆开讲清楚:为什么选 TypeHandler、AES 和 3DES 到底怎么选、TypeHandler 内部是怎么被调用的、怎么写一个可复用的加密处理器、落地时有哪些坑。全文基于我自己实际跑过的代码,不是理论推导,照着做就能用。适合正在做数据安全改造、或者刚接触 MyBatis-Plus 想了解 TypeHandler 原理的后端同学。
1. 单列加解密:为什么我最终选了 TypeHandler
1.1 哪些字段值得加密存储
先说结论:不是所有字段都要加密,加密是有代价的。我们项目里最初“一刀切”的想法被 DBA 反驳了,因为加密字段无法走索引查询,order by、group by 也会受影响,加密后长度还会膨胀,比如 11 位手机号 AES 加密后 Base64 编码,字段长度会变成 24 或 44 个字符,原来的 varchar(11) 根本存不下。
结合实际经验,我建议优先加密这几类字段:
- 手机号、座机号等联系方式
- 身份证号、护照号等证件信息
- 银行卡号、账号等金融敏感信息
- 邮箱地址、详细住址等个人隐私信息
这些字段的特点是:查询条件很少用等值匹配(大部分场景是按 ID 查出来再展示),或者即使有查询需求也可以通过“先解密再筛选”的方式绕过。反过来说,像订单号这种高频出现在查询条件里、还需要做唯一校验的字段,就不太适合直接加密存储,更适合在应用层做脱敏展示。
1.2 方案对比:TypeHandler 还是拦截器还是手动加解密
当时我们几个候选方案,我挨个都验证过:
| 方案 | 改动量 | 优点 | 缺点 |
|---|---|---|---|
| 业务层手动加解密 | 大 | 逻辑清晰 | 侵入强,每个读写点都要改,漏一个就是漏洞 |
| AOP 切面拦截 | 中 | 集中处理 | 注解和切面维护成本高,事务内多次调用容易重复加解密 |
| MyBatis 拦截器(Interceptor) | 中 | 可统一处理所有 SQL | 需要解析 SQL 参数和结果集,复杂,容易误伤 |
| 数据库函数/触发器 | 小 | 对应用透明 | 加密逻辑和数据库绑定,不方便换库,密钥管理麻烦 |
| TypeHandler | 小 | 字段级精确控制,声明式使用 | 查询条件按密文匹配,无法模糊查询 |
TypeHandler 最打动我的地方是:它把加解密逻辑收敛到了“字段映射”这一层。实体类的属性对应数据库列,读的时候自动解密,写的时候自动加密,业务代码里拿到的始终是明文,数据库里存的始终是密文。对于老项目的改造来说,只要把实体类字段加上注解,SQL 都不需要动,这是非常关键的优势。
1.3 算法选型:AES 还是 3DES
在做技术方案时,顺手做了个 3DES 和 AES 的加解密速度对比,这也是很多同事问我的点。先说结论:新项目一律用 AES,3DES 只用于兼容老系统。
我拿一份 10 万条手机号数据分别跑了一轮加密耗时,结果大致如下(仅供参考,不同机器会有差异):
| 算法 | 密钥长度 | 分组长度 | 10万次加密耗时(我本机 JDK8) | 安全性 |
|---|---|---|---|---|
| 3DES(Triple-DES) | 112/168 bit | 64 bit | 约 1800 ms | 不推荐,已接近淘汰 |
| AES-128 | 128 bit | 128 bit | 约 300 ms | 可用,目前主流 |
| AES-256 | 256 bit | 128 bit | 约 320 ms | 推荐,强度更高 |
3DES 本质上是对 DES 做三次加密,分组长度仍是 64 位,在块密码的“生日攻击”下安全性远不如 AES。AES 在主流 JDK 里都有硬件指令级优化(AES-NI),性能优势很明显。单列数据加解密这种场景,AES-128 完全够用;如果合规和风控要求更严格,直接用 AES-256,性能差异很小。
要注意的是,JDK8 默认的 AES-256 需要替换 JCE 策略包,JDK9 以后默认不限长度。如果公司 JDK 版本比较老,建议先在测试环境跑通 AES-256,不然会遇到 java.security.InvalidKeyException: Illegal key size,那就要么升级 JDK,要么回退到 AES-128。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 类型处理器实现详解
2.1 整体架构:TypeHandler 在 MyBatis 里到底扮演什么角色
要理解这套方案,先从 MyBatis 的数据流转说起。MyBatis 在把 Java 对象的参数设置到 PreparedStatement 时,需要知道“Java 类型 → JDBC 类型”怎么转换;在把 ResultSet 的行数据映射到 Java 对象时,需要知道“JDBC 类型 → Java 类型”怎么转换。这个转换器就是 TypeHandler。
MyBatis-Plus 作为 MyBatis 的增强框架,完全继承了这套机制。你在实体类字段上用 @TableField(typeHandler = XxxTypeHandler.class) 声明后,MyBatis-Plus 生成 SQL 或者映射结果集时,就会把这个字段的读写操作委托给对应的 TypeHandler。
整个调用链路是这样的:
- 业务代码调用
userMapper.insert(user)或userService.save(user)。 - MyBatis-Plus 解析实体类,发现
phone字段绑定了EncryptTypeHandler。 - 插入时,MyBatis 把
user对象作为参数传入,调用EncryptTypeHandler.setNonNullParameter,在方法里对明文手机号加密后,设置到PreparedStatement上。 - 查询时,MyBatis 从
ResultSet里取到密文字符串,调用EncryptTypeHandler.getNullableResult,在方法里解密后再赋给实体的phone属性。
理解了这条链路,你就明白为什么业务层可以做到无感:加解密发生在 ORM 层的内侧,业务代码看到的只是“赋值”和“取值”。
2.2 密钥管理与 AES 加密工具类
TypeHandler 本身不负责密钥管理,它只负责调用加密工具。密钥如果硬编码在代码里,那跟明文存储也没太大区别。建议至少做到密钥独立一个配置项,通过环境变量或配置中心下发。我们生产环境用的是配置中心 + 定期轮换,本地开发则读环境变量。
下面是一个我常用的 AesEncryptUtil 工具类,使用 AES/GCM/NoPadding 模式。之所以选 GCM,是因为它自带完整性校验,能发现密文被篡改,比传统的 CBC 更安全;代价是多 12 字节的 IV 和 16 字节的 Tag。如果你们内部统一要求 CBC + PKCS5Padding,那也行,但 EC B 模式千万别用。
java复制public class AesEncryptUtil {
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final String ALGORITHM = "AES";
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH_BITS = 128;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private AesEncryptUtil() {
}
public static String encrypt(String plainText, SecretKey key) throws Exception {
if (plainText == null) {
return null;
}
byte[] iv = new byte[IV_LENGTH];
SECURE_RANDOM.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_LENGTH_BITS, iv);
cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec);
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
// 输出格式:IV + 密文 + Tag,统一 Base64
byte[] result = new byte[iv.length + cipherText.length];
System.arraycopy(iv, 0, result, 0, iv.length);
System.arraycopy(cipherText, 0, result, iv.length, cipherText.length);
return Base64.getEncoder().encodeToString(result);
}
public static String decrypt(String encryptedText, SecretKey key) throws Exception {
if (encryptedText == null || encryptedText.isEmpty()) {
return null;
}
byte[] payload = Base64.getDecoder().decode(encryptedText);
byte[] iv = new byte[IV_LENGTH];
System.arraycopy(payload, 0, iv, 0, IV_LENGTH);
byte[] cipherText = new byte[payload.length - IV_LENGTH];
System.arraycopy(payload, IV_LENGTH, cipherText, 0, cipherText.length);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_LENGTH_BITS, iv);
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec);
byte[] plainBytes = cipher.doFinal(cipherText);
return new String(plainBytes, StandardCharsets.UTF_8);
}
}
这段代码里有几个细节值得说:
- IV 不固定,且随机生成:如果 IV 固定,同一明文多次加密会得到相同密文,相当于泄露了“明文差异”信息。把 IV 放在密文前面一起存储,解密时先取出 IV 再用,这样加解密都是独立的,不需要额外保存 IV。
- 输出 Base64:数据库 varchar 列存的是可打印字符,Base64 是通用做法。注意 Base64 有
+、/、=等字符,如果列参与 URL 参数传递,还需要 URLEncoder,但数据库存储不需要。 - 密钥用 SecretKey 对象:不要用字符串到处传,统一在启动时从配置加载并构造
SecretKey。
2.3 自定义 TypeHandler:继承 BaseTypeHandler 还是实现 TypeHandler
MyBatis 提供了 BaseTypeHandler<T> 抽象类,我们只需要继承它并实现四个方法。泛型类型用 String,因为我们的明文和密文都是字符串。
下面是我在实际项目里用到的 EncryptTypeHandler,它内部持有一个 SecretKey,注意这个类会被 MyBatis 反复实例化,所以密钥不能在这里动态加载,最好通过静态初始化或者 Spring 容器注入。我用的是静态初始化,从环境变量读取密钥,简单直接。
java复制@MappedTypes(String.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class EncryptTypeHandler extends BaseTypeHandler<String> {
private static final SecretKey SECRET_KEY = loadKey();
private static SecretKey loadKey() {
try {
String key = System.getenv("APP_ENCRYPT_KEY");
if (key == null || key.length() < 16) {
throw new IllegalStateException("APP_ENCRYPT_KEY must be at least 16 chars");
}
byte[] keyBytes = Arrays.copyOf(key.getBytes(StandardCharsets.UTF_8), 16);
return new SecretKeySpec(keyBytes, "AES");
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
String parameter, JdbcType jdbcType) throws SQLException {
try {
String encrypted = AesEncryptUtil.encrypt(parameter, SECRET_KEY);
ps.setString(i, encrypted);
} catch (Exception e) {
throw new SQLException("Encrypt error for column index " + i, e);
}
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
String columnValue = rs.getString(columnName);
return decryptIfNecessary(columnValue);
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String columnValue = rs.getString(columnIndex);
return decryptIfNecessary(columnValue);
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String columnValue = cs.getString(columnIndex);
return decryptIfNecessary(columnValue);
}
private String decryptIfNecessary(String columnValue) throws SQLException {
if (columnValue == null || columnValue.isEmpty()) {
return columnValue;
}
try {
return AesEncryptUtil.decrypt(columnValue, SECRET_KEY);
} catch (Exception e) {
throw new SQLException("Decrypt error for value: " + columnValue, e);
}
}
}
这段代码里有几个关键点:
setNonNullParameter只处理非 null 值,null 值由 MyBatis 走setNull,所以我们不需要在加密方法里判断 null。- 三个
getNullableResult重载分别处理“按列名取”“按索引取”“存储过程取”三种场景。很多人只写一个,后来在 XML 里用resultMap按列名映射时就解密失败,就是因为没重载getNullableResult(ResultSet, String)这个版本。 - 解密失败直接抛
SQLException,不建议静默返回原文。否则线上如果出现历史明文数据,解密失败被吞掉,查询结果出现一部分明文一部分密文,排查起来非常痛苦。 - 这里密钥长度取了
Array.copyOf,无论配置多长都截断到 16 字节。如果你用 AES-256,需要改成 32 字节并处理 JCE 限制。
3. 核心细节与实战要点
3.1 数据库列长度:密文膨胀怎么算
这是大家最容易踩的第一个坑。明文 11 位手机号,AES-GCM 加密后是 IV(12) + 密文 + Tag(16),Base64 编码后长度为 ceil((12 + 16 + 16) / 3) * 4,大概是 56 个字符。如果明文更长,比如身份证号 18 位,密文 Base64 后长度 48 到 60 个字符。所以设计表结构时,加密字段列类型至少 varchar(128) 起步,保险起见直接 varchar(255)。
我在项目里遇到过同事把加密手机号存到 varchar(20) 列里,插入时数据库报 Data too long for column,排查了一下午。后来我们总结了一个规则:凡是加了 TypeHandler 的字段,DBA 建表时统一用 varchar(255),从源头避免长度问题,反正现在 MySQL 单行长度限制下 255 是完全安全的。
另外字符集也要注意。密文是 Base64 字符串,只包含 ASCII 字符,但如果你用的字符集是 utf8mb4,依然没问题。真正需要注意的是排序规则(collation),utf8mb4_unicode_ci 和 utf8mb4_general_ci 对密文排序没有本质影响,因为密文不参与业务排序。
3.2 查询条件中的等值匹配:实践上的取舍
加密最麻烦的问题就是查询。比如登录时需要根据手机号查用户,如果你给手机号字段加了 TypeHandler,那 WHERE phone = ? 条件里的 ? 是明文,数据库里存的是密文,两边对不上,必然查不到数据。
我当时的处理方式分三种情况:
- 业务允许先查后筛:用其他条件(如用户 ID)先缩小范围,再在内存里对手机号做匹配。适合数据量小的管理端场景。
- 必须用手机号等值查询:额外增加一个“摘要列”,比如手机号
md5(plainText)作为专门的查询列,存储时同时写入。查询时先对入参做 md5 再走WHERE phone_md5 = ?,这个列不需要解密,只用于等值匹配。 - 支持模糊查询:这个比较麻烦。如果业务上非要
LIKE '%张三%'这种,加密方案就基本没法直接支持。可行路径是把数据导入 Elasticsearch,在 ES 里做明文索引和搜索,MySQL 只负责加密源数据。
更文艺一点的方案是使用可搜索加密(Searchable Encryption),比如保持密文索引的保序加密(OPE),但工程落地太复杂,我建议普通项目别碰,性价比太低。
3.3 批量插入与更新:TypeHandler 的表现
TypeHandler 对单条插入、批量插入、批量更新都是一视同仁的,因为它是“一行一行”处理参数的。批量插入的性能开销主要来自加密计算本身。AES 单条加密在毫秒以下,1000 条批量插入完全没压力。如果担心性能,可以把密钥加载做成单例,避免每次 Cipher.getInstance 都重新加载密钥和初始化。
我在测试环境用 10 万条数据验证过一次批量插入,MyBatis-Plus 的 saveBatch 默认分批 1000 条提交,加上加密逻辑后耗时大约比明文多了 20%,完全可接受。这个结论基于 AES-128 + GCM,如果换成 3DES,耗时可能多 3 到 5 倍,所以算法选型真的会影响线上体验。
3.4 字段脱敏与日志输出:别把明文打出去
TypeHandler 保证了数据库加密,但日志和接口返回值同样会泄漏明文。我在改造中把日志打印的 User 对象用 @ToString.Exclude 排除敏感字段,或者使用 FastJson / Jackson 的 @JsonSerialize 自定义脱敏序列化器。
如果你用了 MyBatis-Plus,注意 ServiceImpl 里默认的 toString() 也可能会输出实体字段,生产环境建议全局关闭 MyBatis 的 SQL 日志中的参数值打印,只保留 SQL 模板。
提示:加解密只是数据安全的一环,脱敏、审计、权限控制都要配套,单点防御撑不起整个安全体系。
4. 在 MyBatis-Plus 中落地一个完整示例
4.1 环境准备:依赖版本和数据库表
我用的是 Spring Boot 2.7 + mybatis-plus-boot-starter 3.5.3 + MySQL 8.0.33 + JDK 8,Lombok 用于简化实体类。pom 里核心依赖如下:
xml复制<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
数据库表先建好,注意加密列的宽度。我这里建一张简单的用户表:
sql复制CREATE TABLE t_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
phone VARCHAR(255) NOT NULL,
id_card VARCHAR(255) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
phone 和 id_card 两个敏感字段都用 varchar(255),ID 和 name 保持原样。
4.2 实体类和 Mapper 的写法
实体类核心在于字段注解:
java复制@Data
@TableName("t_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
@TableField(typeHandler = EncryptTypeHandler.class)
private String phone;
@TableField(typeHandler = EncryptTypeHandler.class)
private String idCard;
private LocalDateTime createdAt;
}
Mapper 直接继承 BaseMapper<User>,不需要额外代码:
java复制@Mapper
public interface UserMapper extends BaseMapper<User> {
}
这样,通过 MyBatis-Plus 生成的 insert、selectById、selectList、updateById 都会自动应用 TypeHandler。你可以在测试类里直接开搞。
4.3 写一个 Spring Boot 测试验证加解密
这里给出一段完整的测试代码,可以直接跑:
java复制@SpringBootTest
@Slf4j
class UserMapperTest {
@Autowired
private UserMapper userMapper;
@Test
void testEncryptInsertAndSelect() {
User user = new User();
user.setName("张三");
user.setPhone("13812345678");
user.setIdCard("110101199001011234");
userMapper.insert(user);
User selected = userMapper.selectById(user.getId());
log.info("解密后手机号: {}, 身份证: {}", selected.getPhone(), selected.getIdCard());
// 直接去数据库查,验证库里是密文
List<Map<String, Object>> maps = userMapper.selectMaps(
new QueryWrapper<User>().eq("id", user.getId()));
log.info("数据库原始值: {}", maps);
}
}
运行后你会看到日志里:
selected.getPhone()输出13812345678,说明读取时自动解密了。maps里的phone字段是一串 Base64 字符串,比如5Zx...,说明写入时自动加密了。
selectMaps 返回的是 Map,不会走实体类的 TypeHandler,所以拿到的是密文。这也是一个排查技巧:如果实体类查询显示正常,但直接查 map 或写 SQL 查询看到的是密文,不要慌,这是符合预期的。
4.4 XML 自定义 SQL 里的 TypeHandler 怎么配
如果你在 XML 里写 <resultMap>,需要指明 typeHandler:
xml复制<resultMap id="UserResultMap" type="com.example.entity.User">
<id column="id" property="id"/>
<result column="phone" property="phone" typeHandler="com.example.handler.EncryptTypeHandler"/>
<result column="id_card" property="idCard" typeHandler="com.example.handler.EncryptTypeHandler"/>
</resultMap>
插入 SQL 里也要显式声明:
xml复制<insert id="insertUser">
INSERT INTO t_user(name, phone, id_card)
VALUES (#{name}, #{phone, typeHandler=com.example.handler.EncryptTypeHandler}, #{idCard, typeHandler=com.example.handler.EncryptTypeHandler})
</insert>
这块很容易遗漏。MyBatis-Plus 的 BaseMapper 是自带注解识别的,但 XML 自定义 SQL 的 #{} 参数不会自动读取实体字段上的 @TableField(typeHandler=...),必须手动声明。我见过同事在这里栽了跟头,自定义了一条 insert,结果加密没生效,数据库里同时存在明文和密文,数据全乱了。
5. 常见问题与排查技巧实录
5.1 加密字段查询结果为空或者解密失败
典型场景:老数据是明文,改造后加上 TypeHandler,查询时走到 decrypt 方法,对明文数据做 Base64 解码直接抛异常,最后 SQLException 导致查询失败。
解决思路是渐进式改造:脚本先把存量明文统一加密,再上线 TypeHandler。如果无法一次性完成,可以给 decryptIfNecessary 加一个兼容逻辑,尝试解密失败后先判断数据是否为合法 Base64,如果是就按原样返回,同时记录告警日志,方便后续补加密。但这个兼容逻辑一定不能长期保留,否则就失去了加密的意义。
5.2 程序里拿到的是明文,但控制台 SQL 日志打的是密文?
这是被混淆最多的点。MyBatis 打印的 SQL 参数值是 TypeHandler 已经写入 PreparedStatement 之后的值,所以日志里显示的是密文,不是 bug,是预期行为。如果你希望日志里看到明文,可以在加密前打印入参,但我不建议在正式环境打印手机号明文。
5.3 实体里增加了新加密字段,但老数据没有密文
这个跟 5.1 类似。上线前必须写数据订正脚本,对新加密字段统一回填密文。同时,最好在代码里加一个“启动自检”:查询一条已知 ID 的数据,用 selectById 验证解密后的明文是否符合预期,不符合就启动失败,把问题扼杀在发布阶段。
5.4 TypeHandler 和 JSON 序列化同时使用时注意什么
实体类里 phone 字段解密后是明文,如果用 @RestController 直接返回这个实体,接口响应里就会带明文。如果你的需求是“数据库加密,接口返回明文”,那没问题;如果希望接口返回脱敏,需要再加一层 @JsonSerialize 或者用 VO 对象隔离。MyBatis-Plus 的实体类最好不要直接当 VO 用,这是老生常谈。
5.5 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
插入报 Data too long for column |
列长度不够 | 加密字段统一 varchar(255) |
| 查询返回密文 | 没有匹配到 TypeHandler | XML 里显式配置 typeHandler,或用实体类查询 |
| 查询时报解密异常 | 历史存量数据是明文 | 先订正数据,或临时兼容并尽快迁移 |
| 加密手机号查不到记录 | WHERE 条件里传的是明文 |
改用摘要列或等值索引列 |
| 更新时加密字段被覆盖 | 只更新非空字段时,某些场景走全字段更新 | 用 UpdateWrapper 指定字段,或核对 MyBatis-Plus 字段策略 |
| AES-256 报 Illegal key size | JDK8 JCE 无限制策略未安装 | 升级 JDK 9+ 或安装 JCE 包 |
5.6 3DES 与 AES 选型补充
关于 3DES 与 AES,我再补充一点实际经验。有一种情况是:老系统已经有一批用 3DES 加密的数据,数据库里已经存在,你想升级到 AES,这时候不要直接删掉 TypeHandler 换算法,而是要做“双算法过渡”。具体做法是:写一个带版本标识的加密值,比如密文前缀 AES: 或 3DES:,TypeHandler 解密时根据前缀选择算法,这样老数据不会被解密失败干扰,新写入的数据用 AES,等老数据逐步过期后,再彻底去除 3DES 分支。
这个思路在真实改造中非常有用,它避免了“不可逆的突然切换”,也让加解密代码更健壮。
6. 这类方案还能怎么扩展
6.1 从单列到多列的枚举管理
如果实体类里有很多加密字段,每个字段都写 @TableField(typeHandler = EncryptTypeHandler.class) 容易手误漏掉。可以抽一个自定义注解,比如 @EncryptField,然后通过 MyBatis-Plus 的元对象处理器或自己定义注解扫描器,在实体类初始化时批量设置 TypeHandler。不过 MyBatis-Plus 对注解生成 TypeHandler 的支持有限,需要自己扩展 TableInfoHelper,改动相对较大。我的建议是:字段不多时直接手写,多了再考虑统一管理,别一开始就造轮子。
6.2 针对 ResultMap 缓存与性能的补充
TypeHandler 本身是无状态的,MyBatis 会为每个字段创建 TypeHandler 实例并缓存复用。所以不要在 TypeHandler 里放“一次性状态”,比如保存上一次加密的结果,否则并发环境下会互相污染。我在项目里要求团队成员写的 TypeHandler 必须是线程安全的,除了静态密钥外,不在实例字段里保存任何东西。
6.3 密钥轮换注意事项
密钥轮换是很多团队忽略的环节。如果你线上加密密钥换了,那么数据库里所有密文都会失效。因此密钥轮换不能直接在配置中心里替换,要先把老密钥保存下来,实现一个多密钥解密逻辑:解密时先用新密钥尝试,失败再用老密钥,同时写一个异步任务用新密钥重新加密所有数据,全部完成后才把老密钥从配置里下掉。这套流程要提前演练,别等到轮换窗口期手忙脚乱。
我在实际项目里踩过几次坑之后,把这套逻辑优化成了一个小型的 KeyManager:维护一个“当前加密密钥”和一组“历史解密密钥”,加解密工具在加密时永远用当前密钥,解密时按优先级依次尝试。虽然代码复杂度高了一些,但至少轮换密钥的时候不用心惊胆战。
结尾之前,说点个人体会
TypeHandler 加解密方案写起来不难,难的是想清楚边界:哪些字段能加密、查询怎么绕开、存量数据怎么迁、密钥怎么管。我前面提到的所有细节,几乎都是线上踩过坑之后才总结出来的,尤其是 XML 自定义 SQL 里 typeHandler 要手动声明这件事,当时排查了整整一天。现在你照着这篇文章里的代码和注意事项走一遍,能少走很多弯路。
最后分享一个小技巧:如果项目里加密字段比较多,建议在测试阶段加一个专门的“加密字段巡检 JUnit”,扫描数据库里所有加了 @TableField(typeHandler = EncryptTypeHandler.class) 的字段,断言它们的值确实是密文格式(Base64 且能解密)。这个巡检用例跑在 CI 里,能第一时间发现“谁漏配了 TypeHandler 或者谁把明文直接写进去了”。数据安全这回事,靠人的自觉不如靠机制兜底。
