1. 项目概述与核心价值
最近在做一个金融相关的项目,涉及到用户身份证号、手机号这类敏感信息的存储。合规要求摆在那里,明文存数据库是绝对的红线。一开始考虑在业务代码里每个insert、update和select的地方手动调用加解密工具类,但很快就发现这活儿太糙了。一来代码侵入性太强,满屏都是加解密逻辑,核心业务逻辑被淹没;二来容易遗漏,哪天新加个查询忘了处理,就是个隐患;三来维护起来也头疼,哪天加密算法要升级,得把所有调用的地方翻个底朝天。
这时候,自然就想到了Spring AOP(面向切面编程)。它的核心思想不就是把那些横跨多个模块的公共功能(比如日志、事务、安全)抽取出来,形成一个独立的“切面”吗?加解密,本质上就是一种横切关注点,完美契合AOP的应用场景。我的思路是:在数据进入DAO层之前,通过切面自动对实体对象中的敏感字段进行加密;在数据从DAO层返回之后,再自动解密还原。这样,业务开发人员几乎可以无感知地操作明文数据,而底层存储的永远是密文。
这个方案的价值非常直接:在几乎零业务代码侵入的前提下,实现数据存储层的透明加解密,兼顾开发效率与系统安全。它特别适合处理存量系统的安全改造,或者在新系统中提前布防。对于Java后端开发者,尤其是使用Spring Boot和MyBatis/MyBatis-Plus或Spring Data JPA的团队,这是一个能直接提升项目安全水位和代码质量的实用技巧。
2. 整体方案设计与技术选型
2.1 核心架构思路
整个方案的核心是围绕MyBatis(或JPA)的Mapper接口方法执行过程进行拦截。我们不去动SQL本身,而是拦截方法传入的参数(即将写入数据库的实体对象)和方法的返回结果(即从数据库查出的实体对象或集合)。
- 写入(加密)切面:拦截Mapper的
insert、update等方法。在方法执行前(@Before或@Around),对传入的实体对象进行扫描,识别出标注了特定注解(如@EncryptedField)的字段,并使用配置好的加密算法对其进行加密,将明文替换为密文。之后,再执行原始的SQL操作。 - 读取(解密)切面:拦截Mapper的
select、get等方法。在方法执行后(@AfterReturning或@Around),对返回的结果对象(或集合中的每个对象)进行扫描,识别出@EncryptedField注解的字段,并使用对应的解密算法进行解密,将密文还原为明文,再返回给业务层。
这样,对于业务代码来说,它操作的一直是包含明文数据的Java对象。加解密的脏活累活,全部由切面在背后默默完成。
2.2 关键技术组件选型
- Spring AOP:方案的基础。我们使用Spring的代理机制来创建切面。考虑到需要对方法参数和返回值进行修改,
@Around注解结合ProceedingJoinPoint是最灵活的选择。 - 加解密算法:这是安全的核心。选型需要权衡安全强度、性能和对数据库查询的影响。
- AES(高级加密标准):首选推荐。它是一种对称加密算法,加解密速度快,安全性高。通常使用AES-256-GCM模式,该模式不仅提供机密性,还提供完整性校验(通过认证标签),能有效防止密文被篡改。密钥管理是关键,必须妥善保管。
- 国密SM4:在国内一些对算法有明确要求的场景下使用。它也是对称加密,性能与AES相当,是国家密码管理局认定的商用密码算法。
- 为什么不用RSA?RSA是非对称加密,性能远低于对称加密,不适合对大量数据进行字段级的实时加解密。它通常用于加密传输对称密钥(如HTTPS),而非直接加密业务数据。
- 字段标记方案:为了让切面知道哪些字段需要处理,我们需要一个标记。自定义注解(如
@EncryptedField)是最清晰、侵入性最小的方式。注解可以携带一些元数据,比如标识使用哪种加密算法(如果系统内有多套算法)。 - 持久层框架:本方案理论上适用于任何ORM框架,但实现细节因框架而异。本文将以最流行的MyBatis/MyBatis-Plus和Spring Data JPA为例进行阐述,因为它们与Spring的集成方式不同,切面切入点也会有所区别。
注意:算法与密钥管理。绝对不要将加密密钥硬编码在代码中或提交到版本库。推荐使用环境变量、配置中心(如Nacos、Apollo)或专业的密钥管理服务(KMS)来注入密钥。在开发、测试、生产环境使用不同的密钥。
2.3 潜在挑战与应对
- 模糊查询:这是字段加密后最大的挑战。例如,对加密后的手机号进行
LIKE ‘%138%’查询是无效的。解决方案通常有:- 放弃模糊查询:在需求评审时说明,涉及加密字段的查询必须精确匹配。
- 脱敏查询:建立单独的、脱敏的查询字段(如手机号后4位),用于支持模糊查询。
- 可信执行环境(TEE)或同态加密:技术复杂,成本高,一般用于极端敏感场景。
- 性能损耗:加解密是CPU密集型操作,频繁调用会有性能开销。需要进行压测,评估在业务峰值下是否可接受。通常对于非高频的核心实体,开销可以忽略。
- 类型处理:加解密操作针对的是字段的
String值。但实体中的字段可能是其他类型(如Long类型的身份证号?实际上应存为String)。确保注解只用于String类型字段,并在加解密时做好类型转换和空值判断。
3. 核心实现步骤详解
下面,我们以Spring Boot + MyBatis-Plus + AES-256-GCM算法为例,拆解实现步骤。
3.1 第一步:准备加密工具类
首先,我们需要一个健壮、线程安全的加密工具类。这里使用JDK自带的Cipher类实现AES-GCM。
import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; @Component public class AesGcmUtil { private static final String ALGORITHM = "AES/GCM/NoPadding"; private static final int TAG_LENGTH_BIT = 128; // GCM认证标签长度 private static final int IV_LENGTH_BYTE = 12; // 推荐GCM的IV长度为12字节 @Value("${system.encrypt.aes-key}") // 从配置读取Base64编码的密钥 private String base64Key; private SecretKey secretKey; @PostConstruct public void init() throws Exception { byte[] decodedKey = Base64.getDecoder().decode(base64Key); this.secretKey = new javax.crypto.spec.SecretKeySpec(decodedKey, "AES"); } /** * AES-GCM 加密 * @param plaintext 明文 * @return Base64编码的字符串,格式为:IV + 密文 + Tag (已合并) */ public String encrypt(String plaintext) throws Exception { if (plaintext == null || plaintext.isEmpty()) { return plaintext; } Cipher cipher = Cipher.getInstance(ALGORITHM); byte[] iv = new byte[IV_LENGTH_BYTE]; SecureRandom random = new SecureRandom(); random.nextBytes(iv); // 生成随机IV GCMParameterSpec parameterSpec = new GCMParameterSpec(TAG_LENGTH_BIT, iv); cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec); byte[] cipherText = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // 将IV和密文(已包含Tag)拼接,然后Base64编码 byte[] combined = new byte[iv.length + cipherText.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length); return Base64.getEncoder().encodeToString(combined); } /** * AES-GCM 解密 * @param ciphertext Base64编码的字符串(IV+密文+Tag) * @return 明文 */ public String decrypt(String ciphertext) throws Exception { if (ciphertext == null || ciphertext.isEmpty()) { return ciphertext; } byte[] combined = Base64.getDecoder().decode(ciphertext); if (combined.length < IV_LENGTH_BYTE) { throw new IllegalArgumentException("Invalid ciphertext"); } byte[] iv = new byte[IV_LENGTH_BYTE]; System.arraycopy(combined, 0, iv, 0, IV_LENGTH_BYTE); byte[] encryptedData = new byte[combined.length - IV_LENGTH_BYTE]; System.arraycopy(combined, IV_LENGTH_BYTE, encryptedData, 0, encryptedData.length); Cipher cipher = Cipher.getInstance(ALGORITHM); GCMParameterSpec parameterSpec = new GCMParameterSpec(TAG_LENGTH_BIT, iv); cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec); byte[] plaintextBytes = cipher.doFinal(encryptedData); return new String(plaintextBytes, StandardCharsets.UTF_8); } }实操心得:IV(初始化向量)的处理。GCM模式要求每次加密使用不同的IV,且IV不需要保密,但绝不能重复使用同一个IV和密钥组合。我们将IV和密文一起存储和传输。解密时,先从密文中提取出IV。这种方式是业界标准做法,比固定IV安全得多。
3.2 第二步:定义字段注解
创建一个自定义注解,用于标记需要加密的实体字段。
import java.lang.annotation.*; /** * 标记实体类中需要加密存储的字段 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface EncryptedField { /** * 加密算法类型,可用于未来扩展 */ String algorithm() default "AES-GCM"; }3.3 第三步:实现核心切面逻辑
这是最核心的部分。我们将创建一个切面,拦截MyBatis Mapper的执行。
import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import java.lang.reflect.Field; import java.util.*; @Aspect @Component @Slf4j @RequiredArgsConstructor public class EncryptionAspect { private final AesGcmUtil aesGcmUtil; /** * 切入点:拦截所有Mapper接口中,执行参数包含@EncryptedField实体对象的方法。 * 这里使用 @annotation(org.apache.ibatis.annotations.Mapper) 可能不够精确, * 更通用的做法是指定Mapper所在的包路径。 */ @Pointcut("execution(* com.yourproject.mapper..*.*(..))") public void mapperPointcut() {} @Around("mapperPointcut()") public Object aroundMapperMethod(ProceedingJoinPoint joinPoint) throws Throwable { String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); // 1. 方法执行前:加密参数 encryptArgs(args); // 2. 执行原方法 Object result = joinPoint.proceed(args); // 3. 方法执行后:解密返回值 result = decryptResult(result); return result; } /** * 加密方法参数 */ private void encryptArgs(Object[] args) { if (args == null) { return; } for (Object arg : args) { if (arg != null && isEntityClass(arg.getClass())) { processEntity(arg, true); // true 表示加密 } // 如果需要,也可以处理参数是集合(如List<Entity>)的情况 } } /** * 解密方法返回值 */ private Object decryptResult(Object result) { if (result == null) { return null; } if (result instanceof Collection) { Collection<?> collection = (Collection<?>) result; if (!CollectionUtils.isEmpty(collection)) { // 假设集合内元素类型一致,取第一个判断 Object first = collection.iterator().next(); if (isEntityClass(first.getClass())) { collection.forEach(item -> processEntity(item, false)); // false 表示解密 } } } else if (isEntityClass(result.getClass())) { processEntity(result, false); // 解密单个实体 } // 其他类型(如Page、Wrapper)需要根据具体结构递归处理,此处省略 return result; } /** * 判断一个类是否是我们的实体类(简单通过包名判断,可根据项目规范调整) */ private boolean isEntityClass(Class<?> clazz) { return clazz.getPackage() != null && clazz.getPackage().getName().contains(".entity"); } /** * 处理单个实体对象的加密或解密 * @param entity 实体对象 * @param isEncrypt true-加密, false-解密 */ private void processEntity(Object entity, boolean isEncrypt) { Class<?> clazz = entity.getClass(); // 获取当前类及其所有父类(不包括Object)的字段 while (clazz != null && !clazz.equals(Object.class)) { Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { if (field.isAnnotationPresent(EncryptedField.class)) { field.setAccessible(true); try { Object value = field.get(entity); if (value instanceof String) { String strValue = (String) value; if (!strValue.isEmpty()) { String processedValue; if (isEncrypt) { processedValue = aesGcmUtil.encrypt(strValue); log.debug("字段 [{}] 加密完成", field.getName()); } else { // 尝试解密,如果解密失败(可能本来就是明文或格式错误),则原样返回 try { processedValue = aesGcmUtil.decrypt(strValue); log.debug("字段 [{}] 解密完成", field.getName()); } catch (Exception e) { log.warn("字段 [{}] 解密失败,将返回原始值。可能该值未被加密或已损坏。", field.getName(), e); processedValue = strValue; } } field.set(entity, processedValue); } } else { log.warn("字段 [{}] 被 @EncryptedField 标记,但其类型不是 String,已跳过。", field.getName()); } } catch (IllegalAccessException e) { log.error("访问字段 [{}] 失败", field.getName(), e); } catch (Exception e) { log.error("处理字段 [{}] 加解密时发生异常", field.getName(), e); } } } clazz = clazz.getSuperclass(); // 处理父类字段 } } }3.4 第四步:应用注解到实体类
在需要加密的实体字段上加上我们定义的注解。
import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @Data @TableName("t_user") public class User { private Long id; private String username; @EncryptedField private String idCard; // 身份证号 @EncryptedField private String mobile; // 手机号 private String email; // ... getters and setters }3.5 第五步:配置与测试
- 配置密钥:在
application.yml中配置加密密钥(务必从安全渠道获取)。system: encrypt: aes-key: your-base64-encoded-256-bit-aes-key-here # 示例:通过 openssl rand -base64 32 生成 - 启用AOP:确保Spring Boot主应用类或配置类上开启了AOP支持(
@EnableAspectJAutoProxy),默认通常是开启的。 - 编写测试:
@SpringBootTest class UserMapperTest { @Autowired private UserMapper userMapper; @Test void testEncryptAndDecrypt() { User user = new User(); user.setUsername("张三"); user.setIdCard("110101199003077876"); user.setMobile("13800138000"); // 插入数据,切面会自动加密idCard和mobile字段 userMapper.insert(user); Long userId = user.getId(); // 查询数据,切面会自动解密 User dbUser = userMapper.selectById(userId); System.out.println(dbUser.getIdCard()); // 应输出明文:110101199003077876 System.out.println(dbUser.getMobile()); // 应输出明文:13800138000 // 可以直接用明文条件查询(前提是等值查询,且切面也处理了查询条件对象) // 注意:如果直接使用QueryWrapper的like,会因为字段被加密而查不到数据 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getMobile, "13800138000"); List<User> list = userMapper.selectList(wrapper); // 此时wrapper中的条件值'13800138000'需要被加密后才能匹配数据库密文。 // 这需要额外处理,详见下文“常见问题”部分。 } }
4. 针对不同持久层框架的适配要点
上面的例子基于MyBatis-Plus的Mapper接口。如果你的项目使用的是Spring Data JPA,核心思想不变,但切入点需要调整。
4.1 适配Spring Data JPA
JPA的Repository接口通常不直接暴露给AOP拦截,我们可以选择拦截JpaRepository的save和find相关方法,或者更底层地拦截EntityManager的持久化操作。这里提供一个更实用的思路:使用Hibernate的@PrePersist、@PreUpdate和@PostLoad生命周期回调注解。这种方式更直接,无需AOP。
- 在实体类中直接实现加解密逻辑:
@Entity @Data public class UserJpa { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; @Column(name = "id_card") private String idCard; // 数据库存储密文 private String mobile; // 数据库存储密文 @Transient // 不持久化到数据库的明文字段 private String idCardPlain; @Transient private String mobilePlain; // 持久化前(插入或更新):将明文加密后存入持久化字段 @PrePersist @PreUpdate public void encryptFields() { if (idCardPlain != null) { this.idCard = aesGcmUtil.encrypt(idCardPlain); } if (mobilePlain != null) { this.mobile = aesGcmUtil.encrypt(mobilePlain); } } // 加载后:将密文解密后存入透明字段供业务使用 @PostLoad public void decryptFields() { if (idCard != null) { this.idCardPlain = aesGcmUtil.decrypt(idCard); } if (mobile != null) { this.mobilePlain = aesGcmUtil.decrypt(mobile); } } // 业务代码操作 getter/setter 应针对 Plain 字段 public String getIdCard() { return idCardPlain; } public void setIdCard(String idCard) { this.idCardPlain = idCard; } // ... 其他 getter/setter }注意:这种方式需要将加解密工具类(如
AesGcmUtil)注入到实体中,可以通过@Configurable注解和AspectJ编译时织入实现,或者使用更简单的ApplicationContextAware来获取Bean。代码会稍显复杂,但避免了AOP的复杂性,且与JPA生命周期完美集成。
4.2 适配原生MyBatis
如果使用原生MyBatis,没有MyBatis-Plus的Mapper接口,我们的切面可以拦截SqlSession的特定方法,或者更常见的是,使用MyBatis的插件(Interceptor)机制。实现一个Interceptor,在Executor的update和query方法前后进行处理,原理与AOP类似,但更贴近MyBatis底层。
@Intercepts({ @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) @Component public class MybatisEncryptionInterceptor implements Interceptor { // ... 实现逻辑与Aspect类似,处理ParameterObject和ResultObject }这种方式功能强大,能拦截所有SQL操作,但需要对MyBatis内部API有一定了解。
5. 常见问题、排查技巧与进阶优化
5.1 模糊查询与等值查询处理
问题:如前述,加密后LIKE查询失效。对于等值查询(=),如果查询条件值是明文,也无法匹配数据库中的密文。
解决方案:
修改切面/拦截器,同时处理查询条件对象(Wrapper/Example)。在MyBatis-Plus中,可以解析
QueryWrapper中的条件,对涉及加密字段的等值条件值进行加密转换。// 在 encryptArgs 方法中增加对 QueryWrapper 的处理 private void encryptArgs(Object[] args) { for (Object arg : args) { if (arg instanceof QueryWrapper) { encryptQueryWrapper((QueryWrapper<?>) arg); } // ... 其他类型处理 } } private void encryptQueryWrapper(QueryWrapper<?> wrapper) { // 获取wrapper中的所有条件表达式 List<Object> conditions = wrapper.getExpression().getNormal(); // 简化示例,实际解析较复杂 // 遍历conditions,如果字段名是加密字段,且条件是等值,则加密其值 // 这是一个复杂点,可能需要反射获取实体类信息 }实操心得:完整解析
QueryWrapper并精准替换条件值非常复杂,容易出错。一个更务实的做法是:约定规范,要求开发者在构造涉及加密字段的查询条件时,手动调用加密工具类对条件值进行加密。虽然牺牲了一点透明性,但实现简单、可控。使用数据库函数(不推荐)。如果数据库支持(如MySQL的
AES_DECRYPT),可以在SQL中直接解密后比较。但这会将密钥暴露在SQL语句或数据库权限中,安全性大打折扣,且严重耦合数据库类型,性能也差。
5.2 加解密性能与缓存
问题:频繁加解密可能成为性能瓶颈。
优化方向:
- 算法层面:AES-GCM本身性能已很高。确保使用JDK的
Cipher并选择正确的Provider(如使用SunJCE)。 - 对象缓存:对于频繁访问的、不变的实体(如系统配置、用户基础信息),可以考虑在解密后,将明文对象放入本地缓存(如Caffeine),并设置合理的过期时间。下次查询时直接返回缓存,避免重复解密。
- 批量操作:在批量插入或更新时,切面会对每个对象的每个加密字段调用加密方法。确保加密工具类本身是线程安全且无状态的,避免成为瓶颈。
5.3 数据迁移与历史数据处理
问题:方案上线后,存量明文数据如何加密?
解决方案:
- 编写数据迁移脚本。这是最稳妥的方式。在业务低峰期,写一个单独的Java程序或SQL脚本(如果使用数据库函数),读取明文数据,调用应用层的加密逻辑进行加密,再写回数据库。务必做好备份和回滚方案。
- 双写与灰度:可以先让切面处于“只加密不解密”或“只解密不加密”的灰度模式,同时运行迁移脚本,确保数据一致性。
5.4 字段类型与空值处理
问题:字段不是String类型,或者值为null/空字符串。
处理技巧:
- 在
processEntity方法中,我们已经做了instanceof String的判断和空值判断。这是必须的。 - 对于非
String类型(如BigDecimal金额),原则上不应直接加密存储,因为会破坏其数值特性。如果必须加密,应先转换为String。更常见的做法是对这类字段进行脱敏显示,而非存储加密。 - 空字符串加密后可能不再是空字符串,这可能会影响一些业务逻辑(如
if(StringUtils.isEmpty(field)))。需要和业务方确认预期行为。
5.5 日志与监控
问题:如何排查加解密过程中的问题?
实操建议:
- 关键日志:在切面的
encryptArgs和decryptResult入口处,记录方法名和参数/结果类型。在processEntity中,为每个字段的加解密成功或失败记录DEBUG或WARN级别日志(如示例代码所示)。 - 监控指标:通过Spring Actuator或Micrometer,暴露加解密操作的计数器(成功、失败次数)和计时器(平均耗时)。这有助于发现性能问题和异常。
- 开关配置:在
application.yml中增加一个开关,如system.encrypt.enabled: true/false。在切面中读取该配置,当为false时,跳过所有加解密逻辑。这在紧急问题排查或数据修复时非常有用。
5.6 多算法支持与密钥轮转
进阶需求:
- 多算法:
@EncryptedField注解可以增加一个algorithm属性。在加密工具类中,根据该属性选择不同的加密器。密钥也需要按算法管理。 - 密钥轮转:为了安全,密钥需要定期更换。方案是:新数据用新密钥加密,旧数据用旧密钥解密。可以在密文中增加一个版本头(如
v1:encryptedData),解密时根据版本选择密钥。迁移旧数据到新密钥,需要另一个离线任务来完成。
整个实现过程,从设计到编码,再到问题排查,核心思想是在安全、性能和开发体验之间寻找平衡。透明加解密切面不是一个“银弹”,它引入了复杂性,但通过良好的设计和约定,它能极大地简化敏感数据处理的开发工作,是构建安全合规系统的有力工具。在实际项目中落地时,一定要充分测试,特别是边界情况、并发场景和与现有业务的兼容性。