Featured image of post Spring Boot 防御式编程:防重复提交 + 缓存 + 配置加密 + AOP

Spring Boot 防御式编程:防重复提交 + 缓存 + 配置加密 + AOP

@RepeatSubmit 注解 + Redis 分布式锁 + Caffeine+Redis 二级缓存 + Jasypt 配置加密 + AOP 切片

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 大支柱:

  1. 防重复提交:Redis SETNX + AOP 注解,5 行代码解决
  2. 二级缓存:Caffeine(L1)+ Redis(L2)+ DB(L3),Caffeine sync=true 防击穿
  3. 配置加密:Jasypt ENC() 密文 + 环境变量注入密钥
  4. 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
    ));
    ...
}

或者直接用 RedissonRLock / 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 验证数据再到生产
使用 Hugo 构建
主题 StackJimmy 设计