1. 这三个需求为什么会同时落到 Redis 头上做过几个高并发业务之后你会发现一个有意思的现象接口幂等、分布式锁、限流这三件事表面上看是三个独立的技术点实际上它们经常出现在同一段代码里而且十有八九最后都落在 Redis 上。原因不复杂——这三件事的共同诉求是在多个进程、多台机器之间共享一个瞬时状态而这个状态又必须足够快。数据库能做但一个下单接口为了防重去查一次唯一索引QPS 上到几千就开始抖本地缓存能做但多实例之间不互通A 机器拦住了、B 机器照样放行。所以从工程角度讲Redis 在这里扮演的角色其实很统一它是一个跨进程的、带原子操作能力的共享内存。幂等靠的是一个只能被消费一次的令牌分布式锁靠的是一个带持有者标识的键限流靠的是一个能被原子读写的计数器或桶。三者底层都是SET、GET、INCR、DEL这些命令区别只在于组合方式和过期策略。这篇文章我想聊的不是Redis 有哪些命令而是把这三块在真实项目里怎么落地讲透令牌到底该谁发、发几次、过期时间怎么定分布式锁为什么手写容易翻车、Redisson 帮我们兜住了哪些底限流为什么看着最简单却最容易出问题。代码我会给到能直接抄的程度踩过的坑也会一并写进去尤其是那些文档里不会写、只有线上出过事才知道的细节。先给个总览后面逐块展开能力核心数据结构关键命令最容易翻车的地方接口幂等StringSET NX PX Lua 比对删除令牌申请与消费不分离、校验非原子分布式锁HashRedisson/ String手写SET NX PX Lua 释放加锁与过期非原子、误删他人锁、续期缺失限流String / ZSET / HashINCR、ZADD、Lua 脚本窗口临界、脚本非原子、多实例各算各的这张表建议先记住后面每一块我都会回扣到它。2. 幂等令牌先发后验的链路拆解与两种拦截姿势2.1 令牌的生命周期为什么必须拆成申请和消费两段很多人第一次做幂等直觉是请求进来判断一下有没有处理过。这个思路在单机时代成立在分布式下必然失效——因为判断和处理之间存在时间窗两个重复请求同时到达两边都判断为没处理过然后都往下走。真正可靠的思路是把判断权收归 Redis用一个只能被删除一次的令牌代表一次执行许可。完整链路分三段。第一段客户端在真正提交业务请求之前先调一个独立的接口申请令牌服务端生成 UUID 存进 Rediskey 带上业务前缀和用户维度比如idem:token:order:1001value 就是这串 UUID过期时间设成业务能容忍的最大重试窗口通常 3 到 5 分钟。第二段客户端把拿到的令牌塞进请求头比如X-Idempotent-Token发起业务请求。第三段服务端在业务逻辑执行之前用 Lua 脚本原子地完成比对并删除成功才放行。这里有个容易被忽略的设计点为什么 key 要带用户或业务维度。如果所有用户共用一个令牌池理论上令牌是随机的不会被猜中但一旦客户端代码写错、把令牌缓存到了公共变量里就会出现 A 用户拿着 B 用户的令牌通过校验的诡异场景。带上业务维度之后即使令牌泄露也只能在同一个主体的同一个业务上复用一次风险面被压到最小。过期时间的取值也有讲究。设太短用户网络慢一点、重试一次令牌就没了体验很差设太长Redis 里堆着一堆永远不会被消费的僵尸令牌。我的经验是按客户端超时时间 x 3来估比如前端请求超时 10 秒令牌就给 30 秒到 1 分钟。如果业务本身有较长的人工确认环节那就不该用令牌方案应该改用业务状态机去重。2.2 校验令牌的 Lua 脚本——GET 和 DEL 之间那点缝隙这是整个幂等方案里最关键的一行代码。假设你这么写// 错误示范 String cached redisTemplate.opsForValue().get(key); if (token.equals(cached)) { redisTemplate.delete(key); // 继续执行业务 }看起来没问题实际上 GET 和 DELETE 是两条独立的命令中间可能被其他请求插进来。两个并发请求同时 GET 到同一个 token都判断相等然后都去 DELETE结果两个都返回成功——幂等彻底失效。这不是理论风险压测的时候很容易复现。正确做法是把比对和删除塞进一个 Lua 脚本Redis 单线程执行脚本天然保证原子性-- idem_check.lua local cached redis.call(GET, KEYS[1]) if cached false then return 0 end if cached ARGV[1] then redis.call(DEL, KEYS[1]) return 1 end return -1返回值我设计成三种状态1表示校验通过且令牌已消费0表示令牌不存在多半是过期了或者被用掉了-1表示令牌存在但不匹配大概率是客户端传错了。三种状态分开返回排查问题的时候非常省事——线上看到大量-1基本可以断定是前端把令牌存到公共变量里了大量0说明过期时间设短了。Java 侧用DefaultRedisScript加载Configuration public class RedisScriptConfig { Bean public DefaultRedisScriptLong idemCheckScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/idem_check.lua)); script.setResultType(Long.class); return script; } }注意脚本一定要走DefaultRedisScript缓存别每次调用都把脚本文本拼一遍再eval。虽然 Redis 会自己缓存脚本的 SHA1但你每次传全文过去网络带宽和解析开销都浪费了QPS 高的时候这部分开销很可观。2.3 姿势一HandlerInterceptor 配合自定义注解第一种拦截姿势是 Spring MVC 的拦截器优点是作用于 Controller 层之前能拿到完整的请求上下文Header、参数、用户身份适合做通用的幂等控制。先定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Idempotent { /** 业务前缀避免不同接口的 key 撞车 */ String prefix() default default; /** 令牌在请求头里的字段名 */ String header() default X-Idempotent-Token; }再写拦截器Component public class IdempotentInterceptor implements HandlerInterceptor { private static final String KEY_PREFIX idem:token:; Resource private StringRedisTemplate stringRedisTemplate; Resource private DefaultRedisScriptLong idemCheckScript; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod hm (HandlerMethod) handler; Idempotent anno hm.getMethodAnnotation(Idempotent.class); if (anno null) { return true; } String token request.getHeader(anno.header()); if (token null || token.trim().isEmpty()) { writeError(response, 缺少幂等令牌); return false; } String key KEY_PREFIX anno.prefix() : token; Long result stringRedisTemplate.execute( idemCheckScript, Collections.singletonList(key), token); if (result null || result ! 1L) { writeError(response, result ! null result -1L ? 令牌不匹配 : 令牌已失效或已被使用); return false; } return true; } private void writeError(HttpServletResponse resp, String msg) throws IOException { resp.setStatus(HttpServletResponse.SC_CONFLICT); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\code\:409,\msg\:\ msg \}); } }注册到 Spring MVCConfiguration public class WebMvcConfig implements WebMvcConfigurer { Resource private IdempotentInterceptor idempotentInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(idempotentInterceptor) .addPathPatterns(/api/**) .order(1); } }用的时候在方法上加注解即可Idempotent(prefix order:create) PostMapping(/api/order/create) public Result createOrder(RequestBody OrderDTO dto) { // 业务逻辑 }这里有个细节值得说拦截器里不要写Transactional相关的逻辑。拦截器跑在事务开启之前如果你在preHandle里开了事务又没关会出各种奇怪的问题。幂等校验本身不涉及数据变更只在 Redis 上动一个 key不需要事务。另一个坑是异常处理。preHandle里如果 Redis 挂了抛异常默认会直接 500 打到前端。我的做法是给 Redis 调用包一层 try-catch遇到连接异常时选择放行并打告警而不是拦死。理由很实际幂等是防重的兜底不是必须的准入条件Redis 抖动的时候宁可让少量重复请求过去后续还有数据库唯一索引再兜一层也不能让整个下单链路全挂。2.4 姿势二AOP 切面在方法签名层面拦截第二种姿势是 Spring AOP 切面。它和拦截器的本质区别在于拦截器只作用于 HTTP 入口切面可以作用于任何 Spring Bean 方法包括内部服务调用、定时任务、MQ 消费者。Aspect Component public class IdempotentAspect { private static final String KEY_PREFIX idem:token:; Resource private StringRedisTemplate stringRedisTemplate; Resource private DefaultRedisScriptLong idemCheckScript; Around(annotation(idempotent)) public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable { String token IdempotentContext.get(); if (token null) { // 没传令牌就当作不启用幂等按业务需要决定是否抛异常 return pjp.proceed(); } String key KEY_PREFIX idempotent.prefix() : token; Long result stringRedisTemplate.execute( idemCheckScript, Collections.singletonList(key), token); if (result null || result ! 1L) { throw new IdempotentException(重复提交请勿重试); } try { return pjp.proceed(); } catch (Throwable e) { // 业务失败要不要归还令牌见下面的分析 throw e; } } }IdempotentContext用ThreadLocal在过滤器里把请求头塞进去切面里取。为什么要有这么一层因为切面只能拿到方法参数拿不到HttpServletRequest而令牌恰恰在 Header 里。用ThreadLocal转一道是最省事的做法记得在finally里remove()。这里引出幂等方案里最纠结的一个问题业务执行失败了要不要把令牌还回去。不还业务因为网络抖动失败用户重试时会发现令牌已被消费直接被拒体验非常差而且会误伤正常请求。还把删掉的 key 重新SET回去看起来合理但如果失败发生在数据库已提交、但后续 RPC 超时抛异常的场景令牌还回去之后用户重试数据就被写了两遍。我的处理原则是按失败类型区分参数校验失败、前置校验不通过这类业务确定没落库的错误归还令牌任何涉及不确定是否已提交的异常RPC 超时、数据库连接中断坚决不归还让用户走查询接口确认结果。这个判断逻辑得写在切面里或者干脆不在切面里做让业务方法自己决定——后者更灵活但要求每个接入方都理解这套规则团队协作成本高。2.5 令牌方案的三个适用边界令牌方案好用但它不是万能的。我踩过三次坑之后总结出三条边界写在这里供参考。第一它要求客户端配合。如果是给第三方开放的 API对方可能根本不理解先申请令牌这套流程强行要求只会增加对接成本。这种场景更适合用业务唯一键 数据库唯一索引来做幂等简单粗暴但有效。第二它不适合长耗时业务。假设一个接口要跑 3 分钟令牌的过期时间就得设 3 分钟以上这期间 Redis 里躺着一堆未消费的令牌而且用户中途关掉页面令牌就成了垃圾。这种情况用状态机更合适把业务状态存在数据库里用UPDATE ... WHERE status INIT这种带条件的更新来保证只执行一次。第三它不能替代数据库约束。Redis 是缓存会丢数据主从切换的时候如果同步没跟上刚SET的令牌可能就没了。所以真正的幂等保险丝永远是数据库的唯一索引或者乐观锁版本号。Redis 令牌的作用是在数据库之前拦掉绝大部分重复请求减轻数据库压力两者是配合关系不是替代关系。3. 分布式锁从手写 SETNX 到 Redisson 看门狗3.1 SETNX 加 EXPIRE 之间丢的那几毫秒手写分布式锁的第一版几乎所有人都会写成这样// 错误示范 Boolean ok stringRedisTemplate.opsForValue() .setIfAbsent(lock:order: orderId, 1); if (Boolean.TRUE.equals(ok)) { stringRedisTemplate.expire(lock:order: orderId, 30, TimeUnit.SECONDS); // 业务逻辑 }问题在于setIfAbsent和expire是两条命令。如果服务在第一条执行成功、第二条还没发出去的时候被 kill 掉发布重启、OOM、容器被调度这个锁就永远不过期了。后续所有请求全部阻塞在这把死锁上直到有人手动DEL。这种事故在发布频繁的团队里几乎必然会遇到一次。解决办法是把设置值和设置过期合成一条命令Boolean ok stringRedisTemplate.opsForValue() .setIfAbsent(lock:order: orderId, uuid, 30, TimeUnit.SECONDS);底层就是SET key value NX PX 30000。NX保证只有 key 不存在时才设置成功PX保证一定带过期时间。这一条命令是原子的从根上消灭了死锁窗口。提示用SET NX PX的时候千万别省PX。我见过有人为了避免业务没跑完锁就过期故意不设过期时间结果靠人工清理。这在测试环境没事线上就是定时炸弹。3.2 释放锁为什么要比对 value加锁原子了释放锁还有坑。第一版释放通常写DEL key问题是有可能删掉别人的锁线程 A 拿到锁过期时间 30 秒。A 的业务因为 GC 或者慢 SQL 卡了 35 秒锁自动过期。线程 B 拿到同一把锁开始执行。A 执行完了执行DEL key把 B 的锁删了。线程 C 立刻拿到锁和 B 同时进入临界区。所以要给锁加一个持有者标识那个 UUID 就是干这个的释放时先比对再删-- unlock.lua if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 endprivate static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript( if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end, Long.class); public void unlock(String key, String value) { stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key), value); }同样的道理GET和DEL分开写依然有窗口必须用 Lua 包起来。至于 Redis 官方说的主从切换导致锁丢失问题那是 RedLock 要解决的范畴实现复杂度和争议都很大我的建议是如果你的业务对锁的安全性要求到了绝对不能两个线程同时进的程度就别只靠 Redis加一层数据库行锁或者唯一索引兜底比上 RedLock 更靠谱。3.3 Redisson 的看门狗到底在看什么上面这套手写方案还有个绕不过去的问题业务执行时间不确定。锁设 30 秒业务跑了 40 秒锁提前释放临界区被击穿。设 5 分钟业务正常只跑 1 秒那 4 分 59 秒里这个 key 白占着其他请求全在等。Redisson 的解法是看门狗加锁的时候不指定过期时间或者指定-1Redisson 会用默认的 30 秒作为初始租期然后启动一个后台任务每 10 秒租期的三分之一检查一次如果业务还没执行完就把租期重置回 30 秒。业务执行完主动unlock看门狗任务被取消。Resource private RedissonClient redissonClient; public void doBusiness(String orderId) { RLock lock redissonClient.getLock(lock:order: orderId); try { // 不传 leaseTime走看门狗自动续期 lock.lock(); // 业务逻辑跑多久都不会提前释放 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里有个极其常见的误用我在代码评审里见过不止一次// 这样写看门狗不会生效 lock.lock(10, TimeUnit.SECONDS);一旦你显式传了leaseTimeRedisson 就认为你明确知道业务多久能跑完看门狗直接不启动10 秒后锁无条件释放。所以除非你非常确定上界否则别传这个参数。那看门狗会不会一直续期导致锁永远不释放理论上是——如果业务线程死循环了锁就会被无限续下去。这就是为什么unlock必须写在finally里而且是isHeldByCurrentThread判断之后再unlock。后者是为了防止当前线程没拿到锁却去释放Redisson 会抛IllegalMonitorStateException把原本的业务异常盖掉排查起来非常痛苦。3.4 tryLock 的参数陷阱与可重入的底层结构lock()是阻塞的拿不到锁就一直等很多场景下这不可接受——接口超时了用户早跑了。这时候用tryLockRLock lock redissonClient.getLock(lock:order: orderId); boolean acquired lock.tryLock(3, 10, TimeUnit.SECONDS); if (!acquired) { throw new BusinessException(当前订单正在处理中请稍后再试); } try { // 业务 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }三个参数分别是waitTime最多等多久拿锁、leaseTime拿到后持有多久、unit。这里第二个参数又踩到上一个坑了——传了leaseTime看门狗不启动。所以如果业务耗时不可控应该用tryLock(waitTime, unit)这个两参数重载让看门狗生效。再说 Redisson 锁的底层结构。它没有用简单的 String而是用 HashHSET lock:order:1001 3f2a1b... 1field 是客户端 ID 线程 ID的组合value 是重入次数。这样设计直接带来了可重入能力同一个线程再次加锁时HINCRBY把计数变成 2解锁时递减减到 0 才真正DEL。这个特性在嵌套调用里非常有用——比如createOrder里调了deductStock两个方法都对同一个订单加锁如果是不可重入的锁第二次加锁自己就把自己锁死了。但可重入也有副作用锁的粒度被放大了。如果你在很外层加了一把范围很宽的锁比如lock:order不带 ID内层所有操作都在这把锁下面串行吞吐直接掉一个数量级。我的习惯是锁的 key 一定带业务主键绝不用大锁宁可多处加锁也不要一把锁管全部。另外要提醒的是unlock的次数必须和lock次数严格对应。可重入的前提下你lock了两次只unlock一次锁永远不会释放。这种问题用finally嵌套也很难完全避免最好是把加锁解锁封装成模板方法public T T executeWithLock(String key, long waitSeconds, SupplierT action) { RLock lock redissonClient.getLock(key); boolean acquired false; try { acquired lock.tryLock(waitSeconds, TimeUnit.SECONDS); if (!acquired) { throw new BusinessException(获取锁失败请稍后重试); } return action.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException(获取锁被中断); } finally { if (acquired lock.isHeldByCurrentThread()) { lock.unlock(); } } }调用方只管写业务逻辑加锁解锁的配对由模板保证出错概率直线下降。4. 限流固定窗口的临界双倍与三种更稳的算法4.1 计数器限流在整点前后为什么会放过两倍流量最简单的限流实现是固定窗口计数器public boolean allow(String key, int limit, int windowSeconds) { Long count stringRedisTemplate.opsForValue().increment(key); if (count ! null count 1L) { stringRedisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS); } return count ! null count limit; }逻辑很直白key 是接口 时间窗口每次请求INCR第一次设置过期时间超过阈值就拒绝。问题出在窗口边界上。假设限制是每分钟 100 次。用户在 10:00:59 的最后 100 毫秒里打了 100 个请求全部放行紧接着 10:01:00 窗口重置又打 100 个也全部放行。也就是说在跨越窗口的这 1 秒内实际通过了 200 个请求是限额的两倍。如果下游服务按每分钟 100 次的容量设计这一下就可能被打挂。这个缺陷不是实现的问题是算法本身的问题。它适合做粗粒度保护——比如限制每个 IP 每分钟最多 1000 次偶尔放过两倍也无所谓。但如果要精确控制就得换算法。顺便说一句上面这段代码还有个隐藏 bugINCR和EXPIRE不是原子的。如果进程在INCR之后、EXPIRE之前挂掉或者抛异常这个 key 就没有过期时间计数器只增不减这个接口从此永久限流。修复方法和加锁一样用 Lua 把两步合成一步-- fixed_window.lua local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) end return current4.2 ZSET 滑动窗口的代价与 PEXPIRE 的必要性滑动窗口的思路是不用整分钟这种硬边界而是每次请求时回看过去 N 秒统计这 N 秒内的请求数。用 ZSET 实现member 存请求的唯一标识score 存时间戳-- sliding_window.lua local key KEYS[1] local now tonumber(ARGV[1]) local windowMs tonumber(ARGV[2]) local limit tonumber(ARGV[3]) local member ARGV[4] -- 清掉窗口外的旧记录 redis.call(ZREMRANGEBYSCORE, key, 0, now - windowMs) local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, now, member) redis.call(PEXPIRE, key, windowMs) return 1 end return 0这段脚本比固定窗口精确得多任意时刻回看过去 N 秒数量都不会超过阈值。但代价也明显内存占用随 QPS 线性增长。每个请求都要在 ZSET 里留一条记录QPS 一万、窗口 60 秒就是 60 万条。虽然 Redis 的 ZSET 用跳表实现单条开销不大但架不住量大。ZREMRANGEBYSCORE的复杂度是 O(log N M)M 是被删除的元素个数。窗口滑动的时候每次都要删一批CPU 开销不小。member 必须唯一。用UUID或者时间戳 随机数如果 member 重复ZADD会覆盖而不是新增计数就偏少了。PEXPIRE那一行也容易被漏掉。如果所有请求突然停止这个 ZSET 就永远不会有人来清理一直挂在 Redis 里。加上过期时间至少能在窗口结束后自动回收。我的实践建议是滑动窗口只用在关键接口 中等 QPS的场景比如登录接口限流防撞库、短信发送接口防刷。QPS 上万的通用限流用令牌桶更划算。4.3 令牌桶的 Lua 实现与速率参数换算令牌桶的核心模型是桶里最多装capacity个令牌以固定速率rate往桶里放请求来了先取令牌取到就放行取不到就拒绝。它的好处是允许突发——桶里攒着的令牌可以一次性消耗掉但长期平均速率被rate卡死。-- token_bucket.lua local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) local ttl tonumber(ARGV[5]) local bucket redis.call(HMGET, key, tokens, lastRefill) local tokens tonumber(bucket[1]) local lastRefill tonumber(bucket[2]) if tokens nil then tokens capacity lastRefill now end -- 按时间差补充令牌 local delta math.max(0, now - lastRefill) tokens math.min(capacity, tokens delta * rate) local allowed 0 if tokens requested then tokens tokens - requested allowed 1 end redis.call(HMSET, key, tokens, tokens, lastRefill, now) redis.call(EXPIRE, key, ttl) return allowed参数换算这里特别容易出错。rate的单位是个/毫秒因为now是从 RedisTIME命令或者客户端拿到的毫秒时间戳。如果业务要求每秒 100 个那rate就是0.1。我见过有人直接把100填进去结果桶瞬间被填满限流完全失效。另一个参数capacity决定突发容忍度。capacity rate * 1秒意味着最多容忍 1 秒的突发流量capacity rate * 10秒意味着一口气能放过 10 秒的量。取值要结合下游服务的承受能力别拍脑袋定。时间戳的来源也值得讲究。必须用 Redis 服务器时间不能用客户端时间。多台应用服务器的时钟如果有几十毫秒偏差delta算出来可能是负数math.max(0, ...)虽然兜住了负数但会导致令牌补充停滞。取 Redis 时间的方式是在脚本里调redis.call(TIME)local t redis.call(TIME) local now tonumber(t[1]) * 1000 math.floor(tonumber(t[2]) / 1000)这样所有实例看到的都是同一个时间源不会出现时钟漂移问题。4.4 网关层限流插件的分布式协同问题除了业务代码里手写限流更常见的做法是在网关层做统一限流。网关限流一般有两种模式单机限流和集群限流。单机限流用的是本地令牌桶内存里算速度快、无网络开销但问题是总量失控。假设配了每台机器每秒 100 次部署了 10 台实际总量就是 1000 次。扩缩容的时候这个数字还会变运维得手动改配置非常容易漏。集群限流则是把计数放在 Redis 里所有实例共享一个桶。准确性高代价是每次请求多一次 Redis 往返。在网关这种每秒几十万请求的位置这个开销不能忽略通常需要用批量上报或者本地预扣的方式优化——每个实例先从 Redis 预领 100 个令牌放本地用完再去领牺牲一点精度换吞吐。这里有个配置上的坑限流的 key 维度选错了等于没限。常见维度有 IP、用户 ID、接口路径、AppKey。如果按 IP 限用户挂代理或者整个办公网共用出口 IP很容易误伤如果按接口路径限恶意用户换个参数就能刷爆。比较稳妥的做法是分层全局一层保护整个系统、用户维度一层防单用户刷、接口维度一层保护热点接口三个桶同时生效命中任意一个就拒绝。还有一点限流的返回码和提示要给对。返回 500 会让客户端以为是服务端故障从而疯狂重试正确做法是返回 429 加上Retry-After头告诉对方多久之后再来。这个细节直接影响故障期间的雪崩速度。5. 同一条下单链路里的执行顺序限流、幂等、锁三块内容分开讲都清楚了真到一条链路里顺序摆错照样出问题。我拿一个典型的下单接口举例把三者的位置标出来。请求 → 网关限流 → 幂等令牌校验 → 分布式锁 → 业务处理 → 释放锁限流放最前面理由很直接限流的目的是保护系统它应该在所有重量级操作之前就拦住超额流量。如果放到幂等后面那每个被限流的请求还是要先查一次 Redis 校验令牌白白消耗资源。放在最前面被拒的请求连 Redis 都不用碰单机限流的情况下。幂等紧随其后放在锁前面。这里可能有人会问幂等校验本身也是一次 Redis 操作会不会有并发问题不会因为幂等校验是原子的 Lua 脚本两个重复请求只有一个能返回 1另一个返回 0 直接被拒压根走不到加锁那一步。反过来如果先加锁再校验幂等重复请求会先在锁上排队等待白白占着连接资源。锁包住业务临界区粒度要尽量小。这里的锁不是为了防重复提交那是幂等的活而是为了防并发修改同一份数据。比如同一订单的支付回调和取消操作同时到达需要串行化处理。还有一个容易忽略的点幂等令牌的消费时机。上面说的方案是在进入业务之前就把令牌删掉这意味着业务一旦抛异常令牌就没了。另一种做法是业务成功后再删令牌但这样就回到了最初的问题——两个并发请求都能通过校验。折中方案是两阶段标记校验时不是删除而是把 value 从PENDING改成PROCESSING用 Lua 的SET ... XX或者 Hash 的字段更新业务成功后再删除失败时把状态改回PENDING允许重试。这个方案更严谨但多了一次写操作而且状态机复杂度上去了。我的选择是普通业务用先删后执行核心资金类业务用两阶段标记用复杂度换确定性。另外三者对 Redis 的依赖程度不同降级策略也该不同组件Redis 不可用时的策略理由限流降级为本地单机限流保护系统是硬需求本地限流精度差但聊胜于无幂等放行 打告警有数据库唯一索引兜底拦死会误伤正常用户分布式锁快速失败拒绝请求临界区被并发进入的后果比拒绝请求严重得多这张表我建议直接写进团队的技术方案文档里出事的时候不用临时讨论。6. 上线前值得再核对一遍的参数与故障对照表最后整理两块内容一块是参数取值一块是故障对照。这两块东西看着琐碎但真出事的时候能省掉大量排查时间。参数取值参考场景参数建议值说明幂等令牌过期时间客户端超时 x 3别超过 5 分钟避免僵尸令牌堆积幂等令牌key 前缀idem:token:{biz}:{token}带业务维度便于隔离和清理Redisson 锁默认租期30 秒不传 leaseTime 时生效Redisson 锁看门狗间隔租期 / 3框架内置不要手动改Redisson 锁waitTime1 到 3 秒超过这个值用户基本已经放弃等待固定窗口限流窗口长度≥ 10 秒窗口太短临界问题被放大令牌桶rate目标 QPS / 1000个每毫秒单位换算最容易错令牌桶capacityrate x 1 到 10 秒决定突发容忍度滑动窗口窗口长度≤ 60 秒再长内存扛不住故障对照表现象可能原因排查动作大量请求返回令牌不匹配客户端把令牌存在公共变量里被覆盖抓包看 Header 里的 token 是否重复幂等校验全部通过重复数据照样入库Lua 脚本加载失败回退成了非原子实现检查DefaultRedisScript的resultType和脚本路径锁一直不释放接口全阻塞用了SETNX没设过期或者lock/unlock次数不配对TTL看锁的剩余时间HLEN看重入计数锁提前释放临界区被击穿lock(10, SECONDS)传了 leaseTime看门狗没启动改回无参lock()限流器在整点前后放过双倍流量用了固定窗口换滑动窗口或令牌桶限流 key 永不过期接口被永久限流INCR和EXPIRE分开写中间进程挂了用 Lua 合并检查TTL是否为 -1集群限流总量超出配置值用了单机限流但按集群容量配的参数核对实例数改用集群限流或按实例数分摊令牌桶补充速率明显偏慢rate单位填成了个每秒换算成个每毫秒我个人在实际操作中的体会是这三块内容真正的难点从来不是怎么写代码而是怎么定边界。令牌过期多久、锁租期多长、限流阈值多少这些数字没有任何文档能给你标准答案只能从业务的实际流量曲线和用户的操作习惯里一点点试出来。我的建议是上线初期把阈值调得宽松一些先观察一周的真实峰值再逐步收紧比一开始就卡得很死然后被用户投诉要舒服得多。还有一个特别实用的小技巧给这三类 Redis key 都加上统一的业务前缀比如idem:、lock:、rate:并且把不同业务的 key 分到不同的 Redis 逻辑库或者不同的实例。原因是它们的访问模式差异很大——限流的 key 数量最多但每个都很小锁的 key 数量少但访问集中在少数热点上幂等的 key 生命周期短但写入频繁。混在一起的时候一次限流导致的 key 数量暴涨可能会把锁的响应时间也拖下去。物理隔离之后故障范围能控制在一个能力内排查的时候也一目了然。