资讯动态

MyBatis字段级加密实战:自定义TypeHandler实现AES-GCM加解密

发布时间:2026/10/15 16:03:07 来源:尧图企业网站定制
做数据加密这块最让我头疼的不是算法而是“加解密到底放在哪一层”。我之前接手一个支付相关的业务项目手机号、身份证号、银行卡号这类敏感字段数据库里要求不能存明文但业务代码又到处都是SELECT *一把梭的写法如果一个个业务方法去改加解密逻辑改到一半自己都要崩溃。后来我选定了MyBatis家族的自定义TypeHandler路线把加解密下沉到ORM框架的字段映射环节效果非常直接——插入的时候自动加密查询的时候自动解密业务代码几乎零感知。这篇文章就把整个方案从原理到落地、再到实测踩坑完整讲一遍适合正在做数据安全改造、或者准备引入字段级加密的同学参考。1. 为什么我最终选了TypeHandler做加解密而不是AOP先说结论在MyBatis和MyBatis-Plus环境下做字段级加解密自定义TypeHandler是侵入性最小、覆盖面最全、可维护性最好的方案。但这不是我一开始就想到的我是把几种常见做法都试过一遍才确定的。1.1 最开始我踩过的三条路第一反应是写一个工具类比如EncryptUtil.encrypt(phone)然后去业务代码里把所有user.setPhone()的地方改成user.setPhone(EncryptUtil.encrypt(phone))所有user.getPhone()的地方改成EncryptUtil.decrypt(user.getPhone())。结果就是项目里到处散落加解密调用改了一晚上第二天发现少改了一个查询方法线上数据库里又插了一条明文记录。这种方案的问题很典型——加解密逻辑和业务强耦合凡是漏掉一个入口就会造成一条脏数据。第二反应是用AOP拦截Service层方法通过注解标记哪些字段要加密解密。这个思路听起来优雅但实际跑起来你会发现Service层拿到实体对象时字段可能已经被MyBatis填充好了插入的时候你在前置通知里加密更新的时候WHERE条件里的字段怎么处理而且MyBatis-Plus的updateById会动态拼SET语句AOP很难区分哪些字段进了SET、哪些字段进了WHERE加密时机和范围控制起来非常拧巴。第三种是数据库触发器或视图在数据库层做透明加密。这个方案对代码侵入确实最小但维护成本很高——数据量大时触发器性能损耗明显而且一旦涉及分库分表、数据迁移触发器跟着数据库走复杂度成倍增加。加上我们用的数据库不是单一环境从开发库到测试库再到生产库触发器脚本要同步三份想想就头大。1.2 TypeHandler方案到底赢在哪里TypeHandler是MyBatis官方提供的一个扩展点专门用来处理Java类型和JDBC类型之间的转换。普通的类型转换比如String到VARCHARMyBatis已经内置了我们要做的就是写一个自定义TypeHandler在String变成VARCHAR时插入加密动作在VARCHAR还原成String时插入解密动作。这个方案有几个天然优势加解密逻辑只存在一个类里不散落在业务代码中只要SQL是走MyBatis的参数绑定和结果集映射就一定会经过TypeHandler不存在“漏改一个方法导致数据变脏”的问题MyBatis-Plus的TableField(typeHandler ...)注解可以精确到字段级想加密哪个字段就加密哪个字段全表加密也行后续维护密钥、更换加密算法影响面收敛在一个Handler内部。我后来在一次分享里把它类比成“海关”数据从Java世界到数据库世界必须经过一个固定的关口检查TypeHandler就是这个关口。加解密在关口处统一处理比在业务代码里各自为政可靠得多。2. TypeHandler读写时机与BaseTypeHandler的四个关键方法在写代码之前必须先搞懂一次完整的INSERT和SELECT过程中TypeHandler是在哪个环节介入的。不然你写出来的Handler可能只对INSERT生效查询出来却是密文你还找不到原因。2.1 写入时参数处理器与PreparedStatement的桥梁当MyBatis执行一条带参数的SQL时比如INSERT INTO user(phone) VALUES(?)框架会遍历传入参数对象为每个属性寻找对应的TypeHandler。如果这个属性恰好配置了我们的自定义HandlerMyBatis就会调用它的setNonNullParameter方法。伪代码大概是这样的// MyBatis内部调用逻辑示意 TypeHandler handler getTypeHandler(property, jdbcType); handler.setParameter(ps, i, paramValue, jdbcType);注意这里的paramValue是Java对象里的原始值——也就是明文。所以加密动作必须放在setNonNullParameter方法里此时数据还停留在Java层一旦调用ps.setString()写出去的就是密文了。如果把加密放在Service层那这套方案就失去了意义如果把加密放在setParameter的外层包装又会和MyBatis的缓存机制冲突容易重复加密。正确姿势就是在setNonNullParameter内部完成“明文 - 密文”的转换。2.2 读取时结果集映射的回程之路查询时流程刚好反过来。数据库返回的ResultSet里那一列是密文MyBatis根据resultMap的列映射关系找到对应TypeHandler调用它的getNullableResult方法。这个方法有三个重载分别对应按列名取值、按列索引取值、从存储过程出参取值三个都得实现否则运行到对应场景会直接报未实现异常。解密放在getNullableResult里也就是“密文 - 明文”的回程转换。我见过有人把两个动作放反了写入时没加密查询时解了个寂寞最后排查了半天发现是setNonNullParameter里压根没写加密逻辑。所以最简单的一句记忆法写走set读走get。2.3 BaseTypeHandler四个方法的职责清单BaseTypeHandlerString这个抽象类一共要求实现四个方法完整列一下方法调用时机处理方向setNonNullParameter(ps, i, parameter, jdbcType)执行INSERT/UPDATE的SET部分明文 - 密文getNullableResult(rs, columnName)普通SELECT按列名取值密文 - 明文getNullableResult(rs, columnIndex)SELECT按列索引取值密文 - 明文getNullableResult(cs, columnIndex)存储过程出参密文 - 明文这里有个容易忽略的细节BaseTypeHandler里还有一个setParameter方法非抽象它负责判空。如果参数是null就直接调用ps.setNull()不会进入setNonNullParameter。也就是说加密Handler不需要自己处理null的情况框架已经兜底了这一点很多新手写Handler时会重复判断导致代码啰嗦。2.4 初版实现的失败教训我第一次写Handler时看到网上教程里代码大致长这样就照着抄了Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, AesEncryptUtil.encrypt(parameter)); }运行起来之后发现一个问题查询出来的数据只有密文没有解密。后来检查resultMap发现我写的result columnphone propertyphone typeHandlerEncryptTypeHandler/只对SELECT生效但MyBatis默认的自动映射autoMappingBehavior在某些场景下不会自动套用自定义TypeHandler。尤其是写了个不带resultMap的selectList框架使用了自动映射压根不认得你的Handler。这个问题在MyBatis-Plus环境里表现得更隐蔽下一章会展开讲。初版代码跑通后我反而更确信要先把原理吃透不然真的会被各种“看起来能跑”给坑到。3. 手写一个可复用的AES加解密TypeHandler明白时机之后写代码就是水到渠成的事。我直接给出实际项目中一直在用的版本不是教学Demo是能直接落地的。3.1 加密算法选型我选AES-GCM对称加密里最常见的两个模式是AES-CBC和AES-GCM。CBC模式需要额外处理IV初始化向量而且不提供完整性校验密文被篡改后解出来的是乱码而不是错误提示GCM模式自带认证标签解密时能发现数据被改写过。作为数据存储加密我最终选了AES-GCM密钥长度128位不需要额外引入Bouncy CastleJDK原生就支持。说句实在话如果你对密码学没有深入研究别自己发明加密协议更别用什么异或、Base64伪装加密。网络安全那边通用的建议就是数据存储加密用AES-GCM结合独立管理的密钥足够应对常规等保和隐私合规要求。public class AesEncryptUtil { private static final int IV_LENGTH 12; private static final int TAG_LENGTH_BITS 128; private static final String TRANSFORMATION AES/GCM/NoPadding; // 生产环境建议从配置中心或KMS获取不要硬编码在代码里 private static String secretKey your-128-bit-secret-key; public static String encrypt(String plainText) { try { byte[] keyBytes Arrays.copyOf(secretKey.getBytes(StandardCharsets.UTF_8), 16); SecretKeySpec keySpec new SecretKeySpec(keyBytes, AES); byte[] ivBytes generateIv(); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(TAG_LENGTH_BITS, ivBytes)); byte[] cipherText cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); byte[] result new byte[ivBytes.length cipherText.length]; System.arraycopy(ivBytes, 0, result, 0, ivBytes.length); System.arraycopy(cipherText, 0, result, ivBytes.length, cipherText.length); return Base64.getEncoder().encodeToString(result); } catch (Exception e) { throw new RuntimeException(AES加密失败, e); } } public static String decrypt(String cipherBase64Text) { try { byte[] encryptedData Base64.getDecoder().decode(cipherBase64Text); byte[] ivBytes Arrays.copyOfRange(encryptedData, 0, IV_LENGTH); byte[] cipherText Arrays.copyOfRange(encryptedData, IV_LENGTH, encryptedData.length); SecretKeySpec keySpec new SecretKeySpec(Arrays.copyOf(secretKey.getBytes(StandardCharsets.UTF_8), 16), AES); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(TAG_LENGTH_BITS, ivBytes)); byte[] plainBytes cipher.doFinal(cipherText); return new String(plainBytes, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException(AES解密失败可能数据被篡改或密钥不一致, e); } } private static byte[] generateIv() { return new SecureRandom().generateSeed(IV_LENGTH); } }几个关键点说一下我把IV拼接到了密文头部随密文一起存储解密时从密文里取回IV。这样每条记录的IV都不一样即使两条明文相同密文也不相同避免了数据模式泄露。secretKey正常情况下应该放配置中心或者KMS工具类里只留一个入口方法。我在项目里最初是写在配置文件里后来切到配置中心Handler一行都没改。解密失败时一定要抛出异常不能返回原字符串也不能返回空串。否则数据被篡改后你会拿到一个看似正常实则被替换的“明文”这在安全审计上是大忌。3.2 自定义EncryptTypeHandler实现工具类准备好之后继承BaseTypeHandlerString逻辑非常简单MappedTypes(String.class) public class EncryptTypeHandler extends BaseTypeHandlerString { Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, AesEncryptUtil.encrypt(parameter)); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { String columnValue rs.getString(columnName); return columnValue null ? null : AesEncryptUtil.decrypt(columnValue); } Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String columnValue rs.getString(columnIndex); return columnValue null ? null : AesEncryptUtil.decrypt(columnValue); } Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String columnValue cs.getString(columnIndex); return columnValue null ? null : AesEncryptUtil.decrypt(columnValue); } }这里要提一个经验不要使用rs.getString()拿不到值时的空串判断直接用null判断。因为我踩过坑——数据库里字段允许为NULL时解密一个NULL或空串会产生毫无意义的异常。BaseTypeHandler对null的兜底只在写入端生效读取端你拿到什么就是什么必须自己判空。3.3 单元测试是最快的自检方式在这类功能上我强烈建议写一个极简单的测试类就是最普通的main方法或JUnit测试直接绕开Spring容器验证Handler的加解密逻辑public class EncryptTypeHandlerTest { public static void main(String[] args) throws Exception { String phone 13800138000; // 1. 模拟写入 EncryptTypeHandler handler new EncryptTypeHandler(); // 手工构造PreparedStatement复杂度太高这里直接测试加密工具类 String encrypt AesEncryptUtil.encrypt(phone); System.out.println(加密结果: encrypt); // 2. 模拟读取 String decrypt AesEncryptUtil.decrypt(encrypt); System.out.println(解密结果: decrypt); assert phone.equals(decrypt); System.out.println(加解密一致测试通过); } }不要小看这一步。我见过不少同事直接部署到测试环境才验证结果数据库里出现了密文查询却解不出来排查链路拖得很长。Handlers本身纯粹依赖JDK和MyBatis API独立测试成本极低收益却很高。4. MyBatis与MyBatis-Plus两种接入方式的配置差异工具类和Handler类都写好了接下来就是接入。这一章我把纯MyBatis和MyBatis-Plus两种常见的配置方式都列出来因为很多项目是混用的——底层是MyBatis业务代码大量使用MyBatis-Plus的BaseMapper。4.1 纯MyBatis XML方式的配置如果项目里主要用XML写SQL需要在resultMap里显式指定加密字段的typeHandlerresultMap idUserResultMap typecom.example.User id columnid propertyid / result columnphone propertyphone typeHandlercom.example.handler.EncryptTypeHandler/ result columnid_card propertyidCard typeHandlercom.example.handler.EncryptTypeHandler/ /resultMap select idselectUserById resultMapUserResultMap SELECT id, phone, id_card FROM user WHERE id #{id} /select写入时在insert语句的#{}里也要声明typeHandlerinsert idinsertUser parameterTypecom.example.User INSERT INTO user(phone, id_card) VALUES(#{phone, typeHandlercom.example.handler.EncryptTypeHandler}, #{idCard, typeHandlercom.example.handler.EncryptTypeHandler}) /insert这里有个经常踩的细节resultMap里的typeHandler对INSERT无效INSERT必须单独在#{}里声明。因为INSERT不带resultMapMyBatis无法从返回类型反向找到字段映射。所以纯XML环境下容易出现“查询能解密插入却是明文”的割裂状态排查时优先检查insert语句有没有带typeHandler。这样写有个明显缺陷每个字段每个SQL都要写一遍太啰嗦。如果你的项目是纯MyBatis且SQL密集可以考虑在mybatis-config.xml里做全局配置typeHandlers typeHandler handlercom.example.handler.EncryptTypeHandler javaTypejava.lang.String/ /typeHandlers但即便全局注册了HandlerresultMap里如果不显式写typeHandler自动映射还是不会主动套用它。全局注册主要方便的是“手动指定时少写类名”并不解决自动映射问题。这一点很多人误解我特别强调一下。4.2 MyBatis-Plus的注解式接入MyBatis-Plus项目要简单很多。实体类上加两个东西类上TableName(autoResultMap true)字段上TableField(typeHandler EncryptTypeHandler.class)。TableName(value user, autoResultMap true) public class User { TableId(type IdType.AUTO) private Long id; TableField(typeHandler EncryptTypeHandler.class) private String phone; TableField(typeHandler EncryptTypeHandler.class) private String idCard; // 其他字段 }autoResultMap true是MyBatis-Plus让你能使用注解式typeHandler的大前提。它会根据实体类上的TableField注解自动生成内置的resultMap并把typeHandler带进去。如果忘加selectById、selectList返回的对象里加密字段就会是密文。这是我见过最多的配置失误没有之一。4.3 三种常见查询方式的覆盖情况对比MyBatis-Plus里不同查询方法对autoResultMap的依赖也不完全一样我实测下来总结了一张表查询方式是否自动解密selectById依赖autoResultMaptrue否则密文selectList/selectPage依赖autoResultMaptrue否则密文自定义XML SQL带resultMap以XML的resultMap为准与实体注解无关自定义XML SQL不带resultMap自动映射不套用typeHandler是密文QueryWrapper条件查询仅返回结果解密条件字段本身不加密理解这张表之后你就能解释很多诡异现象。比如selectById返回了密文往往是autoResultMap没开自定义SQL查出来是密文往往是XML里漏写了resultMap。把这两条规则刻在脑子里能省下大量排查时间。4.4 踩坑提醒Wrapper条件查询不会自动加密这是4.3表格里最后一行要展开的重点。很多人在QueryWrapper里写QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(phone, 13800138000); userMapper.selectOne(wrapper);然后发现查不到数据。原因很简单selectOne走的是参数绑定phone这个字段虽然实体类上配了typeHandler但QueryWrapper里的条件拼SQL时并不会主动套用实体类字段上的typeHandler。换句话说你在代码里传的是明文写到SQL里还是明文而数据库里存的是密文WHERE phone 13800138000自然匹配不上。解决方案有两种查询前手动加密条件值wrapper.eq(phone, AesEncryptUtil.encrypt(13800138000))这是最直接的办法但容易漏。如果项目里查询条件很多考虑统一封装一个QueryWrapper工具类提供eqEncrypt(phone, value)这样的方法内部自动加密。我最终在项目里就是往这个方向演进。这个问题的本质是TypeHandler只是一条“单向海关通道”它只作用于参数绑定和结果映射这两个环节并不会替你重写整个SQL。意识到这一点就能避免很多“Handler写了但没生效”的错觉。5. 实测中遇到的四个坑及排查过程配置完成后我用一个模拟项目完整跑了一遍插入、查询、更新、删除的生命周期踩了四个坑每一个都值得单独说。这一章我按“现象 - 排查链路 - 解决”的结构写你可以直接对照自己的场景。5.1 第一个坑查询返回的phone是密文但insert又是好的现象插入数据后用selectById查出来phone字段是AES...开头的密文字符串而数据库中存储的已经是密文说明insert方向加密成功但查询方向没有解密。排查过程一开始怀疑是getNullableResult没走对于是在三个重载方法里分别打了System.out.println日志结果发现方法根本没被调用。再查MyBatis-Plus的官方文档和源码确认selectById依赖实体类生成的resultMap而resultMap是否带上我的typeHandler取决于TableName(autoResultMap true)。回看实体类果然没有配这个属性。解决加上autoResultMap true查询立即恢复正常。这个坑属于配置遗漏不是代码逻辑问题但隐蔽性极强因为insert不受这个开关影响只有查询受影响表现非常割裂。5.2 第二个坑updateById更新不了数据影响行数为0现象updateById(entity)返回的影响行数是0数据库数据没变也没报错。排查过程把MyBatis-Plus打印出来的SQL拿到数据库里手动执行发现WHERE id ?的条件值是正常的但SET phone ?的参数值看起来是经过加密后的长串乱码。我立刻意识到问题出在实体类字段上——TableField(typeHandler EncryptTypeHandler.class)让SET phone正常加密了但WHERE条件里的主键id不是加密字段为什么影响行数还是0再深挖发现MyBatis-Plus的updateById生成SET子句的逻辑里配了typeHandler的字段会主动加密但WHERE子句并没有带上任何typeHandler。对比数据库里的密文SET phone的密文长度比现有数据长因为GCM模式每次IV不同是随机密文。我用新的密文去更新旧密文本应成功但updateById的乐观锁字段版本校验冲突了。去掉乐观锁字段后更新又恢复正常。解决这个坑其实是乐观锁与加密字段混用的特殊场景。处理上给需要加密的字段更新时我统一改用自定义的updateSQL或者确保实体的乐观锁版本号始终从数据库最新记录里读取。这不是TypeHandler本身的问题但却是实际项目中一定会撞上的写出来供参考。5.3 第三个坑日志打印导致明文/密文混乱现象MyBatis-Plus开启SQL日志后控制台打印的Preparing: UPDATE user SET phone? WHERE id?参数里有时是明文有时是密文排查数据时对不上。排查过程先看日志打印的时机。MyBatis的日志是在参数绑定之前打印的也就是说setParameter还没执行日志先输出原始参数。对加密字段而言日志输出的是来自Java对象的明文但数据库存的是密文。除非在日志配置里过滤phone字段否则排查线上问题时会把明文误认为是库里的值产生误导。解决数据脱敏层统一处理。我们的做法是在日志输出配置里对加密字段做脱敏比如只显示前3后4位中间打星同时配合log-impl关闭参数详细打印或者将参数打印级别从DEBUG调到TRACE。这个坑不修不影响功能但会让人在排查问题时走弯路。5.4 第四个坑加密字段无法做模糊查询现象业务方提需求说“按手机号后6位模糊查询用户”我下意识写LIKE %123456%结果一条都查不出来。排查过程回顾一下数据库存的是密文比如aB3x...xyz你去LIKE %123456%密文里根本不存在这6位数字的明文特征自然查不到。就算用“先查出全部再在内存里解密过滤”这种歪招数据量一上去就废了。解决既然加密字段天然不支持LIKE我在项目里采用了冗余索引列方案单独建一个phone_plain_index列存储明文手机的哈希值或脱敏后的后6位明文专门用于模糊查询查询时先用索引列缩小范围再用IN或主键回表精确获取数据后统一解密。phone_plain_index这列本身存的是脱敏信息不体现完整明文隐私泄露风险可控。做法可行性说明LIKE %明文片段%不行数据库为密文匹配不上全表查出来解密后内存过滤可以但性能差适用于数据量小不建议生产冗余哈希列或脱敏列推荐查询走索引列精确匹配再回表解密改用密文等值查询部分可行必须知道完整明文才能加密后查询模糊不行模糊查询这个问题没有银弹核心思路是“能走索引的列别加密必须加密的列别指望SQL筛选”。如果业务上确实要加密又要模糊检索那就老老实实设计冗余列不要试图在密文上做文章。这个教训我付出了不少代价才换回来。6. 密钥管理、性能开销与后续扩展方向四个坑处理完之后方案基本稳定了但作为从事多年开发的人还有几件事值得提前布局尤其是密钥管理和性能评估。6.1 密钥不要写在代码里我见过太多项目把secretKey直接定义成静态常量放进类里这跟明文存储没有本质区别一旦源码泄露所有加密数据等于裸奔。规范的做法是集中放配置中心或KMS按环境区分开发、测试、生产的密钥定期轮换。Handler从配置中心获取密钥轮换时只需要更新配置重启生效业务代码无感。如果你还没有配置中心也要把密钥放在环境变量里跟代码仓库隔离。6.2 性能开销实测AES-GCM加解密性能本身极快但每次加解密都会有开销。我在模拟项目里用10万条数据跑了两次全量查询对比开启加密Handler前后的耗时。结果大概是单次查询在数据量不大时加解密带来的额外耗时在毫秒级以下对单体应用几乎没有感知但在列表页每页拉50条、每条2个加密字段整体耗时增加约8%到12%。这个数字对大多数业务可以接受但对于高并发系统需要评估是否需要缩短加密字段数量或者引入缓存层。我自己的经验是敏感字段能少加密就少加密。手机号和身份证号这种高频查询又必须加密的字段优先考虑倒排索引列端口隔离的查询方案而不是依赖每次解密。6.3 除了手机号还能怎么扩展自定义TypeHandler不止用于AES加解密这套模式还能延伸到其他需要统一处理字段值的场景。比如字段脱敏查询出来直接对手机号、银行卡中间打码在getNullableResult里处理。JSON字段加密实体类里Map或List类型的字段序列化后加密存库反序列化解密。数据权限过滤按登录人角色在Handler里过滤某些字段的值。审计留痕在写入链路里截获原值做审计日志。我的经验是只要ORM层面需要“同一类型字段统一转变”的逻辑都可以优先考虑TypeHandler。它不像AOP那样依赖方法调用链也不像数据库函数那样受限于数据库方言。只要这条SQL走了MyBatis的字段映射就一定能被Handler兜住。6.4 最后分享一点个人体会这个方案落地之后我最大的感受是加密能力不是越复杂越好而是越统一越好。把加解密收口到TypeHandler这个边界里业务代码完全不需要感知字段是否加密后续做安全合规审计的时候只需要排查TableField(typeHandler EncryptTypeHandler.class)这一行注解覆盖了哪些字段改起来非常清晰。回到最初选型的问题当时如果直接照搬搜索引擎里“AOP加密”的答案是能跑通的但到一定规模之后业务方法数量上升遗漏点会越来越多。而TypeHandler从数据库访问最底层入手从机制上保证了“只要字段被映射就必须走加解密”。这个“机制保障”而不是“自觉保障”的思路是我最推荐它的一点。如果你正准备给存量项目加字段级加密我建议的落地顺序是先用一个小表试运行把插入、查询、更新、条件查询、模糊查询这些场景全部测通再逐步推广到核心表。不要第一天就把所有敏感字段都加密否则数据迁移、条件匹配、业务联调的坑会同时涌过来到时候真的会焦头烂额。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