Springboot

Redis 缓存一致性 + 分布式限流完整实战

2026-07-06 #java#Springboot#Redis#缓存

从”只删一次缓存的坑”到”延迟双删的正确姿势”,从”固定窗口的临界突刺”到”令牌桶的匀速填充”——本文把 Redis 缓存策略和限流算法的完整知识体系,用一个社区团购系统的真实代码从头到尾讲透。


第一章:为什么缓存一致性是个难题

1.1 先看”只删一次”的坑

最常见的缓存更新策略是 Cache Aside:先更新数据库,再删除缓存。但在高并发下有一个经典竞态条件:

1
2
3
4
时刻T1  线程A:更新数据库(price = 100 → 99)
时刻T2 线程B:读缓存 → miss → 读数据库(拿到新值 99)
时刻T3 线程A:删除缓存
时刻T4 线程B:将新值 99 写入缓存

看起来没问题?但把时间轴换一种顺序:

1
2
3
4
5
6
时刻T1  线程B:读缓存 → miss → 读数据库(拿到旧值 100)
时刻T2 线程A:更新数据库(price = 100 → 99)
时刻T3 线程A:删除缓存
时刻T4 线程B:将旧值 100 写入缓存 ← 脏数据!
缓存中 price = 100,数据库 price = 99
后续所有读请求都拿到 100,直到缓存过期

这就是”脏窗口”问题:在”线程B读数据库”和”线程B写缓存”之间,线程A完成了更新+删除,但线程B的旧数据最终还是写回了缓存。

1.2 延迟双删的解法

思路很直接:删两次

1
2
3
4
① 先更新数据库
② 立刻删缓存(第一次删)
③ 等一段时间(覆盖脏窗口)
④ 再删一次缓存(第二次删)

第二次删除会清掉脏窗口内被写回的旧数据。关键是等待时间要大于”一次读请求的耗时”(通常是数据库查询+网络往返,几百毫秒足够)。

1.3 其他缓存策略对比

策略 一致性 性能 复杂度 适用场景
先删缓存再更新DB 低并发读
先更新DB再删缓存 一般 中等并发
延迟双删 高并发读写
Canal 订阅 binlog 最好 强一致性要求
Write-Through 写多读少

延迟双删是”高一致性 + 适中复杂度”的甜点选择,适合大多数 Spring Boot 项目。


第二章:Redis 配置基础

2.1 RedisTemplate 序列化配置

在做任何缓存操作之前,先配置好 Redis 的序列化方式。如果用默认的 JDK 序列化,存入的对象在 Redis 中是乱码,而且体积大、不可读。

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
@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

// Value 用 Jackson 序列化(支持复杂对象),Key 用 String
Jackson2JsonRedisSerializer<Object> jacksonSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// ★ 保留类型信息,反序列化时能还原为原始对象
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jacksonSerializer.setObjectMapper(om);

StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jacksonSerializer);
template.setHashValueSerializer(jacksonSerializer);
template.afterPropertiesSet();
return template;
}
}

为什么用 activateDefaultTyping:不加这个配置,序列化一个 UserEntity 到 Redis,读回来会变成 LinkedHashMap。加上后,序列化时写入类型信息 ["com.entity.UserEntity", {...}],反序列化时自动还原为 UserEntity

2.2 Redis Key 命名规范

前缀 格式 用途 TTL
cache: cache:{module}:{id} 业务数据缓存 5分钟
token: token:{jwt} 用户Token会话 1小时(滑动续期)
rate_limit: rate_limit:{Controller:method}:{IP} 接口限流计数 时间窗口大小
lock: lock:{key} 分布式锁 10秒

命名原则:用冒号 : 分隔层级,Redis 可视化工具会自动按 : 分组折叠,管理方便。


第三章:延迟双删完整实现

