Spring Boot 防御式编程:防重复提交 + 缓存 + 配置加密 + AOP
背景与价值
“防御式编程"是指预设攻击/异常场景并提前拦截的编程范式。在 Spring Boot 项目中,常见的防御场景:
- 防重复提交:用户网络卡顿 / 双击按钮导致的同一请求多次执行
- 缓存雪崩 / 穿透 / 击穿:恶意请求打垮数据库
- 配置泄露:明文密码、API Key 出现在
application.yml 中,被提交到 Git - 横切关注点:日志、鉴权、限流等重复代码抽离
本文按"问题 → 方案 → 代码"完整路径,给出生产可复用的 4 套方案。
一、防重复提交(Redis 分布式锁 + AOP)
1. 场景
用户点击"提交订单"按钮,由于:
- 网络延迟用户多次点击
- 浏览器自动重试
- 客户端脚本 bug
导致同一笔订单被重复创建(库存被扣两次、支付两次)。
2. 方案
利用Redis SETNX 原子性 + AOP 切片,把"防重"逻辑与业务逻辑解耦。
3. 依赖
1
2
3
4
5
6
7
8
| <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
|
4. Redis 配置
1
2
3
4
5
6
7
| spring:
redis:
database: 0
host: {{REDIS_HOST}}
port: 6379
timeout: 5000
password: {{REDIS_PASSWORD}}
|
5. 自定义注解
1
2
3
4
5
6
7
8
| @Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/** 锁定时间(秒) */
int time() default 1;
}
|
6. 切片(核心)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| @Aspect
@Component
@Slf4j
public class NoRepeatSubmitAspect {
private static final String JWT_TOKEN_KEY = "Authorization";
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Pointcut("@annotation(com.example.aop.RepeatSubmit)")
public void pointcut() {}
@Around("pointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
// 1. 获取 token + URL 作为唯一 key
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader(JWT_TOKEN_KEY);
if (!StringUtils.hasText(token)) {
token = request.getSession().getId();
}
String key = "repeat_submit:" + DigestUtils.md5Hex(token + "-" + request.getRequestURL());
// 2. Redis 原子 SETNX,锁定 N 秒
Boolean locked = redisTemplate.opsForValue().setIfAbsent(key, "1", 1, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 3. 执行业务
return pjp.proceed();
} else {
throw new BusinessException("操作过于频繁,请稍后再试");
}
}
}
|
7. 业务使用
1
2
3
4
5
| @PostMapping("/order/create")
@RepeatSubmit(time = 3) // 3 秒内同一用户同一接口只允许一次
public Result<Order> createOrder(@RequestBody OrderDTO dto) {
return orderService.create(dto);
}
|
二、缓存雪崩 / 穿透 / 击穿 防御
1. 二级缓存架构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| ┌──────────────┐
│ Application │
└──────┬───────┘
│ 1. 查本地缓存(Caffeine)
▼
┌──────────────┐
│ Caffeine │ L1(本地,纳秒级)
└──────┬───────┘
│ 2. miss 后查 Redis
▼
┌──────────────┐
│ Redis │ L2(分布式,毫秒级)
└──────┬───────┘
│ 3. miss 后查 DB
▼
┌──────────────┐
│ Database │ L3(毫秒-秒级)
└──────────────┘
|
2. 依赖
1
2
3
4
5
| <dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.2.0</version>
</dependency>
|
3. Caffeine 一级缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @Configuration
public class CaffeineConfig {
@Bean
public Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5)) // 写入 5 分钟后过期
.refreshAfterWrite(Duration.ofMinutes(1)) // 写入 1 分钟后被动刷新
.recordStats()
.build();
}
}
|
4. 缓存击穿:分布式锁 + sync 模式
1
2
3
4
| @Cacheable(value = "user", key = "#id", sync = true) // sync=true 防止击穿
public User getById(Long id) {
return userRepository.findById(id).orElse(null);
}
|
sync=true 让 Spring Cache 在缓存 miss 时只允许一个线程查 DB,其他线程阻塞等待。
5. 缓存穿透:空值缓存 + 布隆过滤器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| public User getById(Long id) {
// 1. 查 L1
User user = (User) caffeineCache.getIfPresent("user:" + id);
if (user != null) return user;
// 2. 查 L2
user = (User) redisTemplate.opsForValue().get("user:" + id);
if (user != null) {
caffeineCache.put("user:" + id, user);
return user;
}
// 3. 查 DB
user = userRepository.findById(id).orElse(null);
// 4. 即使是 null 也缓存(防穿透)
redisTemplate.opsForValue().set("user:" + id, user == null ? "" : JSON.toJSONString(user), 60, TimeUnit.SECONDS);
caffeineCache.put("user:" + id, user);
return user;
}
|
6. 缓存雪崩:分散过期时间
1
2
3
4
| // 给过期时间加随机值(±10%)
int baseExpire = 300; // 5 分钟
int randomExpire = baseExpire + ThreadLocalRandom.current().nextInt(-30, 30);
redisTemplate.opsForValue().set(key, value, randomExpire, TimeUnit.SECONDS);
|
7. 缓存更新:Caffeine refreshAfterWrite + Redis Canal 订阅 MySQL binlog
1
| MySQL update → binlog → Canal → Kafka → 业务消费 → 更新 Redis
|
refreshAfterWrite 让 Caffeine 被动刷新(不删除,下次访问时异步加载新值),保证旧值不会长时间存在。
三、配置加密(Jasypt)
1. 场景
application.yml 中经常有明文敏感信息:
1
2
3
| spring:
datasource:
password: {{DB_PASSWORD}} # ❌ 提交到 Git = 泄露
|
2. 依赖
1
2
3
4
5
6
7
8
9
10
| <dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<plugin>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-maven-plugin</artifactId>
<version>3.0.5</version>
</plugin>
|
3. 命令行加密
1
2
3
4
5
6
7
8
9
10
11
12
13
| # 下载 jasypt JAR
# https://repo1.maven.org/maven2/org/jasypt/jasypt/1.9.3/jasypt-1.9.3.jar
# 加密(输入明文,输出 ENC() 包裹的密文)
java -cp jasypt-1.9.3.jar \
org.jasypt.intf.cli.JasyptPBEStringEncryptionCLI \
input="MyPlainPassword" \
password="MySecretKey" \
algorithm=PBEWithMD5AndDES
# 输出:
# ----OUTPUT----------------------
# 2ADo+VDsxwyhFO+X6LSUZ2/mS4bbIbcS
|
4. application.yml 使用
1
2
3
| spring:
datasource:
password: ENC(2ADo+VDsxwyhFO+X6LSUZ2/mS4bbIbcS) # ✅ 密文安全提交
|
5. 启动时指定密钥
1
2
3
4
5
6
| # 方式 1:环境变量(推荐)
export JASYPT_ENCRYPTOR_PASSWORD=MySecretKey
java -jar myapp.jar
# 方式 2:命令行参数
java -Djasypt.encryptor.password=MySecretKey -jar myapp.jar
|
生产关键:密钥 JASYPT_ENCRYPTOR_PASSWORD 绝不能写进 application.yml,必须通过环境变量 / K8s Secret / Vault 注入。
6. 非对称加密(更安全)
1
2
3
4
5
6
7
8
| # 生成私钥(输入密码保护)
openssl genrsa -des3 -out privkey.pem 2048
# 提取公钥
openssl rsa -in privkey.pem -outform PEM -pubout -out public.pem
# 转私钥为 PKCS#8 格式(Java 可读)
openssl pkcs8 -topk8 -inform PEM -in privkey.pem -outform pem -nocrypt -out pkcs8.pem
|
1
2
3
4
| # 用公钥加密
mvn jasypt:encrypt \
-Djasypt.encryptor.public-key-format=pem \
-Djasypt.encryptor.public-key-location=file:src/main/resources/jasypt/public.pem
|
非对称优势:开发 / 测试环境持公钥即可加密,私钥只在生产部署服务器(运维保管),开发无法反推。
四、AOP 实战
1. 依赖
1
2
3
4
| <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
|
2. 5 种通知类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| @Aspect
@Component
public class LogAspect {
// 1. @Before:方法前
@Before("execution(* com.example.service..*(..))")
public void beforeLog(JoinPoint jp) {
log.info("调用前: {}", jp.getSignature());
}
// 2. @After:方法后(无论成功失败)
@After("execution(* com.example.service..*(..))")
public void afterLog(JoinPoint jp) {
log.info("调用后: {}", jp.getSignature());
}
// 3. @AfterReturning:方法成功返回
@AfterReturning(value = "execution(* com.example.service..*(..))", returning = "result")
public void returnLog(JoinPoint jp, Object result) {
log.info("返回: {} -> {}", jp.getSignature(), result);
}
// 4. @AfterThrowing:方法抛异常
@AfterThrowing(value = "execution(* com.example.service..*(..))", throwing = "e")
public void throwLog(JoinPoint jp, Exception e) {
log.error("异常: {} -> {}", jp.getSignature(), e.getMessage());
}
// 5. @Around:环绕(最强大,可控 proceed() 时机)
@Around("execution(* com.example.service..*(..))")
public Object aroundLog(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed();
log.info("耗时: {} ms - {}", System.currentTimeMillis() - start, pjp.getSignature());
return result;
} catch (Throwable t) {
log.error("异常耗时: {} ms", System.currentTimeMillis() - start);
throw t;
}
}
}
|
3. 切入点表达式
| 表达式 | 含义 |
|---|
execution(* com.example.service..*(..)) | service 包下所有方法 |
execution(public * *(..)) | 所有 public 方法 |
@annotation(com.example.aop.Log) | 被 @Log 注解的方法 |
within(com.example.service..*) | service 包下所有类的所有方法 |
args(String, ..) | 第一个参数是 String 的方法 |
4. Spring 缓存集成(@Cacheable 原理)
@Cacheable 本质是 AOP 实现的:
1
2
3
4
5
6
7
8
9
10
11
| // 简化原理
@Around("@annotation(org.springframework.cache.annotation.Cacheable)")
public Object cacheable(ProceedingJoinPoint pjp) {
String key = spel(pjp, "key");
Object cached = cache.get(key);
if (cached != null) return cached;
Object result = pjp.proceed();
cache.put(key, result);
return result;
}
|
理解了 AOP 原理,@Cacheable / @RepeatSubmit / @RateLimit 等注解都能自己写。
前置知识与下一步
前置:
- Spring Boot 基础
- Redis 基础(SETNX 原子性)
- AOP 概念(切面 / 切入点 / 通知)
下一步:
- 接口幂等性:用 Token 机制 + Redis 实现表单防重
- 限流:用 Guava RateLimiter + Redis 滑动窗口
- 链路追踪:用 Spring AOP + Sleuth 注入 traceId 到日志
小结
防御式编程的 4 大支柱:
- 防重复提交:Redis SETNX + AOP 注解,5 行代码解决
- 二级缓存:Caffeine(L1)+ Redis(L2)+ DB(L3),Caffeine
sync=true 防击穿 - 配置加密:Jasypt
ENC() 密文 + 环境变量注入密钥 - AOP 切片:5 种通知(Before/After/AfterReturning/AfterThrowing/Around)抽离横切关注点
核心原则:把"防御"逻辑从业务代码中抽离,用注解 + AOP 复用,业务方只需要在方法上加 @RepeatSubmit(time=3) 一行注解即可享受防重保护。
2024+ 视角:Resilience4j + Spring Boot 3.x + 云原生配置中心
Resilience4j 替代 Hystrix 已是事实标准
Hystrix 2018 年停更,Resilience4j 2.x 是 Spring Boot 3.x 官方推荐:
1
2
3
4
5
6
7
8
9
| <dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-reactor</artifactId> <!-- WebFlux -->
</dependency>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # application.yml
resilience4j:
circuitbreaker:
instances:
orderService:
sliding-window-type: COUNT_BASED
sliding-window-size: 100
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
retry:
instances:
orderService:
max-attempts: 3
wait-duration: 1s
exponential-backoff-multiplier: 2
ratelimiter:
instances:
orderService:
limit-refresh-period: 1s
limit-for-period: 100
bulkhead:
instances:
orderService:
max-concurrent-calls: 50
max-wait-duration: 0
|
防重复提交的 2024+ 升级
setIfAbsent 在 Redis 7.0+ 已是 @Deprecated——改用 set(key, value, SetArgs.Builder.nx().ex(seconds)):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @Around("pointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
String key = "repeat_submit:" + DigestUtils.md5Hex(token + "-" + request.getRequestURL());
// ✅ 2024+ 推荐写法
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofSeconds(3));
// 或更精细:
redisTemplate.execute(connection -> connection.stringCommands().set(
key.getBytes(), "1".getBytes(),
Expiration.from(3, TimeUnit.SECONDS),
SetOption.SET_IF_ABSENT
));
...
}
|
或者直接用 Redisson(RLock / RSemaphore)做更高级的分布式锁:
1
2
3
4
5
6
7
8
9
10
11
12
| @Around("pointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
RLock lock = redissonClient.getLock("submit:" + key);
if (!lock.tryLock(0, 3, TimeUnit.SECONDS)) {
throw new BusinessException("操作过于频繁");
}
try {
return pjp.proceed();
} finally {
lock.unlock();
}
}
|
配置加密的 2024+ 新选择
Jasypt 2023 起进入维护模式(社区 fork 持续),Spring Boot 3.x 有更现代的方案:
方案 1:HashiCorp Vault(企业首选)
1
2
| @Value("${vault://secret/myapp/db.password}")
private String dbPassword;
|
1
2
3
4
5
6
| # bootstrap.yml
spring.cloud.vault:
host: vault.example.com
authentication: KUBERNETES
kv:
enabled: true
|
方案 2:Spring Cloud Alibaba Nacos 加密配置
1
2
3
4
| # Nacos 控制台直接编辑密文
spring:
datasource:
password: ENC(2ADo+VDsxwyhFO+X6LSUZ2/mS4bbIbcS)
|
方案 3:Jasypt(继续用,3.0.5+ 兼容 JDK 17/21)
1
2
3
4
5
| <dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
|
二级缓存的 2024+ 演进
Caffeine 3.x 仍是 L1 首选,但 2024+ 多了几个变化:
1
2
3
4
5
| <dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version> <!-- 3.x JDK 17 兼容 -->
</dependency>
|
refreshAfterWrite 性能优化:异步刷新不再阻塞读- W-TinyLFU 算法:命中率比 LRU 高 5-10%
- 内存统计:
recordStats() 输出 hit rate / miss rate / load time
L2 缓存新选择:
- Redis 7.x(2022+)内置
FUNCTION(Lua 替代品)+ 多线程 IO - Dragonfly(2022+ 出现)—— Redis 兼容 + 多线程,单实例可达百万 QPS
- KeyDB(2024 仍维护)—— Redis 兼容 + 多线程
- Apache Kvrocks(2024 活跃)—— Redis 兼容,基于 RocksDB,适合大数据量
限流的 2024+ 新方案
Sentinel 1.8.8+ 仍是 Java 生态首选,新增:
- 集群限流:Token Server 集群分配配额
- 网关层流控:与 Spring Cloud Gateway 深度集成
- 热点参数限流:相同参数值的请求单独限流
1
2
3
4
5
6
| @SentinelResource(value = "orderQuery",
blockHandler = "blockHandlerMethod",
fallback = "fallbackMethod")
public Order queryOrder(String orderId) {
return orderService.findById(orderId);
}
|
链路追踪 2024+
Sleuth 已废弃,Spring Boot 3.x 推荐 Micrometer Tracing + OpenTelemetry:
1
2
3
4
5
6
7
8
| <dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
|
1
2
3
4
5
6
| management:
tracing:
sampling.probability: 0.1 # 10% 采样
otlp:
tracing:
endpoint: http://jaeger:4318/v1/traces
|
2024+ 防御式编程的"完整清单”
| 防御点 | 2024+ 推荐方案 |
|---|
| 防重复提交 | Redisson RLock + AOP |
| 接口幂等 | Token 机制 + Redis SETNX |
| 熔断 | Resilience4j 2.x |
| 限流 | Sentinel 1.8.8+ / Resilience4j |
| 缓存雪崩/穿透/击穿 | Caffeine 3.x + Redis 7.x + 空值缓存 |
| 配置加密 | HashiCorp Vault / Nacos 加密 / Jasypt |
| 链路追踪 | Micrometer Tracing + OpenTelemetry |
| 全链路灰度 | OpenTelemetry + Sentinel 灰度规则 |
| 流量染色 | Spring Cloud Gateway + 自定义 Header |
| 异常监控 | Sentry / 阿里云 ARMS / SkyWalking 9.x |
实战坑(2024+)
- Jasypt 3.0.5+ 与 Spring Boot 3.x有兼容问题,加
jasypt.encryptor.algorithm=PBEWithHMACSHA512AndAES_256 - Resilience4j 注解 + Sentinel 注解不能混用——AOP 拦截顺序冲突
- Redisson 3.17+ 的看门狗自动续期默认 30s,老项目升级要重新评估锁超时
- OpenTelemetry Collector 部署后必须先在本地用
otel-cli 验证数据再到生产