1. 为什么要给数据库字段上锁:从一次数据泄露说起的方案选型
大概一年多前,我参与的一个项目在例行安全审计时被查出了问题:用户手机号、身份证号在数据库里全是明文。审核方直接指出了风险,说如果数据库备份文件泄露,或者DBA账号被拖库,用户隐私数据就是裸奔状态。当时团队的第一反应是改数据库权限、加白名单,但仔细想想,这些都是"外围防守"——真正的命门在于数据本身以明文落盘,一旦存储介质失守,任何权限控制都救不回来。
那段时间我搜了很多资料,除了常规的脱敏和权限控制外,真正能解决"落盘即明文"问题的手段就是加密。但项目里几十张业务表、上百个字段,不可能全部加密——加密范围越大,性能损耗越明显,查询条件也越受限。需求梳理下来其实很清晰:只需要对少量敏感字段做加密,比如手机号、身份证、邮箱、银行卡号。这个粒度就叫字段级加密。
1.1 字段级加密适合哪些场景
字段级加密不是银弹,它有明确的适用边界。我归纳为三个特征:敏感度高、总量可控、查询条件简单。
- 敏感度高:指字段一旦泄露就属于数据安全事故,比如个人隐私、支付信息、商业机密。
- 总量可控:不是说只有几条数据才能加密,而是说加密字段占整体字段的比例小。如果几乎每个字段都要加密,那说明你需要的可能是表级或库级加密方案,而不是字段级。
- 查询条件简单:这是最关键的限制。加密后的字段不具备普通索引能力,也无法走普通的范围查询和LIKE模糊匹配。如果业务上频繁需要按手机号精确查、按邮箱模糊查,那么字段级加密就要配合额外的检索方案来设计。
拿我们这个项目来说,手机号是登录名,登录时拿着手机号去库里查,属于等值查询;身份证号一般只做展示和核验,偶尔按完整证件号精确查。这两个场景用字段加密改造的代价是可控的。如果业务里需要按手机号后四位找人,那就要换一套思路了,比如单独生成检索列。
1.2 常见实现路线对比:应用层AOP、MyBatis TypeHandler、数据库字段加密插件
确定要做字段级加密后,摆在面前的实现路线有三条,我逐一做了分析对比。
| 实现方式 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| 应用层AOP切面 | 在Service或Mapper层做拦截,方法入参加密、出参解密 | 不限定ORM框架,代码侵入点集中 | 必须严格约定切入点,容易漏掉地方;如果绕开Service直接用Mapper,切面不生效 |
| MyBatis TypeHandler | 利用MyBatis的TypeHandler扩展点,在JDBC读写时自动加解密 | 和MyBatis无缝集成,对Service层完全透明,实体类字段类型可以保持String不变 | 只对MyBatis体系生效;需要处理特殊SQL场景(如LIKE、Group By) |
| 数据库插件/代理 | 通过数据库中间件或插件在SQL层面透明加解密 | 对应用代码完全无侵入 | 引入额外的运维组件,配置复杂,部分商业方案价格高;有些插件会劫持SQL,排查问题成本大 |
从投入产出比来看,这类SpringBoot项目绝大多数都会选用MyBatis,而MyBatis本身就提供了TypeHandler扩展机制,让我们能在PreparedStatement赋值和ResultSet取值两个节点上做文章,天然就是做字段加解密的位置。
1.3 为什么我最终选择MyBatis TypeHandler
选择TypeHandler还有其他几个现实原因。
第一,实体类字段类型不用动。手机号在库里存的是加密后的密文VARCHAR(128),但Java实体里仍然是String,密码学对业务代码是透明的。做安全改造时,最怕的就是把业务代码翻个底朝天。
第二,规则收敛在一个类里。把加密、解密的逻辑全部塞进一个TypeHandler,团队成员只需要知道"这个字段标了注解,会自动加密",不需要关心底层算法细节。
第三,测试成本低。TypeHandler可以脱离Spring容器做单元测试,写一段JDBC模拟代码就能验证加解密的正确性。
这些优势叠加起来,让我最终确定走TypeHandler这条路。后面我就用实际代码把整个实现链路的每个环节拆开讲。
提示:MyBatis的TypeHandler并不是只服务于加密场景,它本身是个通用机制。任何"Java类型和JDBC类型不一致,需要转换"的场景都用得上,比如枚举转整数、JSON字符串互转。理解它的通用性,能帮你降低使用门槛。
2. 核心原理拆解:TypeHandler在MyBatis执行链路中到底干了什么
想用TypeHandler用得顺手,不能光会写代码,得先明白它在整个MyBatis执行链路里处于什么位置。这一节我尽量用大白话讲透。
2.1 MyBatis读写一条数据的生命周期
MyBatis执行一条SQL,大体可以分为两个方向:
- 写方向(参数绑定):应用代码调用Mapper接口方法,传入实体对象或参数,MyBatis将参数值填入PreparedStatement的占位符,再交给JDBC执行。在这一步,如果参数的类型和数据库字段类型不匹配,就需要TypeHandler出手转换。
- 读方向(结果集映射):数据库返回ResultSet后,MyBatis遍历每一行结果,把JDBC类型值转换成Java对象的属性值,再封装成实体类返回。同样,这一步也需要TypeHandler把数据库里的密文还原成明文。
TypeHandler的工作时机恰好就是这两个节点。你可以把它理解成一个双向翻译器:Java值进库之前,翻译成密文;数据库值出库之后,翻译回明文。加密算法被封装在这个翻译器内部,对MyBatis来说,它只是调用了"setParameter"和"getResult"两个方法而已。
2.2 加解密的执行流程细节
我们想实现的是"实体字段保持明文,数据库落盘是密文",那流程就更具体了:
- 业务代码里,用户注册时输入手机号
13800138000,set进User实体的phone字段。 - Mapper接口调用
insert(user)时,MyBatis碰到phone字段上标注的@EncryptField注解,找到对应的EncryptTypeHandler。 setParameter方法内调用加密工具类,把13800138000加密成U2FsdGVkX1/...这串密文,然后set进PreparedStatement。- 数据库里存储的是密文,即使DBA直接查表,看到的也是一串看不懂的字符。
- 查询时,ResultSet把密文取出来,交给EncryptTypeHandler的
getResult方法解密还原成13800138000,最后由MyBatis映射进实体的phone字段。
这个过程对Service层和Controller层完全透明,开发人员写代码时感知不到加密的存在,使用体验和数据加密前几乎一致。
2.3 自研TypeHandler类的设计骨架
实现字段级加密的TypeHandler,核心类只有两个层次:
public class EncryptTypeHandler extends BaseTypeHandler<String> { @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { // 参数绑定阶段:明文加密后传入 ps.setString(i, encrypt(parameter)); } @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { // 结果集映射阶段:按列名取密文并解密 String columnValue = rs.getString(columnName); return decrypt(columnValue); } @Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String columnValue = rs.getString(columnIndex); return decrypt(columnValue); } @Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String columnValue = cs.getString(columnIndex); return decrypt(columnValue); } }实际开发中我不会让TypeHandler直接持有加密算法,而是把算法封装成一个独立的CryptoUtil工具类,TypeHandler只做适配和转发。这样如果后续升级算法、切换密钥,只有工具类需要改动,TypeHandler的职责保持单一。
继承BaseTypeHandler<String>的原因,是因为我们这个场景Java类型和JDBC字段类型都是字符串,但需要中间的转换逻辑。基类里还有三个getNullableResult重载方法,分别对应"按列名取值""按下标取值""从存储过程出参取值",三种情况都必须实现,否则运行时可能报错。
3. 代码落地:从注解标记到TypeHandler注册的完整实现
这一节是全文的实操主干。我按"从下往上"的顺序搭:先写工具类,再写注解和TypeHandler,然后注册配置,最后在实体类和Mapper里使用。
3.1 依赖与基础配置
项目基于SpringBoot 2.x,MyBatis使用mybatis-spring-boot-starter。为了做单元测试和演示方便,我使用H2内存数据库,依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>MyBatis的配置中需要指定TypeHandler所在的包,SpringBoot会自动注册:
mybatis: type-handlers-package: com.example.demo.handler configuration: map-underscore-to-camel-case: truetype-handlers-package这个配置很关键,MyBatis会扫描该包下所有实现了TypeHandler接口的类,将其注册到类型处理器注册器中。如果忘了配置,即便注解写了也没用,MyBatis找不到对应的处理器。
3.2 实现加密工具类
加密算法我选用AES/GCM/NoPadding。为什么不用AES/ECB?因为ECB模式相同的明文会加密出相同的密文,这等于变相泄露了数据分布规律;GCM是带认证的加密模式,既能保证机密性又能校验密文完整性,是目前对称加密的主流推荐。这里为了演示,密钥我直接写死在配置里,真实项目必须走密钥管理服务,这在第5节会专门讲。
public class CryptoUtil { private static final String TRANSFORMATION = "AES/GCM/NoPadding"; private static final int GCM_TAG_LENGTH_BITS = 128; private static final int IV_LENGTH_BYTES = 12; public static String encrypt(String plainText, String base64Key) { if (plainText == null) { return null; } try { byte[] decodedKey = Base64.getDecoder().decode(base64Key); SecretKeySpec keySpec = new SecretKeySpec(decodedKey, "AES"); // 每个密文生成独立的随机IV,保证同一明文多次加密结果不同 byte[] iv = new byte[IV_LENGTH_BYTES]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 拼接IV和密文,统一用Base64编码 ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length); byteBuffer.put(iv); byteBuffer.put(cipherText); return Base64.getEncoder().encodeToString(byteBuffer.array()); } catch (Exception e) { throw new RuntimeException("字段加密失败", e); } } public static String decrypt(String cipherText, String base64Key) { if (cipherText == null) { return null; } try { byte[] decodedKey = Base64.getDecoder().decode(base64Key); byte[] decodedData = Base64.getDecoder().decode(cipherText); ByteBuffer byteBuffer = ByteBuffer.wrap(decodedData); byte[] iv = new byte[IV_LENGTH_BYTES]; byteBuffer.get(iv); byte[] encrypted = new byte[byteBuffer.remaining()]; byteBuffer.get(encrypted); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); byte[] plainBytes = cipher.doFinal(encrypted); return new String(plainBytes, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("字段解密失败", e); } } }注意几个设计细节:
- 每次加密都生成新的随机IV,这是GCM模式的标准做法,目的是让同一条手机号在不同时间加密出来的密文完全不同。密文里拼上IV,解密时先拆出IV再用它初始化Cipher。
- 解密失败要抛出异常而不是返回空串或null,否则一旦密钥配置错误、数据被篡改,系统会静默吞掉错误,后续业务拿到错误数据更麻烦。
- Base64编码是为了让密文可打印、可存储,数据库字段设计为VARCHAR(128)对多数场景够用。手机号加密后一般不超过80字符,身份证号也在百字符以内。
3.3 编写注解与TypeHandler
为了让业务开发人员的使用成本降到最低,我设计了一个@EncryptField注解,标记在需要加密的实体字段上:
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface EncryptField { }实际的TypeHandler通过反射读取字段上的注解,只有在标记了解密需求的字段才执行加密逻辑。这里有一个实现上的细节:TypeHandler本身并不知道自己正在处理哪个实体字段,所以我把"是否启用加密"的判断拿到了外层,在插入和查询时通过字段元数据告诉TypeHandler。一个更简便的做法是:不同类型的加密字段使用不同的TypeHandler别名,但这样类会爆炸。更推荐的方案是,在实体字段上用@Column指定typeHandler,再配合一个可开关的开关。
为了简洁和可用性均衡,我采取了一种折中设计:在实体字段上标注@EncryptField的同时,字段上通过@Column的typeHandler属性明确指向我们的EncryptTypeHandler,这样MyBatis在映射该字段时就会使用指定的Handler。TypeHandler内部加一个ThreadLocal或上下文开关来区分"当前字段是否需要加解密"。考虑到帖子的篇幅和团队接手成本,这里我给出最实用的"字段注解+指定typeHandler"方案:
@Data public class User { private Integer id; private String name; @EncryptField private String phone; @EncryptField private String idCard; }3.4 自定义TypeHandler的具体实现
@MappedTypes(String.class) @MappedJdbcTypes(JdbcType.VARCHAR) public class EncryptTypeHandler extends BaseTypeHandler<String> { @Value("${crypto.aes-key}") private String aesKey; @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, CryptoUtil.encrypt(parameter, aesKey)); } @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { String columnValue = rs.getString(columnName); return CryptoUtil.decrypt(columnValue, aesKey); } @Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String columnValue = rs.getString(columnIndex); return CryptoUtil.decrypt(columnValue, aesKey); } @Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String columnValue = cs.getString(columnIndex); return CryptoUtil.decrypt(columnValue, aesKey); } }这里有个SpringBoot时代的坑:TypeHandler被MyBatis容器实例化时,能不能通过@Value注入配置?实测是可以的,因为MyBatis的type-handlers-package扫描由Spring管理的情况下,TypeHandler会被当做一个Spring Bean来处理。如果你的MyBatis配置不走SpringBoot自动配置,而是完全手写SqlSessionFactoryBean,那么TypeHandler可能由MyBatis原生实例化,此时@Value不生效。最稳妥的做法是:在MybatisConfig里手动注册TypeHandler,并显式set进去,这样密钥注入完全可控。
3.5 在Mapper和MyBatis Config中的注册方式
直接在实体类上用@Column注解时,MyBatis的注解模式可以这样写明:
public interface UserMapper { @Insert("INSERT INTO user(name, phone, id_card) VALUES(#{name}, #{phone, typeHandler=com.example.demo.handler.EncryptTypeHandler}, #{idCard, typeHandler=com.example.demo.handler.EncryptTypeHandler})") int insert(User user); @Select("SELECT id, name, phone, id_card FROM user WHERE id = #{id}") @Results(id = "userMap", value = { @Result(column = "phone", property = "phone", typeHandler = EncryptTypeHandler.class), @Result(column = "id_card", property = "idCard", typeHandler = EncryptTypeHandler.class) }) User findByUserId(Integer id); }如果项目用XML文件写SQL,也是一样的道理:在resultMap的<result>节点上指定typeHandler,在insert语句的#{phone, typeHandler=...}里指定handler。两种方式本质上都在告诉MyBatis:"这个字段不要用默认的StringTypeHandler,用我指定的加密Handler"。
3.6 数据表结构设计建议
加密字段的存储类型必须预留足够空间。手机号明文11位,加密后根据算法不同长度会膨胀,AES/GCM模式Base64编码后通常到80~100字符。我把数据库字段统一设置为VARCHAR(128),能够覆盖身份证号、手机号、邮箱这些常见字段。如果你的业务涉及加密长文本,比如地址、备注,建议用TEXT类型。
这里给出建表MySQL示例:
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(128) NOT NULL COMMENT '手机号-密文存储', id_card VARCHAR(128) COMMENT '身份证号-密文存储', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段注释里明确说明"密文存储",这是为了降低后期运维维护的认知成本。否则DBA排查问题时看到一串乱码可能误以为是脏数据。
4. 实测中最大的三个坑:模糊查询失效、MyBatis缓存命中、事务回滚
代码写出来能跑只是第一步,真正上线被业务方拷打后,我发现这个方案有几个绕不开的"隐藏缺陷",必须提前设计兜底方案。
4.1 模糊查询失效:加密字段不能LIKE
这是字段级加密绕不开的第一大坑。用户需求里如果出现"按手机号后四位搜索会员""按姓名模糊搜客户",加密方案就彻底麻烦了。因为密文不是明文的可逆变换,WHERE phone LIKE '%138%'在密文上执行,结果完全是错的。
我当时的处理思路是把查询需求拆成两类:
- 等值查询:直接查加密列,但前提是使用固定的初始化向量。我在第3节特意强调了随机IV会让密文每次不同,这虽然提升了安全性,却让"加密列的等值查询"变成不可能——同一条手机号每次加密结果都不一样,数据库里存了多条密文,你怎么WHERE?所以,如果业务上有高频的等值查询需求(比如登录),就必须为这个字段额外存一列确定性加密值,或者用哈希索引列。
- 模糊查询:原则上加密字段禁止LIKE。如果业务必须支持,通常配合ES等搜索引擎,将解密后的明文同步到检索系统,查询时走检索系统,再回表取密文。
针对"等值查询+确定性加密"的诉求,我在项目里单独加了一列phone_hash,存储手机号的HMAC-SHA256哈希值,查询时先计算明文哈希再WHERE phone_hash = ?。这个方案牺牲了一点安全性(攻击者可做字典攻击),但换来了查询能力。如果对安全性要求更高,可以对哈希结果加盐。
public static String hashForSearch(String plainText, String salt) { try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec keySpec = new SecretKeySpec(salt.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); mac.init(keySpec); byte[] hashBytes = mac.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hashBytes); } catch (Exception e) { throw new RuntimeException("生成检索哈希失败", e); } }4.2 同一实体插入和查询结果不一致:解密时机问题
另一个坑比较隐蔽。实体类中phone字段既参与了写操作又参与读操作,但MyBatis对insert语句的返回值和查询结果处理路径不同。如果我在setParameter阶段把明文加密,而在getResult阶段又把密文解回明文,链路是通的。但业务上经常会有这样的代码:
User user = new User(); user.setPhone("13800138000"); userMapper.insert(user); // 此时user.getPhone()还是明文,没问题 User dbUser = userMapper.findByUserId(user.getId()); System.out.println(dbUser.getPhone()); // 期望输出明文findByUserId走resultMap映射时,如果@Result里忘了写typeHandler,那么MyBatis会用默认的StringTypeHandler把密文原样塞给phone,返回给业务的就是密文。所以查询语句的resultMap必须和插入语句的typeHandler配套匹配,任何一边漏掉都会导致"写入是加密的、读出是乱码"的诡异现象。
我的排查方法很笨但很有效:在所有Mapper查询语句里,统一用XML的<resultMap>集中定义,而不是散落在注解里。这样只要看到某个查询SQL没有引用加密resultMap,就能立刻发现遗漏。
4.3 索引和唯一约束:密文列不能建唯一索引
这是设计阶段最容易忽略的问题。原来明文手机号上可能有唯一索引保证不重复,但加密存储后,同一个手机号每次加密出来的密文都不一样(随机IV),无法对密文列直接加唯一索引。即使换成确定性加密,密文的唯一性也不代表明文的唯一性——理论上存在极小概率的碰撞,但这不是主要问题。
正确做法是借助哈希检索列来做唯一约束。比如在phone_hash上建唯一索引,插入前先查一下哈希列是否存在,如果存在则说明明文重复:
ALTER TABLE user ADD UNIQUE KEY uk_phone_hash (phone_hash);业务代码里insert之前先执行:
SELECT COUNT(*) FROM user WHERE phone_hash = #{phoneHash}这个方案需要接受一个代价:如果业务量极大,这条查重的SQL会成为热点,但只要不是秒杀级并发,完全扛得住。
4.4 缓存与事务边界:MyBatis二级缓存会缓存明文还是密文
MyBatis有二级缓存,实体对象会被序列化后缓存在内存或外部存储中。当二级缓存开启时,第一次查询走TypeHandler解密得到明文对象,这个对象被缓存了,第二次查询直接命中缓存,返回的还是明文对象——这没问题,因为缓存里存的是对象形态,不是SQL结果集的原始密文。
问题出在缓存的有效性和权限边界上。如果项目里数据权限区分严格,不同用户看到的数据范围不同,那么缓存明文数据有越权的风险。另外,如果缓存的实体类以明文形态在分布式缓存中传输,相当于把明文数据挪到了另一个存储介质里,安全边界又扩大了。所以我的建议是:有加密字段的表,谨慎开启二级缓存,或者直接禁用。一级缓存(SqlSession级别)影响不大,保持默认即可。
事务层面也有一个容易被忽视的点:同一个事务内先插入用户,再查询该用户,MyBatis一二级缓存可能都在,拿到的是事务前或缓存中的对象。如果此时打印user.getPhone(),看到的可能是明文,这个符合预期;但如果你绕过MyBatis,用JdbcTemplate直查数据库,拿到的是密文。我遇到过有同事在同一个事务里用两种数据访问方式查同一张表,结果两边数据对不上,排查半天才发现是加密字段的问题。所以引用加密表时,数据访问方式必须统一,不能今天走Mapper明天走JdbcTemplate。
5. 密钥管理、性能开销与进阶扩展
基础功能跑通后,剩下的就是工程化问题。这一节聊三个重点:密钥怎么管理、性能损耗多大、这套方案还能做成什么样子。
5.1 密钥管理:不要信手写进配置文件
网上很多demo为了省事,直接把AES密钥写死在application.yml里。我承认,为了快速演示这是最方便的,但生产环境这么干就是给自己挖坑。密钥一旦写入配置,所有能接触到配置文件、Git仓库、CI日志的人都能拿到密钥,加密等于摆设。
我在项目里实际采用的方案分了三层:
- 环境变量注入:启动时从环境变量读取
CRYPTO_AES_KEY,不在配置文件中明文出现。 - KMS/配置中心托管:使用云厂商的KMS服务或者自建的配置中心(比如Nacos、Apollo)存储密钥,应用启动时通过SDK拉取,运行期间定期轮转。KMS还能提供加密机级别的保护,即使应用被攻破,攻击者也拿不到密钥原文。
- 密钥轮转策略:不同的敏感字段使用不同的密钥版本,轮转时老数据用老密钥解密,新数据用新密钥加密,通过
version字段标记密文使用的密钥版本。这类设计虽然复杂,但对于金融、医疗等行业往往是刚需。
有个折中的低成本方案:把密钥拆成两部分,一部分放环境变量,一部分放配置文件,运行时拼接。这对中小团队来说增加了一点破解难度。但无论如何,密钥永远不应该进Git仓库。
5.2 性能影响:一次加解密的开销到底有多大
做这个技术方案时,最担心的是加解密导致接口性能明显下降。我专门写了压测脚本测了一组数据(测试机器配置为4核8G,Java 11,AES-GCM加解密纯内存操作):
| 场景 | 平均耗时(无加密) | 平均耗时(2个字段加解密) | 性能影响 |
|---|---|---|---|
| 单条插入 | 约2ms | 约2.3ms | 增加0.3ms左右 |
| 单条查询 | 约2ms | 约2.2ms | 增加0.2ms左右 |
| 批量插入100条 | 约35ms | 约40ms | 增加5ms左右 |
| 批量查询100条 | 约30ms | 约33ms | 增加3ms左右 |
结论很明确:对单次请求来说,加解密的CPU开销微乎其微,主要影响来自数据库通信、网络IO和SQL执行。真正影响性能的不是算法本身,而是前面说的查询能力丧失——不能用索引、不能走LIKE,导致查询计划变差。所以做性能评估时,不要盯着加解密耗时,要重点评估业务查询是否需要新增检索列或者引入外部索引。
5.3 进阶扩展:MyBatis拦截器、ShardingSphere与透明数据加密
做完整套TypeHandler方案后,我把市面上常用的扩展思路也梳理了一遍,给有更高需求的读者作为参考:
- MyBatis拦截器统一处理:如果你不想每个Mapper都写
typeHandler,可以写一个自定义Interceptor,在Executor执行SQL前拦截并改写SQL,自动为标记了@EncryptField的字段追加handler。这个方案更自动化,但SQL改写涉及字符串解析,复杂度和Bug率都会上升,适合字段数量较多的团队。 - ShardingSphere的加密功能:Apache ShardingSphere提供了数据加密模块,它可以在SQL解析层自动把明文SQL改写成密文SQL,对应用完全透明。如果你所在团队已经在用ShardingSphere做分库分表,可以直接启用它的加密能力,省去自己写TypeHandler的工作。缺点是多引入一个中间件,前期学习成本较高。
- 加密字段与其他组件组合时的注意事项:比如将加密字段输出到Elasticsearch、消息队列或者报表系统时,需要提前解密再投递,否则下游拿到密文无法使用。这条我吃过亏:当时有一个统计报表任务,直接从库里捞数据,结果全是密文,被业务方反馈"数据是乱的"。所以凡是数据流出应用边界,都要考虑在哪里解密。
说个我在实际操作中的体会:字段级加密的方案并不难,难的是把**"在哪里加密、在哪里解密、哪些数据能出网、哪些字段能查"**这些边界约束梳理清楚,并让团队所有人都遵守同一套约定。技术选型上,TypeHandler只是实现载体,真正核心的是你的数据分类意识和密钥管理能力。
最后分享一个我后来一直沿用的设计习惯:在公司的代码规范文档里,把所有需要加密的字段列成一个清单,标注字段所属业务域、加密算法、密钥版本、允许的查询方式(等值/禁止模糊/禁止排序)。这份清单在项目评审时帮了大忙,能提前拦住很多"这个字段要求模糊查"的需求。如果你也要做字段级加密,从第一天就把这份清单建起来,后面会省掉很多无休止的争论。