3.1 CacheService 核心类

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@Service
public class CacheService {

private static final String CACHE_PREFIX = "cache:";
private static final long DEFAULT_EXPIRE_SECONDS = 300; // 默认5分钟

@Autowired
private RedisTemplate<String, Object> redisTemplate;

// ==================== 基本缓存操作 ====================

public void set(String key, Object value) {
redisTemplate.opsForValue().set(
CACHE_PREFIX + key, value, DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS);
}

public void set(String key, Object value, long seconds) {
redisTemplate.opsForValue().set(
CACHE_PREFIX + key, value, seconds, TimeUnit.SECONDS);
}

public Object get(String key) {
return redisTemplate.opsForValue().get(CACHE_PREFIX + key);
}

public void delete(String key) {
redisTemplate.delete(CACHE_PREFIX + key);
}

public boolean exists(String key) {
return Boolean.TRUE.equals(
redisTemplate.hasKey(CACHE_PREFIX + key));
}

// ==================== 延迟双删策略 ====================

/**
* 延迟双删 —— 便捷方法
*
* 用法:
* cacheService.doubleDelete("product:list", () -> {
* productService.updateById(entity);
* });
*
* 自动完成:更新DB → 删缓存 → 延迟500ms → 再删
*/
public void doubleDelete(String key, Runnable dbAction) {
// 1. 先更新数据库
dbAction.run();
// 2. 立刻删缓存
doubleDeleteFirst(key);
// 3. 异步延迟 500ms 后再删(覆盖脏窗口内被写回的旧数据)
doubleDeleteSecond(key);
}

/**
* 第一次删除(同步,更新DB后立刻执行)
*/
public void doubleDeleteFirst(String key) {
delete(key);
log.debug("双删策略 - 第一次删除缓存: {}", key);
}

/**
* 第二次删除(异步,延迟 500ms)
* ★ 必须用 @Async,否则会阻塞当前请求线程 500ms
*/
@Async
public void doubleDeleteSecond(String key) {
try {
Thread.sleep(500); // 延迟 500ms,等待脏窗口内的并发读完成
delete(key);
log.debug("双删策略 - 第二次删除缓存: {}", key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("双删策略 - 第二次删除被中断: {}", key, e);
}
}

// ==================== Hash 类型缓存 ====================

public void hSet(String key, String field, Object value) {
redisTemplate.opsForHash().put(CACHE_PREFIX + key, field, value);
}

public Object hGet(String key, String field) {
return redisTemplate.opsForHash().get(CACHE_PREFIX + key, field);
}

public void hDelete(String key, Object... fields) {
redisTemplate.opsForHash().delete(CACHE_PREFIX + key, fields);
}
}

3.2 @Async 生效的前提

@Async 注解不是加上了就能生效——它依赖 Spring 的异步代理。主启动类必须加 @EnableAsync

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootApplication(exclude = {
SecurityAutoConfiguration.class,
SecurityFilterAutoConfiguration.class
})
@MapperScan(basePackages = {"com.dao"})
@EnableAsync // ★ 关键!没有这个 @Async 不生效,第二次删除会同步阻塞 500ms
public class SpringbootSchemaApplication extends SpringBootServletInitializer {

public static void main(String[] args) {
SpringApplication.run(SpringbootSchemaApplication.class, args);
}

@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(SpringbootSchemaApplication.class);
}
}

如果忘记加 @EnableAsyncdoubleDeleteSecond 方法会在当前线程中同步执行,Thread.sleep(500) 会阻塞请求 500 毫秒。在高并发场景下,这 500ms 的阻塞会导致线程池迅速耗尽。

3.3 业务调用示例

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
@Service
public class ProductServiceImpl implements ProductService {

@Autowired
private CacheService cacheService;
@Autowired
private ProductMapper productMapper;

@Override
public void updateProduct(ProductEntity product) {
// ★ 延迟双删:自动完成 更新DB → 删缓存 → 延迟再删
cacheService.doubleDelete("product:" + product.getId(), () -> {
productMapper.updateById(product);
});
}

@Override
public ProductEntity getProduct(Long id) {
// 读缓存
ProductEntity cached = (ProductEntity) cacheService.get("product:" + id);
if (cached != null) {
return cached;
}
// 缓存miss → 读DB
ProductEntity product = productMapper.selectById(id);
if (product != null) {
cacheService.set("product:" + id, product);
}
return product;
}
}

