
先说个我自己踩过的坑。以前做过一个后台管理系统用户手机号、身份证号在数据库里全是明文当时觉得“反正内网权限也控了应该没事”。结果有一次测试库被误当成生产库pull到本地一份带全量用户信息的备份就这么散出去了还好没造成实质损失但那个冷汗确实让人记忆深刻。从那以后我就特别关注数据落地加密这件事。后来在项目里引入ShardingJDBC做分库分表顺便研究了一下它自带的数据加密功能发现这玩意儿比想象中好用而且“透明”这两个字真的不是营销话术。这篇文章我打算从原理到实战完整聊一遍ShardingJDBC数据加密包括它的核心机制、配置怎么写、怎么处理存量数据、密文模糊查询怎么破最后再分享几个我实际排过的坑。不管你现在是只用单库单表想做字段加密还是已经在分库分表场景里挣扎这文章应该都能给你省不少时间。1. 数据加密方案选型为什么要在ShardingJDBC这一层做透明加密1.1 先搞清楚数据安全的最后一道防线在哪里很多团队对数据安全的认知停留在“网络隔离 数据库账号权限”这一层。网络隔离管的是外部访问数据库权限管的是谁能连库。但有一个很尴尬的事实一旦数据库账号泄露、备份文件被脱库、或者运维同学误操作导出了一份数据所有的边界防护就全部失效了。这时候能救命的只有一个东西——数据本身是密文的。所以“字段级加密”听起来很基础却是数据安全的最后一道防线。问题在于字段加密怎么做常见做法无非这么几种业务代码里手动加解密、ORM框架拦截器做加解密、数据库层TDE透明数据加密、中间件层透明加密。前两种侵入性太强改造成本高还容易漏改。后两种主打“透明”但你用的时候会发现TDE和ShardingJDBC的透明加密根本不在一个层面上。这里我提一下“透明数据加密TDE白皮书”这个概念很多人对TDE有误解以为数据库有了TDE就不用做字段加密了。实际上TDE保护的是磁盘文件、备份文件这类静态数据数据到了数据库内存里、返回给应用的链路上TDE是管不到的。ShardingJDBC的加密发生在SQL执行引擎层数据从应用发出到数据库落盘之间就被加密了查出来的时候再解密。两者是互补关系不是替代关系。1.2 TDE和应用层透明加密不是二选一是各管一段我画过一张对比表给自己团队讲方案贴出来供参考对比维度数据库TDEShardingJDBC/JDBC中间件加密加密位置数据库存储引擎层应用与数据库之间的JDBC层保护对象磁盘文件、备份文件SQL语句涉及到的字段数据对应用影响完全无感知业务代码几乎无感知配置即可密文可检索性存储层解密后再查询索引不受影响密文落库LIKE/范围查询天然受限适用场景防拖库、防备份泄露防数据库账号泄露、防DBA直接看敏感字段我看到很多团队的误区是已经买了数据库TDE就觉得字段加密不用做了。实际上一旦应用账号泄露别人直接select敏感字段TDE返回的是明文。而ShardingJDBC加密之后即使拿到账号、连上数据库看到的敏感列也是一堆密文。反过来也一样只做字段加密、不做TDE备份文件被盗一样是明文泄露。所以有条件的话两层都做各管一段。2. 核心机制拆解ShardingJDBC如何做到“透明”加密2.1 写入和查询的全链路数据流ShardingJDBC本身是一个数据库中间件所有SQL都会经过它的解析引擎。加密模块就是寄生在SQL解析、改写、执行、结果归并这四个环节里的。听起来复杂其实核心就两步。写入的时候业务代码里照常传明文SQL解析器拿到这条insert语句发现目标表的某个字段配置了加密规则就会在改写阶段把字段名改成密文列把参数值替换成加密后的密文。所以数据库里落盘的一开始就是密文。查询的时候流程反过来SQL正常查询数据库返回密文结果集ShardingJDBC在结果归并阶段识别到加密列自动解密成明文业务代码拿到的还是正常明文。这就是“透明”的含义。业务代码完全不知道自己被加密了就像请了个翻译官写的时候把中文翻成英文递给对方读的时候把英文翻回中文递给你你跟对方说话的内容始终没有变过。2.2 五个核心概念加密器、密文列、明文列、辅助查询列、查询开关用了一整年ShardingJDBC加密之后我觉得只要把下面五个概念吃透配置基本不会踩坑。加密器encryptors定义用什么算法来加密。ShardingJDBC自带了AES、MD5两种也支持自定义实现。加密器可以配置多种不同的表、不同的字段可以挂不同的加密器。密文列cipherColumn实际存密文的物理列建表的时候要提前建好。框架不会自动帮你改表结构。明文列plainColumn存明文的物理列。这个列不是必须的但在做存量数据双写迁移的时候特别有用。配置之后插入数据时会同时写一份明文、一份密文查询时可以指定走哪个列。辅助查询列assistedQueryColumn这个列是为了解决“密文无法检索”问题设计的。后面第4章会细讲这里先知道有这个概念就行。查询开关queryWithCipherColumn决定查询时优先用密文列解密返回还是直接用明文列返回。默认是true也就是优先走密文。存量迁移阶段可以改成false等数据刷完再切开。这五个概念之间的依赖关系我在实战章节会结合配置逐行拆。2.3 自带的加密算法与自定义扩展思路ShardingJDBC自带两种加密器。一种是MD5哈希型不可逆适合密码校验这种只需要比对、不需要还原的场景。另一种是AES对称加密可逆适合手机号、身份证这类查询后需要展示明文的字段。AES需要配置密钥密钥管理是落地过程中的头等大事。实际项目中遇到的需求往往比标准算法更复杂。比如要求密文格式不能被识别、要求自定义密钥协商逻辑。这时候就需要自定义加密器。ShardingJDBC的加密器是一个SPI扩展点实现相关接口重写encrypt和decrypt方法然后在META-INF/services里注册实现类就行了。我去年给一个政务项目做过一个国密SM4的自定义加密器思路完全一样改起来不复杂。3. 实战搭建一个可以跑的加密数据源光说不练假把式。这一章我直接给出一套能跑通的Minimal配置从依赖到建表到代码验证全部贴出来。3.1 环境准备与依赖引入我用的版本组合是ShardingSphere 5.4.1 Spring Boot 2.7.x MyBatis Plus 3.5.x MySQL 8.0JDK要求8以上。依赖只加一个starter即可dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.4.1/version /dependency注意如果你是老项目升过来的4.x的依赖是sharding-jdbc-spring-boot-starter包名和配置项都有变化别直接拿来用。3.2 加密规则配置逐行拆解下面是一份最简配置我加了注释说明每一段的作用spring: shardingsphere: datasource: names: ds0 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/test_db?useUnicodetruecharacterEncodingutf8 username: root password: root rules: - !ENCRYPT encryptors: aes_encryptor: type: AES props: aes-key-value: 123456abc tables: t_user: columns: password: cipherColumn: password_cipher encryptorName: aes_encryptor这里有三个关键点。第一encryptors下面定义了一个名为aes_encryptor的加密器type是AES密钥是123456abc。这个密钥在实际项目中绝对不能硬编码在yml里要从环境变量或者配置中心拉取并且要定期轮换。第二tables下面配置的是逻辑表名如果你同时配置了分库分表规则这里的表名必须和分片规则里的逻辑表名保持一致。第三columns下面配置的是逻辑列名cipherColumn指向真实的密文物理列。另外注意一点5.x里AES的密钥配置项是aes-key-value而4.x是aes.key.value中间横线和点号的差别非常容易踩坑。很多网上的老帖子贴的是4.x写法直接粘到5.x项目里启动就会报错。3.3 建表、实体、Mapper与效果验证配置好规则之后数据库建表要按密文列来建。我这里的t_user表是这样的CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password_cipher VARCHAR(256) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;实体类里需要注意映射关系。物理列是password_cipher业务字段是passwordMyBatis里可以通过resultMap做映射Data TableName(t_user) public class User { private Long id; private String username; TableField(password_cipher) private String password; }MyBatis Plus的TableField就能解决列名映射。如果是原生MyBatisresultMap里把propertypassword columnpassword_cipher即可。接下来写个简单的插入和查询测试User user new User(); user.setUsername(zhangsan); user.setPassword(123456); userMapper.insert(user); // 查询 User dbUser userMapper.selectById(1L); System.out.println(dbUser.getPassword()); // 这里打印的是解密后的明文123456跑一下就能看到效果。插入之后去数据库里查password_cipher列存的是一长串密文比如aESzjK2xV9fQ....而通过Mapper查出来的是明文的123456。说明透明加解密链路已经通了。这里我再提醒一个查询性能问题因为加密配置在SQL改写层生效查询的时候框架会自动把逻辑列改成密文列再在结果集里解密。所以selectById、条件查询这些操作对于开发同学来说是透明的但底层实际上是多了解密这一步的CPU开销高并发场景下要注意预留性能余量。3.4 和分库分表规则共存如果你的项目本来就配置了分库分表加密规则和分片规则是可以同时生效的。一个典型的配置是spring.shardingsphere.rules下面同时有!SHARDING和!ENCRYPT两个规则加密表配置在ENCRYPT里分片表配置在SHARDING里两者通过逻辑表名关联。执行SQL时ShardingJDBC会先做分片路由、再做加密改写顺序不会冲突。有一点需要留意加密列尽量不要作为分片键。原因很简单分片路由需要根据字段值计算目标分片但写入时这个字段已经变成密文了同一个明文每次加密结果可能还不同AES-CBC模式下相同输入加密结果也不一样这样分片就完全失去意义了。如果业务非要按这个字段分片建议在明文入表前先在应用侧算好分片键存到单独的分片列里别直接拿加密列参与路由。4. 落地中的边界问题与排查实录4.1 存量明文数据的平滑迁移方案配置好加密规则之后新写入的数据都是密文但历史数据还是明文躺在数据库里。这是所有加密改造项目都绕不开的问题。我推荐的方案是“双写 灰度切换”一共三步第一步保持queryWithCipherColumn为false同时在配置里给字段加上plainColumn和cipherColumn。这样查询优先走明文列业务无感知写入时会同时写明文和密文两列。第二步写一个批量刷数脚本把历史明文逐行加密回写到密文列。注意批量刷数要在业务低峰期执行并且要分批commit避免大事务锁表。第三步确认密文列数据完整之后把queryWithCipherColumn切回true观察一段时间确认查询逻辑正常。之后再把plainColumn从配置里移除最后把数据库里的明文列drop掉。我实际做迁移的时候在第二步踩过一个坑刷数脚本同时更新明文列和密文列但忘了在刷数期间暂停写入业务导致边刷边写最后密文列和明文列数据对不上。后来加了一个简单的版本号字段做并发控制才解决。所以做迁移前一定得评估好业务的写入窗口。4.2 密文模糊查询一个绕不开的坎这是加密改造里被问得最多的问题。需求方经常说“我要按照手机号模糊查询”但密文在数据库里是一串毫无规律可言的乱码直接SQL拼LIKE是绝对匹配不到的。哪怕两个手机号只有最后一位不同整条加密结果都可能完全不同。怎么破我实际操作下来有三个可选思路。第一个是用assistedQueryColumn辅助查询列。这个列的语义是“为了检索而冗余的派生列”在配置里加上之后框架写入时会同时维护这个派生列。你可以在自定义加密器里让派生列生成一个适合检索的值比如手机号脱敏后保留前3后4的明文片段。这样等值查询、模糊查询都走辅助列需要展示完整明文时再解密cipherColumn。第二个是“索引表方案”。单独建一张映射表明文片段与数据主键做关联查询时先查索引表拿主键再回主表查密文。这种方式适合查询模式比较固定的场景缺点是要维护数据一致性。第三个是“内存解密过滤”。数据量小的时候可用先按其他条件查出候选集在应用内存里解密后再做模糊过滤。数据量一大基本不可行慎用。本质上这三个方案的思路上有一个共识密文本身不可检索必须在加密设计阶段就给“需要检索的字段”预留一条可检索的旁路。最怕的是上线之后才想起来要模糊查询那只能手动补数据非常痛苦。4.3 常见报错与排错速查表下面这些是我在实际使用中遇到过的、以及在各技术社群里高频出现的报错我整理成了速查表现象可能原因解决办法插入后数据库里还是明文表名/列名没匹配上加密配置检查yml里的逻辑表名、逻辑列名和SQL里的实际名称是否一致查询结果全是nullMyBatis没映射密文物理列resultMap里把column指向password_cipherproperty指向password启动报错找不到EncryptorencryptorName拼写错误或自定义加密器SPI注册失败检查name和SPI文件路径配置了AES但启动失败提示Invalid key密钥长度不符合AES要求或用的是4.x的配置项AES密钥长度选16/24/32字节确认是aes-key-value同时用分片加密数据路由到错误分片加密列被当作分片键分片键单独建列不要指向加密列如果遇到以上情况没有覆盖到的问题优先做一件事打开ShardingJDBC的SQL日志看实际执行的SQL长什么样。框架最终执行的SQL会清晰地展示列名改写和参数替换结果看到真实SQL基本就能定位到是配置问题还是代码问题。4.4 性能与安全层面的几条补充建议性能方面AES加解密的开销在绝大多数业务场景下可以忽略真正影响性能的是密文导致的索引失效。加密列上建的索引基本等于没用因为每个值都是随机的选择性很差。该加密的字段做好心理预期能用等值查询用等值查询能用辅助列用辅助列别指望数据库索引帮你加速密文扫描。安全方面我特别想强调密钥管理。ShardingJDBC允许你在配置里写死key但那只是演示。生产环境至少要满足三个要求密钥不落盘到配置文件、密钥定期轮换、轮换时有平滑机制。我见过有人把密钥写在代码常量里上线结果代码仓库泄露整个加密形同虚设。另外一个容易被忽视的点是日志很多框架会自动打印SQL参数如果日志级别开成DEBUG明文参数会直接打到日志文件里这等于前门锁了后门开着。上线前务必检查日志脱敏配置。最后再分享一个小技巧。自定义加密器实现里AES算法的Key对象是可以复用的不需要每次加解密都从原始字符串重建一遍密钥。我写过一版没做缓存的自定义加密器压测的时候发现加解密逻辑占了接口耗时的一大截改成缓存Key之后性能提升非常明显。这类细节官方文档里不会写得实际压过才体会得到。用ShardingJDBC做数据加密我的总体感受是配置门槛不高复杂在于方案设计。字段怎么选、密钥怎么管、模糊查询怎么绕、存量数据怎么迁这些问题考虑清楚了落地就是一天的事考虑不清楚上线之后就是踩不完的坑。希望这篇文章能帮你把该避的坑提前避掉。