做了几年Java后端,最近在做一个涉及用户隐私数据的项目改造,需求说得非常直白:用户手机号、身份证号、银行卡号这些敏感字段,不能再明文落库。第一反应是自己写个加解密工具类,在Service层做一层转换,但真正动起手来,发现事情远没有这么简单——业务代码太多了,所有涉及这些字段的插入、更新、查询都要改,而且后续如果换算法、改需求,又得再来一轮大改,光是排查哪里漏了加密就够头疼的。
后来在项目里引入ShardingSphere的脱敏组件,用配置驱动的方式把“敏感字段加密”这件事从业务代码中剥离出去。这篇笔记就是基于这次实际改造过程整理的,包含版本选择、核心配置、存量数据迁移、读写链路验证,以及我踩过的几个坑。如果你也在做SpringBoot项目的数据脱敏,或者只是想了解ShardingSphere的数据加密功能怎么落地,这篇内容应该能帮你省不少时间。
1. 先说结论:ShardingSphere脱敏到底解决什么问题
1.1 手动加解密的三个典型痛点
很多项目一开始对敏感字段的处理方式,就是在代码里写个AES工具类,然后在Service层手动加密:
user.setPhone(AesUtil.encrypt(user.getPhone()));这种做法在数据量小、字段少的时候没啥问题,但项目一旦跑起来,痛点会集中爆发:
第一是散落。加解密的调用散落在各个Service和Mapper里,你永远不知道自己有没有漏掉某个入口。有一次我们项目里新增了一个用户查询接口,开发只查了密文列,忘了解密,前端拿到一串乱码,排查了半天才发现是漏了“查询后解密”这一步。
第二是不可控。假设哪天公司安全规范要求把AES的key换成新的,或者把AES换成国密SM4,你需要把所有调用点翻出来逐一修改,任何一个遗漏都会导致线上数据解不开,风险极大。
第三是耦合。加解密逻辑和业务逻辑混在一起,导致代码评审的时候很难说清楚“哪些字段做过安全处理”。特别是当你需要把加密逻辑单独交给安全团队管控时,这种散落的方式完全无法满足要求。
1.2 ShardingSphere的切入方式:在SQL解析层做文章
ShardingSphere处理脱敏的思路,和手动加解密完全不是一个路子。它把自己包装成了一个数据源代理,应用启动时配置的数据源实际上是ShardingSphere的代理数据源,业务代码发给数据库的SQL会先经过它的解析引擎。
这个链路大致是这样的:SQL进入解析引擎,ShardingSphere会根据配置的加密规则识别SQL中涉及加密逻辑列,然后把对逻辑列的操作改写为对密文列的操作。比如业务代码写的是INSERT INTO t_user (phone) VALUES ('13800000000'),中间件会自动改写成INSERT INTO t_user (phone_cipher) VALUES ('加密后的密文')。查询时反过来,SQL里查到的是密文列,返回结果经过解密后再映射成业务代码里的逻辑列。
关键点在于,业务代码和MyBatis的Mapper几乎不需要改动,你写的还是user.setPhone("13800000000"),界面展示的也还是明文,真正处理加密和解密的是中间件这一层。
1.3 脱敏方案横向对比:为什么这次选了它
| 方案 | 侵入性 | 算法可替换性 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|
| Service层手动加解密 | 高,每个调用点都要改 | 低,靠人肉排查 | 低 | 字段少、逻辑简单的小项目 |
| 数据库加密插件 | 中,需要改表结构或触发器 | 中,受限于数据库类型 | 高,备份和性能受影响 | 对数据库强依赖的单体系统 |
| 自定义MyBatis拦截器 | 中,需要自己维护解析逻辑 | 中,但扩展复杂 | 中 | 有一定开发能力,需要定制化处理 |
| ShardingSphere数据加密 | 低,配置驱动 | 高,改配置即可 | 中,依赖中间件 | 需要规范治理、字段较多的中大型项目 |
我这次的取舍逻辑很简单:项目里有二十多个涉及敏感字段的表,如果一个个手写加密,改造周期和测试成本都不可控。ShardingSphere的做法是把加解密这件事标准化,统一在一个配置层面治理,后续调算法、换key,动配置就行。
2. 环境搭建:版本选择的坑与依赖配置
2.1 版本选择:SpringBoot与ShardingSphere的配对
版本选择是这次踩的第一个坑。ShardingSphere的版本更新节奏比较快,API变动也大,选错了版本会出现“照着文档配但启动报错”的情况。
我最终选定的是SpringBoot 2.7.12加ShardingSphere 5.2.1。之所以不用最新版本,是因为5.4.x之后的版本对配置模型做了一些调整,网上能搜到的大多数案例还是基于5.1.x和5.2.x。如果你用的是SpringBoot 3.x,需要注意javax到jakarta的命名空间迁移,ShardingSphere的对应版本也要选对。
另外强调一点:不要用ShardingSphere 4.x的老版本,它在配置语法上和5.x差别很大,网上大量博客里的encryptRule配置在5.x里已经被rules.encrypt替代了,照着抄直接报“配置项不存在”的错误。
2.2 pom依赖:只需要一个starter
在SpringBoot项目中引入ShardingSphere-JDBC非常简单,不需要额外引入一大堆模块,核心依赖就一个:
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.2.1</version> </dependency>还需要配置好数据库驱动和MyBatis相关的依赖。如果项目原本用的是druid连接池,注意ShardingSphere的代理数据源配置里type要指定成实际使用的连接池类型,我这次用的HikariCP:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency>有一点需要特别提醒:ShardingSphere的starter会自动检测并接管数据源,所以项目里如果还有单独的spring.datasource配置,两者会冲突。引入ShardingSphere后,原来的数据源配置要么删掉,要么改成ShardingSphere的datasource配置格式。
2.3 启动前校验:确认数据源已经代理成功
依赖配好后,先别急着写加密规则,做一个最小化验证:配置一个最简单的数据源,启动项目,看日志里是否出现ShardingSphere的配置加载信息。
spring: shardingsphere: datasource: names: ds ds: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root props: sql-show: true启动后看到控制台输出ShardingSphere-JDBC ... started这类日志,并且日志里没有报“数据源初始化失败”的错误,说明代理数据源已经生效。如果没有输出或者直接连原库查数据,优先检查依赖版本是否冲突,特别是项目里是否同时引入了旧版的shardingsphere-core。
3. 规则配置拆解:加密规则的五要素与完整配置
3.1 逻辑列、密文列、辅助列:一张表说清楚
刚接触ShardingSphere加密功能时,最容易被logicColumn、cipherColumn、assistantQueryColumn这几个概念绕晕。我用一张用户表的实际例子说明:
| 概念 | 数据库真实字段 | 业务代码中的字段类型 | 作用 |
|---|---|---|---|
| 逻辑列 | 无,只是虚拟列名 | phone | 业务SQL和实体类中使用,代表明文 |
| 密文列 | phone_cipher | 无 | 数据库中实际存储加密后数据的列 |
| 辅助查询列 | phone_assistant | 无 | 存放用于模糊查询或匹配的辅助数据,例如明文后几位手机号 |
关键理解点在于:物理表里并没有phone这个字段,只有phone_cipher和有可能存在的phone_assistant。但你的实体类、SQL、MyBatis的Mapper文件里使用的都是phone。ShardingSphere在中间层做转换,插入时把业务代码里的phone明文转成密文写入phone_cipher,查询时把phone的明文转成密文后去匹配phone_cipher,查出来再解密还原给实体类的phone属性。
3.2 application.yml完整配置:照着抄就能跑
下面是我在项目中实际使用的配置,以t_user表的手机号字段为例:
spring: shardingsphere: datasource: names: ds ds: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: root rules: encrypt: tables: t_user: columns: phone: cipherColumn: phone_cipher encryptorName: aes_encryptor id_card: cipherColumn: id_card_cipher encryptorName: aes_encryptor encryptors: aes_encryptor: type: AES props: aes-key-value: 3e78f2c9a1b5d4e6f8a0b1c2d3e4f5a6 props: sql-show: true注意配置里的columns下面每个键就是逻辑列名,cipherColumn指定它在数据库里对应的密文列,encryptorName引用具体的加密器。encryptors节点定义加密器实例,type为AES,aes-key-value就是加密密钥,这个key需要妥善保管,不要提交到代码仓库。
配置完成后,t_user表结构应该是这样的:
CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) DEFAULT NULL, `phone_cipher` varchar(128) DEFAULT NULL, `id_card_cipher` varchar(128) DEFAULT NULL, PRIMARY KEY (`id`) );可以看到表里没有phone和id_card字段,只有加密后的存储列。
3.3 加密器选型:AES和MD5怎么选
ShardingSphere内置了多种加密器,最常用的是AES和MD5,这两种的适用场景完全不同。
AES是可逆加密,加密后的数据可以解密还原成原文,所以适合手机号、身份证号、银行卡号这些需要展示给用户看的字段。比如用户个人中心需要显示手机号,数据库里存的是密文,查出来通过中间件解密后再返回给前端,整个过程对业务代码透明。
MD5是不可逆的哈希算法,加密后无法还原。有些场景根本不需要还原明文,只需要保证同一个明文加密后的结果一致,这时候用MD5更合适。比如登录账号、邮箱做唯一判断,只需要等值匹配,不需要展示原文。使用MD5还能避免密钥泄露后历史数据被批量解密的严重风险。
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| 手机号、身份证号、银行卡号、姓名 | AES | 需要回显原文 |
| 登录账号、邮箱、密保问题 | MD5 | 只做匹配,不需要还原 |
| 地址、备注等较长自由文本 | AES | 业务上可能需要完整展示 |
4. 存量数据迁移的实操记录
4.1 迁移的整体思路:双写过渡还是停机切换
对于已经跑着的线上系统,原来表里存的是明文,改造后表结构要变成密文列,那存量数据怎么处理就是第一个要解决的问题。
我这次项目采用了停服迁移加双写校验的方式。步骤大致是:先把代码发布到生产环境,但暂时不开启所有表的加密规则,只保留规则配置中的部分测试表;写一个迁移脚本,把原表中的明文数据加密后写入密文列;在业务低峰期切换数据库表结构,删除或屏蔽明文列;最后开启完整的加密规则,全链路验证。
如果你对系统可用性要求很高,不能接受停机,那可以考虑双写过渡方案:新建密文列,保留明文列,配置中使用辅助查询列。方案的成本在于需要维护两份数据的一致性和校验脚本,耗时更长,但对业务几乎无损。
4.2 迁移脚本怎么写:确保加密结果与中间件一致
存量迁移最大的坑,是你自己写出来的AES加密结果,和ShardingSphere解密时期待的数据格式不一致。我建议迁移程序中不要重复造轮子,直接使用ShardingSphere自带的AES加密器,通过Spring容器获取并调用它的encrypt方法。
我这里给出一个基于Spring Boot CommandLineRunner的迁移示例:
@Component public class DataMigrateRunner implements CommandLineRunner { @Autowired private UserMapper userMapper; @Override public void run(String... args) throws Exception { // 迁移开关,避免每次启动都执行 if (!"true".equals(environments.getProperty("migrate.enabled"))) { return; } List<User> users = userMapper.findAllWithPlainData(); for (User user : users) { // 通过ShardingSphere的加密API生成密文 String phoneCipher = encryptData(user.getPhone()); user.setPhoneCipher(phoneCipher); userMapper.updatePhoneCipher(user); } } private String encryptData(String plainText) throws Exception { // 这里直接复用项目中配置的AES算法,保持一致 return ShardingSphereCryptoUtils.encrypt(plainText); } }在实际项目中,我建议直接在迁移脚本里调用与ShardingSphere相同算法实现的工具方法,然后先用少量样例对比:把线上几条数据的明文通过自己的脚本加密,和已经在跑的项目配置加密后的结果比对,完全相等再全量执行。
4.3 字符集和表字段长度的坑
这次迁移我踩了个非常隐蔽的坑:密文列字段长度不够。MySQL的varchar类型,有些人为了省空间设置成了50,AES加密后的密文长度会随着原文长度变化,一个手机号11位,加密后通常在32到64个字符之间,如果配合CBC模式还会带上随机IV,长度可能超过64。
我建议所有密文列都统一使用varchar(255),避免以后换算法、加长密钥时长度不够。另外注意排序规则,推荐utf8mb4,避免加密后的二进制数据因为字符集转换导致乱码或特殊字符被截断。
5. 业务代码改造:读写链路的完整演示
5.1 插入与更新:业务代码无感知
配置好加密规则后,业务代码基本不需要感知加密逻辑。写一个标准的Service插入逻辑:
@Service public class UserServiceImpl implements UserService { @Resource private UserMapper userMapper; @Override public void addUser(User user) { user.setPhone("13800000000"); userMapper.insert(user); } }执行这条插入语句时,ShardingSphere会在SQL解析阶段把SQL改写为实际数据库需要执行的语句。你写的SQL是:
INSERT INTO t_user (name, phone) VALUES (?, ?)中间件把逻辑列phone改写为密文列phone_cipher,参数也通过AES加密后绑定:
INSERT INTO t_user (name, phone_cipher) VALUES (?, ?)控制台开着sql-show: true的话,可以看到真实执行的SQL和参数。这一步能直观验证加密是否生效。
更新操作与此类似。你可以通过极少数代码改动完成敏感字段的写入保护,这也是这个方案最大的价值所在。
5.2 等值查询:用逻辑列查询,中间件自动改写
查询时,业务写的SQL依然是逻辑列:
SELECT * FROM t_user WHERE phone = '13800000000'ShardingSphere会做两件事:把SQL中的查询条件phone = '明文'改写成phone_cipher = '密文',把查询列phone改写为phone_cipher,执行查询后将密文解密后映射回逻辑列,MyBatis自动将结果封装到实体类的phone属性中。
所以这一层对开发非常友好。你写的代码和之前完全一致,不需要为了查询专门去调一个解密工具方法。
5.3 最容易忽略的一环:返回值的JSON序列化
写完了正常的读写链路,还有一个细节容易被忽略:DTO返回给前端时,字段序列化出来的到底是不是解密后的明文。
如果项目里在某些POJO的属性上直接用了自定义的加密注解,或者自行在getter里做了加密处理,会和ShardingSphere的解密链路产生冲突,导致存进去的是正常密文,查出来却被二次处理成乱码。
我在项目中排查过这种问题,最后的结论是:不要在加密字段上叠加自定义的加解密AOP逻辑,ShardingSphere已经负责了加解密,其它逻辑不做特殊处理。实体类中直接用原生的String phone属性即可,返回值正常序列化:
{ "name": "张三", "phone": "13800000000" }这一点非常关键,如果序列化出来的是密文或者乱码,优先检查是不是有自定义的加密注解在作怪,而不是怀疑ShardingSphere配置错了。
6. 常见问题排查与避坑速查表
6.1 规则不生效:先看SQL解析日志
加密规则配置好之后,最怕的就是你以为生效了,实际没生效。判断规则是否生效最直接的办法,就是打开sql-show: true,查看控制台打印的真实SQL。
如果真实SQL里还是phone,而不是phone_cipher,请按这个顺序排查:
- 表名是否匹配。规则里写的表名必须和SQL中的表名完全一致,包括大小写。MySQL在Linux下对表名大小写敏感,而Windows下不敏感,很容易出现本地正常、Linux服务器上规则不生效的情况。
- 逻辑列的名字是否和Mapper中SQL写的列名完全一致。比如配置里写的
phone,SQL里写的是userPhone并通过别名映射,规则就不会生效。 - 是否引入了正确的starter。如果项目里还残留着旧版的shardingsphere-jdbc-core,而不是新版shardingsphere-jdbc-core-spring-boot-starter,自动配置可能没有加载。
6.2 模糊查询、排序、分组:加密字段的三个禁区
常见的业务需求里,后端经常需要按手机号后四位做模糊查询,或者在列表页按手机号排序。但加密字段在这三种SQL场景下都会出现严重问题。
模糊查询:WHERE phone LIKE '%1234%'。因为数据库里存的是密文,密文的某几位和明文的后四位不存在任何规律,这条SQL永远查不出正确结果。解决思路是引入辅助查询列,单独存储明文的后几位,比如手机号后四位,用于支持有限的模糊匹配。如果模糊需求复杂,建议走ElasticSearch等搜索引擎,把需要对外的可搜索字段单独索引。
排序:ORDER BY phone。排序是基于密文排序的,密文的字典序和明文的字典序完全无关,所以排出来得到的结果毫无意义。遇到这种需求,要么在应用层解密后分页排序,要么在设计阶段就避免对加密字段直接排序。
分组:GROUP BY phone。同理,分组依据是密文,同一个明文加密后如果使用了随机IV,则每次密文不同,分组结果会非常混乱。
| 场景 | 加密字段的表现 | 可行替代方案 |
|---|---|---|
| LIKE模糊查询 | 无法匹配密文 | 辅助查询列、搜索引擎 |
| ORDER BY排序 | 按密文排序,结果乱序 | 业务侧解密后再排序 |
| GROUP BY分组 | 分组依据错误 | 避免直接分组,加冗余分组短列 |
6.3 性能开销:加解密对接口耗时的影响
引入ShardingSphere之后,每个涉及加密字段的操作都会额外产生加解密计算和SQL解析的开销。实测下来,单条数据的增删改查耗时从几百微秒增加到一毫秒左右,整体影响不大,QPS高的时候需要关注的是解析引擎对SQL吞吐的影响。
我的优化建议是:非必要的字段不要全部加密,敏感字段有取舍地加密;加密操作尽量集中在低频场景,比如登录和查询用户信息,高频的不需要展示明文的数据考虑使用MD5哈希做匹配。更细的性能优化可以考虑引入本地缓存把解密后的热点数据缓存起来,避免每次都重复解密。
6.4 一个容易踩的深坑:数据源配置的重复与冲突
引入ShardingSphere后,如果项目里原来的application.yml还保留着spring.datasource配置,启动时控制台会提示数据源已经被代码生成,实际数据源是ShardingSphere创建的代理数据源,而不是你手动配置的那个。这会导致一些诡异的问题,比如多环境配置切换失效、密码加解密失效。
建议在引入ShardingSphere之后,把原有的spring.datasource配置块整个移除,所有数据源配置统一放在spring.shardingsphere.datasource节点下。如果你有多个环境,比如dev、test、prod,把spring.shardingsphere.datasource放在环境对应的profile文件里维护。
6.5 加密数据无法在数据库层面做唯一性校验
加密列如果作为唯一索引,理论上AES加密的确定性输出可以保证同一明文产生同一个密文,所以可以建唯一索引。但MD5加密的哈希值存在碰撞概率,唯一索引的约束有失效可能。身份证号和手机号这类字段如果之前有唯一性需求,建议改造成应用层先查重再插入,避免直接在密文上建立唯一索引。
这个点我是在一次测试中发现的,当时测试环境里插入了两个不同的身份证号,密文列上的唯一索引没有报错,排查后发现是哈希碰撞的问题。虽然概率极低,但在金融等强一致场景里,数据库唯一索引依然是最后的防线,不要轻易依赖加密后的密文做唯一约束。
最后再分享一个实际经验
这次改造做了大概两周,最大的体会是:数据加密这件事的复杂度不会消失,只会转移。手写加解密是把复杂度摊到每个业务开发身上,中间件方案则是把所有复杂度收拢到配置层和中间件层。要驾驭它,就必须真正理解逻辑列、密文列、加密器这几个概念,还得提前想清楚存量数据怎么迁、查询需求怎么降级。
如果你所在的团队也准备做敏感字段的脱敏,建议先选一个小表试点,跑通整条链路后再推广到全部表。用sql-show: true观察真实SQL是排查问题的万能钥匙,几乎百分之八十的“规则没生效”都能靠看日志快速定位。希望这篇实践笔记能帮你少踩几个坑。