函数式 API 的好处:调用方只需关注”做什么DB操作”,缓存一致性逻辑由 CacheService 统一处理,避免每个 Service 手写”更新+删+延迟+删”的重复代码。

3.4 微服务版的双删实现

在 Spring Cloud 2024 Demo 项目中,双删用了不同的实现方式——用 new Thread() 而非 @Async

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
@Service
@RequiredArgsConstructor
public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product>
implements ProductService {

private final StringRedisTemplate redisTemplate;

@Override
@Transactional(rollbackFor = Exception.class)
public R<Void> updateProduct(Product product) {
// Redis 双删策略:第一步 - 先删缓存
redisTemplate.delete("product:info:" + product.getId());

updateById(product); // 更新DB

// Redis 双删策略:第二步 - 延迟3秒再删
scheduleDelayedDelete("product:info:" + product.getId(), 3000);
return R.ok();
}

/**
* Redis 双删策略 - 延迟删除
* ★ 用 new Thread() 而非 @Async
*/
private void scheduleDelayedDelete(String key, long delayMs) {
new Thread(() -> {
try {
Thread.sleep(delayMs);
redisTemplate.delete(key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
}

new Thread() vs @Async 的区别

维度 @Async new Thread()
线程管理 Spring 线程池(可配置) 每次创建新线程
线程复用
异常处理 可配置 AsyncUncaughtExceptionHandler 需手动 try-catch
依赖 需要 @EnableAsync 无依赖
线程名 有规律(async-xxx) Thread-1, Thread-2…

推荐用 @Async——线程池管理更规范,不会因为高频更新创建大量线程。但 new Thread() 也有它的价值——不依赖 Spring 配置,在快速原型开发时更方便。


第四章:延迟时间的取舍

500ms 不是拍脑袋定的,要考虑多个因素:

因素 影响 典型值
数据库查询耗时 读请求从 miss 到写回缓存的耗时 1-10ms
网络往返 应用 → Redis → 应用 → DB → 应用 → Redis 1-50ms
GC 停顿 JVM GC 可能导致几百毫秒延迟 0-200ms
安全余量 取最大单次读耗时的 2-3 倍

经验值:大多数业务场景 500ms 足够。如果读操作涉及多表 JOIN 或外部调用,可调大到 1-2 秒。Spring Cloud Demo 项目用了 3 秒——更保守,但意味着脏数据存活时间更长。

代价:延迟删除期间,如果有正常读请求刚好写了新缓存,第二次删除会把正确数据也删掉。但不会造成数据不一致——下次读请求会重新从 DB 加载最新值写回缓存。只是多了一次缓存 miss。


第五章:Redis + Lua 原子限流

5.1 为什么用 Lua 脚本?

最简单的限流思路:INCR key 计数,超过阈值就拒绝。但 INCREXPIRE 是两条命令:

1
2
3
INCR rate_limit:login:192.168.1.1   # 计数 +1 → 返回 1
# ← 如果这里进程崩溃了,下面这条命令不会执行
EXPIRE rate_limit:login:192.168.1.1 60 # 设置过期时间

如果 INCR 之后进程崩溃,EXPIRE 没执行,这个 key 会永久存在——用户的 IP 被永久限流锁死。

Lua 脚本保证原子性INCREXPIRE 在一个脚本中执行,Redis 保证 Lua 脚本执行期间不会被其他命令打断。

5.2 限流注解定义

1
2
3
4
5
6
7
8
9
10
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
String key() default ""; // 限流key前缀,默认用方法全限定名
int count() default 5; // 时间窗口内最大请求次数
long period() default 60; // 时间窗口大小
TimeUnit unit() default TimeUnit.SECONDS; // 时间单位
String message() default "请求过于频繁,请稍后再试";
}

5.3 AOP 切面:Lua 脚本执行

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@Aspect
@Component
public class RateLimitAspect {

private static final String RATE_LIMIT_PREFIX = "rate_limit:";

/**
* Lua 脚本:INCR + EXPIRE 原子执行
*
* 解析:
* 1. INCR KEYS[1] → 计数+1,返回当前值
* 2. if current == 1 → 首次请求,设置过期时间
* 3. return current → 返回当前计数值
*
* ★ if current == 1 判断的意义:
* 只在首次请求时设置 EXPIRE,避免每次请求都重复设置
* 如果不加这个判断,每次 INCR 后都会重置过期时间
* → 窗口变成"最后一次请求后60秒",而非"固定60秒窗口"
*/
private static final String LUA_SCRIPT =
"local current = redis.call('INCR', KEYS[1]) " +
"if current == 1 then " +
" redis.call('EXPIRE', KEYS[1], ARGV[1]) " +
"end " +
"return current";

@Around("@annotation(com.annotation.RateLimit)")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);

// 1. 构建限流 key:rate_limit:{方法名或自定义key}:{客户端IP}
String key = buildKey(rateLimit, method);

// 2. 计算时间窗口(秒)
long periodSeconds = rateLimit.unit().toSeconds(rateLimit.period());
int maxCount = rateLimit.count();

// 3. 执行 Lua 脚本获取当前计数
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(LUA_SCRIPT);
redisScript.setResultType(Long.class); // ★ 必须设为 Long.class,否则返回 null

Long currentCount = redisTemplate.execute(
redisScript,
Collections.singletonList(key), // KEYS[1]
periodSeconds // ARGV[1]
);

// 4. 超限则拒绝
if (currentCount != null && currentCount > maxCount) {
throw new RateLimitException(rateLimit.message());
}

// 5. 放行
return point.proceed();
}

/**
* 构建限流 key
* 格式: rate_limit:{自定义key或方法名}:{clientIp}
* ★ IP 隔离:不同用户的请求互不影响
*/
private String buildKey(RateLimit rateLimit, Method method) {
String baseKey;
if (!rateLimit.key().isEmpty()) {
baseKey = rateLimit.key();
} else {
baseKey = method.getDeclaringClass().getSimpleName()
+ ":" + method.getName();
}
String clientIp = getClientIp();
return RATE_LIMIT_PREFIX + baseKey + ":" + clientIp;
}
}

5.4 Lua 脚本逐行解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Lua 脚本在 Redis 中原子执行

-- 1. 对 KEYS[1] 执行 INCR(计数+1)
-- 如果 key 不存在,INCR 会自动创建并设为 1
-- 返回值 current = 当前计数
local current = redis.call('INCR', KEYS[1])

-- 2. 如果是第一次请求(current == 1),设置过期时间
-- ARGV[1] 是从 Java 传入的时间窗口(秒)
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end

-- 3. 返回当前计数值给 Java 端
return current

为什么不用 SET key 1 EX 60 NX 替代?

SET NX EX 只在 key 不存在时设置——第一次请求会设置成功(计数=1),但后续 60 秒内的请求都设置失败(返回 nil)。你只知道”是否超过1次”,不知道具体多少次,无法实现”允许 N 次”。

5.5 实际使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@IgnoreAuth  // 登录接口免鉴权
@RateLimit(
key = "login:users",
count = 5,
period = 60,
message = "登录请求过于频繁,请1分钟后再试"
)
@PostMapping(value = "/login")
public R login(@RequestBody Map<String, String> params, HttpServletRequest request) {
String username = params.get("username");
String password = params.get("password");
UserEntity user = userService.getOne(
new QueryWrapper<UserEntity>().eq("username", username));
if(user == null || !PasswordEncoder.matches(password, user.getPassword())) {
return R.error("账号或密码不正确");
}
String clientIp = JwtUtils.getClientIp(request);
String token = jwtUtils.generateToken(
user.getId(), username, "users", user.getRole(), clientIp);
redisTokenService.saveToken(token, user.getId(), username, "users", user.getRole());
return R.ok().put("token", token);
}

效果:同一个 IP,60 秒内最多 5 次登录请求,第 6 次抛出 RateLimitException

5.6 限流异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(RateLimitException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) // HTTP 429
public R handleRateLimitException(RateLimitException e) {
return R.error(429, e.getMessage());
}

// 其他异常处理...
@ExceptionHandler(ExpiredJwtException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public R handleExpiredJwtException(ExpiredJwtException e) {
return R.error(401, "登录已过期,请重新登录");
}

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public R handleValidException(MethodArgumentNotValidException e) {
return R.error(400, "参数校验失败: " + e.getMessage());
}
}

统一返回 R 类({code, msg, data} 格式),前端可以根据 code 做不同处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 前端 Axios 拦截器
service.interceptors.response.use(
response => response.data,
error => {
if (error.response.status === 429) {
ElMessage.warning('请求过于频繁,请稍后再试');
} else if (error.response.status === 401) {
localStorage.removeItem('Token');
router.push('/login');
}
return Promise.reject(error);
}
);

第六章:Bucket4j 令牌桶限流

6.1 固定窗口的”临界突刺”问题

Lua 脚本实现的是固定窗口限流——在固定的时间窗口内计数。但它有一个问题:

1
2
3
4
5
窗口1: 0-60秒    窗口2: 60-120秒

──────────────────┼──────────────────
55秒: 5次请求 65秒: 5次请求
←── 10秒内10次请求 ──→

在窗口切换的瞬间(55秒~65秒),10秒内通过了 10 次请求——超过了”60秒5次”的限制。这就是”临界突刺”。

6.2 令牌桶原理

1
2
3
4
5
6
7
8
9
┌─────────────────┐
│ 令牌桶 (容量N) │ ← 匀速填充令牌 (R个/秒)
│ ● ● ● ● ● ● ● │
└────────┬────────┘
│ 每次请求消耗1个令牌

┌─────────┐
│ 请求 │ → 有令牌: 放行
└─────────┘ → 无令牌: 拒绝 (429)
参数 含义 示例
capacity 桶容量(最大突发量) 5
refillRate 填充速率(每秒补充几个) 5
timeWindow 时间窗口(秒) 60

令牌桶通过”匀速填充 + 容量上限”同时解决了”临界突刺”和”突发流量”两个问题。

6.3 注解定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String keyPrefix() default ""; // 限流key前缀
LimitType limitType() default LimitType.USER; // 限流维度
int capacity() default 100; // 令牌桶容量
double refillRate() default 100; // 令牌填充速率
int timeWindow() default 60; // 时间窗口(秒)
String message() default "请求过于频繁,请稍后再试";

enum LimitType {
USER, // 按用户ID限流
IP // 按IP地址限流
}
}

6.4 Bucket4j 切面实现

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@Aspect
@Component
public class RateLimitAspect {

@Resource
private LettuceConnectionFactory lettuceConnectionFactory;

private ProxyManager<byte[]> proxyManager;
private RedisClient redisClient;

/**
* 懒加载初始化 Bucket4j 代理管理器
* ★ 基于 Lettuce 客户端连接 Redis,让令牌桶状态跨实例共享
*
* 这是分布式限流的关键——多个应用实例共享同一个桶
*/
private synchronized ProxyManager<byte[]> getProxyManager() {
if (proxyManager == null) {
String host = lettuceConnectionFactory.getHostName();
int port = lettuceConnectionFactory.getPort();
this.redisClient = RedisClient.create("redis://" + host + ":" + port);
this.proxyManager = LettuceBasedProxyManager.builderFor(redisClient).build();
}
return proxyManager;
}

@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
// 1. 生成限流 key
String key = generateKey(rateLimit);

// 2. 获取或创建令牌桶
Bucket bucket = getOrCreateBucket(key, rateLimit);

// 3. 尝试消费1个令牌
if (bucket.tryConsume(1)) {
return joinPoint.proceed(); // 有令牌,放行
} else {
return R.error(429, rateLimit.message()); // 无令牌,限流
}
}

private String generateKey(RateLimit rateLimit) {
StringBuilder keyBuilder = new StringBuilder("rate_limit:");
if (!rateLimit.keyPrefix().isEmpty()) {
keyBuilder.append(rateLimit.keyPrefix()).append(":");
}
// ★ 按维度限流:IP 或 用户ID
if (rateLimit.limitType() == RateLimit.LimitType.IP) {
keyBuilder.append("ip:").append(getIpAddress());
} else {
keyBuilder.append("user:").append(getCurrentUserId());
}
return keyBuilder.toString();
}

/**
* 从 Redis 获取或创建令牌桶
* ★ proxyManager 让桶状态存储在 Redis 中,多实例共享
*/
private Bucket getOrCreateBucket(String key, RateLimit rateLimit) {
ProxyManager<byte[]> proxyManager = getProxyManager();
byte[] keyBytes = key.getBytes();

// 经典令牌桶:容量 + 间隔填充
Bandwidth bandwidth = Bandwidth.classic(
rateLimit.capacity(), // 桶容量
Refill.intervally(
(long) rateLimit.refillRate(), // 填充量
Duration.ofSeconds(rateLimit.timeWindow()) // 填充间隔
)
);

BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(bandwidth)
.build();

// 从 Redis 获取或创建 Bucket
// supplier 在 key 不存在时创建新配置
return proxyManager.builder()
.build(keyBytes, () -> configuration);
}
}

