MyBatis 拦截器做数据加解密这个方向我身边做后端的朋友问过很多次。一开始听到这个需求很多人第一反应是“直接在 Service 层加密不就完了”但实际上数据落库前后还有一堆隐性问题分页查询、批量插入、模糊搜索、日志脱敏每一项都能让“简单方案”翻车。这篇文章我直接拿一套可落地的实践来聊从拦截器原理、AES 参数选型到具体拦截点设计、字段映射策略再到最后怎么排查问题一条线讲完。适合正在做数据安全改造、或者被合规需求“赶着上”的 Java 后端开发参考MyBatis 版本在 3.4 以上应该都能直接套用。1. 为什么选择在 MyBatis 这一层做统一加解密先解决一个根本问题加解密逻辑到底放在哪一层才靠谱。如果你在一个老项目里做敏感字段保护最忌讳的就是把 AES 调用散落到业务代码里。Service 层改一遍、Controller 层再改一遍过两个月换套加密算法那就是全局大扫除漏掉一个字段都不知道去哪排查。另一个很现实的场景是项目里存在大量历史代码所有出入参都是明文数据库里也已经躺着几百万条“裸奔”数据。此时想在不改动业务逻辑的前提下给敏感字段加层保护最省事的做法就是把拦截器架在 MyBatis 的 SQL 执行链路上。所谓“共用一个出入口”指的就是 MyBatis 的四大组件在 Executor、StatementHandler、ParameterHandler、ResultSetHandler 这四层天然形成了请求流转闭环。写操作进来时在参数处理阶段做加密读操作出去时在结果处理阶段做解密加解密对上层 Service 完全透明对底层数据库也完全透明。这种方案的核心优势有三个。对业务代码零侵入。原来怎么写 SQL 还是怎么写原来怎么传对象还是怎么传拦截器在背后统一处理。可集中管控密钥与算法。加密逻辑只出现在一个地方密钥轮转、算法升级只需要改一处。便于统一审计。哪些字段加密了、哪些字段没加密配置一目了然不用满项目搜索 crypto 相关调用。当然这种设计并非没有代价。拦截器一旦强制加密某个字段意味着所有对该字段的查询条件都必须以加密后的密文来匹配这里面的坑后面专门讲。一个常见误解是只有“大厂”才有这种需求。实际上现在很多中小型项目也在应对等保测评、数据安全法这类合规要求只要涉及手机号、身份证、银行卡、住址这类个人敏感信息基本都跑不掉。早做比晚做省事因为你永远不知道存量数据会在哪天变成一把悬着的刀。2. 拦截器运行机制拆解四条链路各管哪一段MyBatis 四层组件拦截器的核心是实现了 Interceptor 接口的类配合 Intercepts 注解声明要拦截的方法签名。不少新手一看要写注解就发怵其实拆开理解并不复杂。先理清四个组件的职责。Executor 负责整体调度包括增删改查的入口方法 update、query、queryCursor以及事务相关方法 commit、rollback。StatementHandler 负责 SQL 语句的预处理包括 prepare、parameterize、batch 等环节。ParameterHandler 负责把 Java 参数转换成 JDBC 所需的参数核心方法是 setParameters。ResultSetHandler 负责把 JDBC 返回的结果集映射成 Java 对象核心方法是 handleResultSets。如果你只想对读写结果做加解密最直观的落点是参数处理与结果处理这两层即 ParameterHandler 和 ResultSetHandler。如果想对 SQL 文本身做改写比如把明文查询条件改成密文匹配则需要动 StatementHandler 或 Executor。这里要特别强调一下很多人以为一个拦截器能覆盖所有场景实际上职责拆分越细越可控。我给自己的项目定的规则是参数与结果尽量在一处拦截器里同时处理避免出现“结果解密了但参数没加密”的错位状态而 SQL 改写类需求单独拎出去用独立的拦截器按 SQL 类型分别处理。很多人问为什么不能直接在 Executor 层一把梭把所有活干了。Executor 层的参数对象类型比较复杂可能是单个对象、Map、List也可能是 Param 标注的多个参数需要做大量类型判断。而 ParameterHandler 层拿到的是已经格式化好的参数映射处理起来反而干净。还有个细节值得关注拦截器支持 Signature 配置多个 method比如同时拦截 prepare 和 parameterize这里建议按自己项目实际需要配置别为了展示技术把所有方法都挂上每个方法都有额外的类型判断开销。用生活的类比来理解四层拦截器Executor 像前台接待负责总揽预约StatementHandler 像厨师长负责决定用哪种锅ParameterHandler 像配菜员负责把食材整理成规定形状ResultSetHandler 像传菜员负责把成品端上桌。你可以在配菜环节把调料换成加密后的形态也可以在传菜环节把菜品还原成顾客认识的样子至于中间环节怎么运作完全不需要打扰它们。理解了这套分工再看拦截器实现代码就会清晰很多。以 Intercepts 注解为例MyBatis 会给被拦截的类生成代理对象并不是拦截器真的改写了 MyBatis 源码这一点心里有底就行。3. 到底该拦截 Executor 还是 ParameterHandler 与 ResultSetHandler我见过不少方案直接在 Executor 层 intercept 方法里获得 BoundSql 之后开始手工解析 SQL 来定位字段和参数的位置。这种方法的问题在于手写 SQL 解析器难度高、边界条件多同样的功能 MyBatis 已经帮你在 ParameterHandler 层实现了参数占位符与真实值的绑定何必重复造轮子。实际项目中最稳妥的组合是一个拦截器拦截 ParameterHandler.setParameters 做加密处理同时拦截 ResultSetHandler.handleResultSets 做解密处理。这样加密发生在参数映射阶段解密发生在结果集映射阶段SQL 里本身仍然是“?”占位符真正替换进去的是已经加密后的值写进去的、读出来的都是完整的数据形态。但这里有一个必须提前想清楚的点拦截器拿到的只是 List想省事的做法是给实体类或 DTO 字段加上自定义注解比如 SensitiveField然后用反射方式检查参数对象里哪些属性带了这个注解。查询条件单值场景下我们往往直接把参数对象当成载体来解析插入更新也是如此。这样虽然要写一些反射代码但结构清晰还能顺便做空值判断。我自己的实现里还处理了 MyBatis 的一个老问题参数集合里既可能有 POJO 对象也可能有 Map、List甚至是 Param 包装对象。判断逻辑如果是 if (param instanceof POJO) 就会漏掉 Map 场景实际应该从上到下依次匹配已知类型并且对 Param 产生的包装对象做深度遍历避免有字段“藏在套子里”。另外一个隐蔽点如果项目启用了二级缓存ResultSetHandler 返回的不一定是数据库原生的“裸”结果集也可能是缓存命中后的对象。拦截器这个时候照常解密即可因为拦截器作用于返回链路之前缓存读出的数据也会经过 ResultSetHandler 的处理流程。但如果直接操作 Executor 层就可能存在二次解密的风险这也是我推荐选择后两层作为主落地点的原因。4. AES 对称加密的参数选型与实践配置AES 本身是一个块加密算法块大小固定 128 位因此加密模式、填充方式、密钥长度这三个参数决定了一套方案的安全等级。实际开发中最常见的组合是 AES-128-GCM其次是 AES-128-CBC 配合 PKCS5Padding。我用 GCM 模式有两个原因。CBC 模式只保证密文的“块级机密性”不提供完整性校验。意味着如果有人篡改了密文中的某个块解密端最多报填充错误改完之后的数据还是能被“部分解密”出来这在防篡改要求高的场景下是硬伤。GCM 是带认证的加密模式解密时会同时校验完整性一旦数据在传输或存储中被改动解密会直接失败不会产出垃圾明文。参数配置上有三项必须盯紧。密钥长度与随机性。AES 支持 128、192、256 位密钥长度越长安全余量越大但性能损耗也随之增加。Java 中 256 位密钥需要安装 JCE 无限强度权限策略文件旧项目没配过的直接在加密报错时才会发现。如果项目环境统一且可控直接用 256 位如果有兼容性顾虑128 位也够用。GCM 的 IV 不可重用。同一把密钥下每次加密必须生成新的随机 IV否则相同的明文会产生相同的认证标签安全性大打折扣。很多人在简单 demo 里写死了 IV这在测试环境没问题上线就是安全隐患。填充模式。GCM 内部自己处理了数据的分段逻辑不太适合再叠加额外填充。Java 中 AES/GCM/NoPadding 是标准写法不要画蛇添足。实际中验证过的一组参数推荐如下。参数项推荐值备注密钥长度128 或 256 位128 位兼容性最好256 位安全余量更高加密模式GCM带完整性认证优先选择IV长度12 字节GCM 推荐值性能最好认证标签长度128 位16字节默认 128 位编码方式Base64密文存储和传输都用 Base64避免二进制乱码AES-GCM 加密后的输出通常包括三部分IV、密文、认证标签实际存储时需要把它们拼接在一起。为了方便数据库存储和后续解密我通常会把 IV 和密文按固定格式拼成一个字符串例如 iv 用 Base64 编码后加冒号再接密文。解密时先按冒号拆开再分别处理。很多人容易踩的坑是直接把密文转成 UTF-8 字符串存库结果某些二进制字符在入库时被截断或变成乱码。正确做法是加密后统一转 Base64存储长度也会比较固定对数据库字段类型的选择更友好。还有一点很重要AES 是算法不是加密方案的终点。真正要保护的密钥本身必须妥善管理绝不能硬编码在代码里或者提交到代码仓库。哪怕你的表结构字段全部加密了只要密钥躺在源码里被人看到整个方案等于白做。小型项目可以用环境变量配合配置中心大型团队建议使用独立密钥管理服务将密钥与业务系统隔离并设置自动轮换策略。这一点在高版本的安全扫描和渗透测试里几乎是必查项。5. 完整实现基于拦截器的 AES 加解密 Demo 拆解先说整体架构再上关键代码。整个模块分为三部分加密工具类 AESUtil、自定义注解 SensitiveField、拦截器 CryptoInterceptor。核心流程是拦截 setParameters 时遍历参数对象找到带注解的字段将其值加密为密文拦截 handleResultSets 时遍历结果对象找到带注解的字段将密文解密为明文。加密工具类不刻意做成 Spring Bean保持无状态静态方法即可方便在任何环境中复用。核心方法涉及加密时的 IV 生成、Base64 编码解密时的密文拆分。这里给出一个基础版本实际项目可以直接在此基础上扩展。public class AesUtil { private static final int IV_SIZE 12; private static final String TRANSFORMATION AES/GCM/NoPadding; public static String encrypt(String plainText, SecretKey key) { try { byte[] iv new byte[IV_SIZE]; SecureRandom secureRandom new SecureRandom(); secureRandom.nextBytes(iv); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); byte[] encrypted cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(iv) : Base64.getEncoder().encodeToString(encrypted); } catch (Exception e) { throw new RuntimeException(加密失败, e); } } public static String decrypt(String cipherText, SecretKey key) { try { String[] parts cipherText.split(:); byte[] iv Base64.getDecoder().decode(parts[0]); byte[] encrypted Base64.getDecoder().decode(parts[1]); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv)); byte[] decrypted cipher.doFinal(encrypted); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException(解密失败, e); } } }这里有个关键点如果你在 GCM 模式下手动指定了每次加密随机生成 IV那么解密时就必须严格使用加密时生成的那个 IV。示例代码里把 IV 放在密文前面存储就是为了后续解密时能找到对应 IV否则无法解密。注解类非常轻量主要是标记作用。需要注意的是注解的保留策略要设置为 RUNTIME否则拦截器里用反射获取不到。Retention(RetentionPolicy.RUNTIME) Target(ElementType.FIELD) public interface SensitiveField { }拦截器是核心实现思路如下。Intercepts({ Signature(type ParameterHandler.class, method setParameters, args {PreparedStatement.class}), Signature(type ResultSetHandler.class, method handleResultSets, args {Statement.class}) }) public class CryptoInterceptor implements Interceptor { private SecretKey secretKey; Override public Object intercept(Invocation invocation) throws Throwable { if (invocation.getTarget() instanceof ParameterHandler) { processParameter(invocation); } else if (invocation.getTarget() instanceof ResultSetHandler) { processResult(invocation); } return invocation.proceed(); } private void processParameter(Invocation invocation) throws Exception { ParameterHandler parameterHandler (ParameterHandler) invocation.getTarget(); Field parameterField ParameterHandler.class.getDeclaredField(parameterObject); parameterField.setAccessible(true); Object parameterObject parameterField.get(parameterHandler); encryptFields(parameterObject); } private void processResult(Invocation invocation) throws Exception { Object result invocation.proceed(); decryptFields(result); return result; } private void encryptFields(Object obj) { // 反射遍历字段匹配 SensitiveField 注解 } private void decryptFields(Object obj) { // 反射遍历字段匹配 SensitiveField 注解转换为明文 } }一个容易被忽略的点是 ParameterHandler 的 parameterObject 字段声明在 MyBatis 的基类 BaseBuilderHandler 内部还是具体实现类里不同 MyBatis 版本字段名可能有所变化。建议在 processParameter 中通过反射遍历当前类的所有字段动态查找到名为 parameterObject 的字段而不是写死路径这样升级 MyBatis 小版本时不会炸。processResult 中 invocation.proceed() 的返回值可能是单个对象也可能是 List在 decryptFields 里都要判断。MyBatis 对于单条查询返回单对象对于列表查询返回 List处理时注意区分。另外一个常见但必须处理的场景解密方法对结果对象做反射访问时如果字段值是 null跳过即可如果原本就是普通字符串比如未加密的历史数据解密会报错需要提前判断密文格式是否符合预期再做解密尝试。实际项目中我习惯让 decrypt 方法内部先检查字符串是否包含分隔符如果不符合格式直接原样返回避免历史数据导致大批量解密失败。6. 生命周期管理密钥配置与拦截器装配拦截器写完了关键问题是在 Spring Boot 环境里怎么让它生效、密钥从哪里注入进来。拦截器装配有两种方式XML 配置中的 plugins 标签或者 Java Configuration 中手动添加 Interceptor 实例。Spring Boot 项目更推荐第二种直接在配置类中构建拦截器对象同时把密钥注入进去。Configuration public class MybatisConfig { Bean public CryptoInterceptor cryptoInterceptor(Value(${crypto.key}) String cryptoKey) { CryptoInterceptor interceptor new CryptoInterceptor(); interceptor.setSecretKey(buildKey(cryptoKey)); return interceptor; } }注意一点如果拦截器被声明为 Spring 的 Component它会被 Spring 容器托管同时也需要手动注册到 MyBatis 配置里。常见的坑是项目用了 MyBatis 自动配置Component 已经被自动扫描并加入全局配置了但你后来又手动 new 了一个新的拦截器实例导致出现两份拦截器参数处理重复加密结果处理重复解密。这种问题非常隐蔽因为偶尔“正常工作”压力大的时候才出现怪异的密文排查难度极高。密钥管理上我建议使用专用于加密的独立配置项而不是把支付宝、数据库连接密码等所有密钥放到同一个 KV 里。针对敏感字段建一个单独的密钥一旦发现有泄露风险只影响该密钥关联的字段不影响其他系统组件。密钥存储方式至少要做到环境变量或配置中心加密字段直接把明文 Base64 写在 application.yml 里属于很危险的做法。初次上线加解密功能时还有一个“存量数据如何处理”的经典问题。三种策略供参考双写兼容。上线后新写入的数据加密旧数据保持明文读取时尝试解密失败就返回明文后台写个全量加密脚本逐步刷数据。这个方案相对平滑但系统里会同时存在明文与密文审计时比较难看。停机迁移。直接停服全表扫描重写适用于数据量小、可接受短时中断的场景。最干净但代价高。影子表迁移。新建一张新表写入加密数据通过开关切换读写流量逐步迁移。工作量最大但对在线业务影响最小。我个人比较推荐双写兼容配合离线清洗脚本因为大部分项目的加密改造都不是从零开始的存量数据一旦很多停服迁移往往会被业务方否决。等加密上线跑稳了再通过后台任务把所有明文记录替换成密文最终状态达到全密文。7. 解锁更多场景分页、批量插入与模糊搜索该怎么处理基础拦截器已经能把“传对象进去加密、查出来还原”这件事跑通了但真实系统哪有这么规矩分页查询、批量插入、模糊搜索这几个场景是拦路虎。先说分页查询。MyBatis 的分页插件常见的有物理分页插件通常基于 Executor 拦截器实现它在 query 方法中构建新的分页参数并覆盖原 SQL。如果我们的加密拦截器只拦截 ParameterHandler那么分页插件拦截器先执行还是后执行会直接影响参数处理链路。建议让分页插件保持默认执行顺序然后确保加密拦截器在参数绑定阶段仍能拿到完整参数集合。分页参数本身不是敏感字段不会带 SensitiveField 注解所以加密逻辑不影响分页。批量插入场景比较麻烦。批量插入通常是一个 List 参数里面每个元素都可能含敏感字段。如果只加密了 List 本身而没有人处理内部元素那密文入库的是对象内存地址灾难性故障。所以在 encryptFields 方法里必须要支持 List 内部元素的遍历处理同样 result 集合也要逐个解密。我在代码里专门写了一个处理 List 的分支避免被 MyBatis 的参数包装类影响。模糊搜索则是另一个完全不同的维度。数据库层面如果某个字段加密成密文那么 LIKE %keyword% 这种查询天然失效。因为密文是不可逆的数据库无法从密文反推明文做模糊匹配。要支持模糊搜索大体有三条路放弃数据库模糊搜索功能上改为精确匹配。增加冗余明文列仅用于模糊搜索且做好专门的脱敏处理控制可见范围。引入加密可搜索方案比如基于哈希的盲化索引但实现难度较高小型项目不一定划算。我做过一个取舍手机号这类字段模糊查询需求不强烈直接退化到精确匹配姓名这类字段增加一个冗余的“明文脱敏列”只存脱敏后的短数据用于模糊匹配同时保留加密列用于精确查询。这种方案在业务上往往能被接受同时控制了实现复杂度。模糊搜索还有一个坑拦截器加密条件字段时如果你用 ResultMap 里专门建了一个不带注解的查询 DTO 对象里面放了一个叫 keyword 的字段但它和数据库里加密的 name 字段对应不上。这种情况下拦截器是加密不到 keyword 的因为注解是在新 DTO 的字段上而不是数据库实体类上。这是设计上需要提前规划清楚的点——哪些入口属于“加密条件”哪些入口属于“脱敏搜索条件”要单独约定。8. 常见问题与排查技巧实录这套方案在真实项目中踩过的坑不少挑几个高频的整理成速查表都是血泪经验。问题现象根因分析解决方案解密报错 InvalidAlgorithmParameterExceptionGCM 模式下 IV 长度不为 12 字节固定 IV 生成长度统一使用 12 字节随机 IV加密后的字符串入库后出现乱码密文 Base64 被当成 UTF-8 文本处理确认数据库字段类型为 VARCHAR长度足够存放 Base64 结果二次加密导致同一条数据被加密两遍项目中存在两个拦截器实例排查自动配置与手动 new 并存的情况只保留一个实例查询条件带密文查不到数据拦截器只加密了插入字段没有加密查询条件字段统一在参数处理中检查 SensitiveField 注解所有入口一视同仁历史明文数据解密报错存量记录未加密被新逻辑当成密文处理在解密方法中对字符串格式做校验不合法直接跳过或做兼容标记分页插件和加密拦截器顺序混乱两个拦截器都修改 SQL 相关行为执行顺序不同导致参数错位日志打印每个拦截器的执行顺序按需调整 Order 或注册顺序一个特别有价值的排查技巧在拦截器 intercept 方法里打日志输出正在处理的参数对象类型与当前字段名能快速定位“到底有没有进入加密逻辑”。很多问题出在位置判断上参数对象包装类型不一致导致遍历没走到你的预期分支日志是唯一能确认这条链路的途径。另一个隐蔽坑是 Spring 事务与二级缓存的叠加效应。当 Service 方法上标注了 TransactionalMyBatis 的一级缓存会在当前 SqlSession 中缓存结果如果执行了更新操作后再次查询查询结果可能命中了一级缓存此时 ResultSetHandler 可能不再被调用。这意味着更新后的解密逻辑可能不会生效。如果发现“明明改了加密逻辑但老数据怎么读都还是密文”先查是不是缓存层的问题。解决方案是加缓存版本号或清缓存再读。日志脱敏也是很多人忽略的部分。如果加密方案只在数据落库时生效但 SQL 日志里已经把参数明文打出来了那整个数据保护效果就会削减。打开 MyBatis 的 SQL 日志后preparing 部分的占位符参数通常都是明文参数值需要在日志框架层面同步配置脱敏拦截或使用专门的日志组件对打印的 SQL 参数做过滤。加了日志脱敏之后排查问题的时候也方便因为日志里看不到明文密文和加密条件也能对上非常有助于定位是参数本身错了还是加密流程错了。另外批量场景下还需要注意异常回滚策略。如果加密过程中某个字段抛异常默认情况下整个事务会回滚这会让业务方很痛苦。我建议在拦截器中区分“加密失败”和“参数不合法”两种异常前者直接往上抛并终止事务后者在安全评估后选择返回业务层自定义异常而不是让 MyBatis 抛一个难以理解的加密错误。9. 性能考量与压测观察很多人天然担心加解密影响性能实际上 AES 对称加密速度很快瓶颈往往不在算法本身而在于反射遍历字段和序列化开销。我在一个中等规模的项目里做过一次简单压测数据量 10 万条每条记录包含一个 18 位身份证号字段。使用 AES-128-GCM 加密单条加密耗时大约在 30 微秒量级解密类似。批量插入场景下1000 条记录的总耗时约 50 毫秒以内这相对于一次网络请求动辄几十毫秒的 IO 耗时基本可以忽略。真正的性能风险点在于反射。拦截器每处理一个对象都要做字段遍历和注解查找如果对象字段特别多比如 30 个字段而这个对象在批量场景中又有 1000 条反射开销就会被放大。优化手段有两种一是使用反射字段缓存把类的 Field 数组缓存到 ConcurrentHashMap 中避免每次重复 getDeclaredFields二是只在标注了 SensitiveField 的类上启用加密逻辑其他类直接放行减少无效遍历。还有一种优化思路是只在读操作时开启解密写操作时不做不必要的字段遍历。比如 SQL 命令为 INSERT、UPDATE 时只处理加密SELECT 时只处理解密。这样既能减少三分之一的反射调用也让职责更明确。但我要说一句为了性能把加解密设计得过于复杂是不值得的。与其用各种花哨的缓存机制不如把参数对象结构设计得简单一点多写几个明确的传参 DTO让拦截器的遍历逻辑更可控比任何缓存都稳。10. 一个并行方案TypeHandler 是否更合适说完拦截器顺便聊聊很多人会问的 TypeHandler 方案。TypeHandler 是 MyBatis 中专门负责 Java 类型与 JDBC 类型之间映射的组件允许你自定义类型处理器。比如给加密字段定义专用的 TypeHandler在插入时加密在查询时解密。TypeHandler 方案和拦截器方案最大的区别在于作用范围。TypeHandler 只作用于单个字段的映射你必须为每个加密字段显式指定 typeHandler。这意味着 XML 里每个字段都要写 typeHandler 引用或者通过 统一声明。如果加密字段很多XML 配置会非常臃肿而且新增加密字段时很容易漏配。拦截器方案则是统一编码在全局逻辑里可维护性更好。TypeHandler 也有它的价值场景如果你只需要处理两三个固定字段而且不想引入写拦截器的复杂度TypeHandler 反而是更轻量的选择。我自己的使用习惯是涉及加密字段数量少且字段固定用 TypeHandler 简单省事涉及多个模块多个表十来个加密字段直接上拦截器方案。两者并非水火不容也可以共存但尽量让每个字段只被其中一个机制处理避免重复加解密。11. 踩坑之后的扩展先做单表试点再做全局推广项目推进过程中还有一个组织层面的建议不要试图一步到位把系统里所有敏感字段全部加密。最稳妥的路径是先选一个表、一个字段做试点比如手机号。确认加密、解密、查询、分页、日志脱敏全部正常后再推广到身份证号、银行卡号、地址等字段。这么做有几个好处问题暴露在最小范围不会引发全局故障。能让业务方看到相同的改造模式对后续工作有信心。逐步形成一套内部检查清单推广时按清单逐项审计。如果一开始就把几十个字段全加密一旦出现二次加密、解密失败、搜索失效等问题排查面太广很容易在“修复加密”和“保持业务可用”之间左右为难。另外一个值得尽早确定的点是密钥隔离。不同敏感级别或不同业务模块可以使用不同的密钥避免一个泄漏导致全部沦陷。密钥本身不要写死在代码里而是通过配置中心下发配合定期轮换策略。我见过把密钥写死在常量类里的项目代码一旦打成 jar 包传到仓库里等于把保险柜钥匙跟保险柜一起送出去了。试点改造做完后再洗存量数据。洗数据建议放在业务低峰期执行并加上限流和进度监控。数据量特别大的表可以用分批条件更新一次处理一万条左右避免大事务压垮数据库。最后再分享一个扩展技巧在 encrypted 字段旁边可以增加一个冗余列用来标记是否已加密比如 is_encrypted 字段。这样解密逻辑中遇到这个标记为未加密的数据时可以直接跳过解密逻辑避免所有历史数据都解析出错。等全量刷完加密后这个标记字段就可以逐步废弃最终删除。这个小技巧在实际存量迁移中帮了大忙比单纯靠“字符串格式是否含冒号”判断靠谱得多。