从”只删一次缓存的坑”到”延迟双删的正确姿势”,从”固定窗口的临界突刺”到”令牌桶的匀速填充”——本文把 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);
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;
@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)); }
public void doubleDelete(String key, Runnable dbAction) { dbAction.run(); doubleDeleteFirst(key); doubleDeleteSecond(key); }
public void doubleDeleteFirst(String key) { delete(key); log.debug("双删策略 - 第一次删除缓存: {}", key); }
@Async public void doubleDeleteSecond(String key) { try { Thread.sleep(500); delete(key); log.debug("双删策略 - 第二次删除缓存: {}", key); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("双删策略 - 第二次删除被中断: {}", key, e); } }
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 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); } }
|
如果忘记加 @EnableAsync:doubleDeleteSecond 方法会在当前线程中同步执行,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) { 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; } 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) { redisTemplate.delete("product:info:" + product.getId()); updateById(product); scheduleDelayedDelete("product:info:" + product.getId(), 3000); return R.ok(); }
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 计数,超过阈值就拒绝。但 INCR 和 EXPIRE 是两条命令:
1 2 3
| INCR rate_limit:login:192.168.1.1
EXPIRE rate_limit:login:192.168.1.1 60
|
如果 INCR 之后进程崩溃,EXPIRE 没执行,这个 key 会永久存在——用户的 IP 被永久限流锁死。
Lua 脚本保证原子性:INCR 和 EXPIRE 在一个脚本中执行,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 ""; 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:";
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);
String key = buildKey(rateLimit, method);
long periodSeconds = rateLimit.unit().toSeconds(rateLimit.period()); int maxCount = rateLimit.count();
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setScriptText(LUA_SCRIPT); redisScript.setResultType(Long.class);
Long currentCount = redisTemplate.execute( redisScript, Collections.singletonList(key), periodSeconds );
if (currentCount != null && currentCount > maxCount) { throw new RateLimitException(rateLimit.message()); }
return point.proceed(); }
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
|
local current = redis.call('INCR', KEYS[1])
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
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) 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
| 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 ""; LimitType limitType() default LimitType.USER; int capacity() default 100; double refillRate() default 100; int timeWindow() default 60; String message() default "请求过于频繁,请稍后再试";
enum LimitType { USER, 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;
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 { String key = generateKey(rateLimit);
Bucket bucket = getOrCreateBucket(key, rateLimit);
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(":"); } if (rateLimit.limitType() == RateLimit.LimitType.IP) { keyBuilder.append("ip:").append(getIpAddress()); } else { keyBuilder.append("user:").append(getCurrentUserId()); } return keyBuilder.toString(); }
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();
return proxyManager.builder() .build(keyBytes, () -> configuration); } }
|
6.5 Refill.intervally vs Refill.greedy
| 模式 |
填充行为 |
特点 |
intervally |
时间窗口结束时一次性填充 |
严格限制,不会有额外令牌 |
greedy |
匀速填充(尽可能快地填充到满) |
更平滑,允许一定程度的突发 |
1 2 3 4 5
| Refill.intervally(5, Duration.ofSeconds(60))
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
| @RateLimit( keyPrefix = "login", limitType = RateLimit.LimitType.IP, capacity = 5, refillRate = 5, timeWindow = 60, message = "登录尝试过于频繁,请1分钟后再试" ) @PostMapping("/login") public R login(@RequestBody Account account) { ... }
@RateLimit( keyPrefix = "register", limitType = RateLimit.LimitType.IP, capacity = 3, refillRate = 3, timeWindow = 60, message = "注册请求过于频繁,请1分钟后再试" ) @PostMapping("/register") public R register(@RequestBody Account account) { ... }
@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 延迟双删的坑
@Async 不生效:忘记加 @EnableAsync,异步方法变成同步执行,第二次删除会阻塞当前请求 500ms。
延迟时间太短:如果读操作涉及多表 JOIN 或外部调用,500ms 可能不够。监控缓存 miss 率,如果一致性事件频发,调大延迟。
第二次删除失败:如果 Redis 在第二次删除时不可用,脏数据会存活到 TTL 过期。生产环境可加重试或引入消息队列兜底。
缓存击穿:热点 key 过期瞬间,大量请求同时穿透到 DB。解法:设置逻辑过期或使用互斥锁。
8.2 Lua 限流的坑
- Lua 脚本结果类型:
INCR 返回的是整数,DefaultRedisScript 的 setResultType 必须设为 Long.class,否则返回 null。
1 2 3 4 5 6 7 8 9 10
| DefaultRedisScript<Object> script = new DefaultRedisScript<>(); script.setScriptText(LUA_SCRIPT); Object result = redisTemplate.execute(script, keys, args);
DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(LUA_SCRIPT); script.setResultType(Long.class); Long result = redisTemplate.execute(script, keys, args);
|
IP 获取不准:Nginx 反向代理时 request.getRemoteAddr() 返回的是 Nginx 的 IP(127.0.0.1),需要从 X-Forwarded-For 头获取真实 IP。
key 命名冲突:如果不加方法名前缀,不同接口的限流 key 可能冲突。
8.3 Bucket4j 的坑
LettuceConnectionFactory 获取不到:在某些 Spring Boot 版本中,lettuceConnectionFactory.getHostName() 返回 localhost 而非实际 Redis 地址。需要从配置中读取。
线程安全: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; 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 { cacheService.set(key, "NULL", 60); } return product; }
public void setWithRandomTTL(String key, Object value) { long ttl = 300 + ThreadLocalRandom.current().nextLong(60); 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.intervally,LettuceBasedProxyManager 分布式共享 |
RateLimitAspect.java |
| 序列化 |
Key用StringRedisSerializer,Value用Jackson2JsonRedisSerializer+保留类型 |
RedisConfig.java |
核心思想:延迟双删不是银弹——它牺牲了一定的可用性(第二次删除失败时脏数据会存活到 TTL),换取了更好的数据一致性。对于电商库存、价格这类强一致场景,这个 trade-off 是值得的。而限流系统的选择——Lua 适合简单场景,Bucket4j 适合复杂维度——取决于你的业务需要多细粒度的控制。