6.5 Refill.intervally vs Refill.greedy

模式 填充行为 特点
intervally 时间窗口结束时一次性填充 严格限制,不会有额外令牌
greedy 匀速填充(尽可能快地填充到满) 更平滑,允许一定程度的突发
1
2
3
4
5
// intervally:每60秒填充5个令牌(一次性)
Refill.intervally(5, Duration.ofSeconds(60))

// greedy:60秒内匀速填充5个(约每12秒1个)
Refill.greedy(5, Duration.ofSeconds(60))

选择建议:严格限流用 intervally,需要平滑限流用 greedy

6.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
// 登录限流:每IP每60秒最多5次
@RateLimit(
keyPrefix = "login",
limitType = RateLimit.LimitType.IP,
capacity = 5,
refillRate = 5,
timeWindow = 60,
message = "登录尝试过于频繁,请1分钟后再试"
)
@PostMapping("/login")
public R login(@RequestBody Account account) { ... }

// 注册限流:每IP每60秒最多3次
@RateLimit(
keyPrefix = "register",
limitType = RateLimit.LimitType.IP,
capacity = 3,
refillRate = 3,
timeWindow = 60,
message = "注册请求过于频繁,请1分钟后再试"
)
@PostMapping("/register")
public R register(@RequestBody Account account) { ... }

// 敏感操作限流:每用户每60秒最多10次
@RateLimit(
keyPrefix = "passwordChange",
limitType = RateLimit.LimitType.USER,
capacity = 10,
refillRate = 10,
timeWindow = 60
)
@PostMapping("/updatePassword")
public R updatePassword(@RequestBody Account account) { ... }

第七章:两种限流方案对比与选型

维度 Redis + Lua Bucket4j + Redis
算法 固定窗口计数 令牌桶
临界突刺
分布式
依赖 仅Redis Bucket4j + Lettuce
复杂度
多维度 仅IP IP/用户/自定义
动态配置 需改代码 可运行时调整
Maven依赖 无额外 bucket4j-redis:8.7.0

选择建议

场景 推荐方案 原因
简单接口限流 Redis + Lua 轻量,无额外依赖
复杂限流策略(多维度、动态配置) Bucket4j 支持IP/用户/自定义维度
网关层限流 Spring Cloud Gateway 内置 RequestRateLimiter 基于 Redis + Lua,网关原生
需要令牌桶语义 Bucket4j 固定窗口无法避免临界突刺

第八章:踩坑总结

8.1 延迟双删的坑

  1. @Async 不生效:忘记加 @EnableAsync,异步方法变成同步执行,第二次删除会阻塞当前请求 500ms。

  2. 延迟时间太短:如果读操作涉及多表 JOIN 或外部调用,500ms 可能不够。监控缓存 miss 率,如果一致性事件频发,调大延迟。

  3. 第二次删除失败:如果 Redis 在第二次删除时不可用,脏数据会存活到 TTL 过期。生产环境可加重试或引入消息队列兜底。

  4. 缓存击穿:热点 key 过期瞬间,大量请求同时穿透到 DB。解法:设置逻辑过期或使用互斥锁。

8.2 Lua 限流的坑

  1. Lua 脚本结果类型INCR 返回的是整数,DefaultRedisScriptsetResultType 必须设为 Long.class,否则返回 null。
1
2
3
4
5
6
7
8
9
10
// ❌ 错误:默认返回类型是 Object
DefaultRedisScript<Object> script = new DefaultRedisScript<>();
script.setScriptText(LUA_SCRIPT);
Object result = redisTemplate.execute(script, keys, args); // 返回 null

// ✅ 正确:设为 Long
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(LUA_SCRIPT);
script.setResultType(Long.class);
Long result = redisTemplate.execute(script, keys, args); // 返回 1, 2, 3...
  1. IP 获取不准:Nginx 反向代理时 request.getRemoteAddr() 返回的是 Nginx 的 IP(127.0.0.1),需要从 X-Forwarded-For 头获取真实 IP。

  2. key 命名冲突:如果不加方法名前缀,不同接口的限流 key 可能冲突。

8.3 Bucket4j 的坑

  1. LettuceConnectionFactory 获取不到:在某些 Spring Boot 版本中,lettuceConnectionFactory.getHostName() 返回 localhost 而非实际 Redis 地址。需要从配置中读取。

  2. 线程安全proxyManager 的初始化要加 synchronized,否则多线程同时首次调用会创建多个 RedisClient。


第九章:缓存击穿、穿透、雪崩的完整防御

9.1 三大问题

问题 描述 后果
缓存击穿 热点key过期瞬间,大量请求穿透到DB DB 瞬间压力飙升
缓存穿透 查询不存在的数据,缓存和DB都没有 每次请求都打到DB
缓存雪崩 大量key同时过期 DB 压力瞬间飙升

9.2 防御方案

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
43
44
45
46
47
48
49
50
51
52
53
54
// 缓存击穿:互斥锁
public ProductEntity getProductWithLock(Long id) {
String key = "product:" + id;
ProductEntity cached = (ProductEntity) cacheService.get(key);
if (cached != null) return cached;

// 获取互斥锁
String lockKey = "lock:product:" + id;
try {
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
// 双重检查
cached = (ProductEntity) cacheService.get(key);
if (cached != null) return cached;

// 查DB并写入缓存
ProductEntity product = productMapper.selectById(id);
if (product != null) {
cacheService.set(key, product);
}
return product;
} else {
// 等待后重试
Thread.sleep(50);
return getProductWithLock(id);
}
} finally {
redisTemplate.delete(lockKey);
}
}

// 缓存穿透:空值缓存
public ProductEntity getProductSafe(Long id) {
String key = "product:" + id;
Object cached = cacheService.get(key);
if (cached != null) {
if ("NULL".equals(cached)) return null; // 空值标记
return (ProductEntity) cached;
}

ProductEntity product = productMapper.selectById(id);
if (product != null) {
cacheService.set(key, product);
} else {
// 缓存空值,防止穿透(TTL短一些)
cacheService.set(key, "NULL", 60); // 60秒
}
return product;
}

// 缓存雪崩:随机TTL
public void setWithRandomTTL(String key, Object value) {
long ttl = 300 + ThreadLocalRandom.current().nextLong(60); // 300-360秒
redisTemplate.opsForValue().set(CACHE_PREFIX + key, value, ttl, TimeUnit.SECONDS);
}

总结

技术点 核心实现 关键文件
延迟双删 @Async + Thread.sleep(500) 异步二次删除 CacheService.java
Lua限流 INCR + EXPIRE 原子执行,if current==1 首次才设过期 RateLimitAspect.java
Bucket4j Bandwidth.classic + Refill.intervallyLettuceBasedProxyManager 分布式共享 RateLimitAspect.java
序列化 Key用StringRedisSerializer,Value用Jackson2JsonRedisSerializer+保留类型 RedisConfig.java

核心思想:延迟双删不是银弹——它牺牲了一定的可用性(第二次删除失败时脏数据会存活到 TTL),换取了更好的数据一致性。对于电商库存、价格这类强一致场景,这个 trade-off 是值得的。而限流系统的选择——Lua 适合简单场景,Bucket4j 适合复杂维度——取决于你的业务需要多细粒度的控制。

评论
分享