1. 这三件事为什么总被摆在同一张桌子上谈
1.1 从一次重复提交的真实困惑说起
做后端几年下来,重复提交和重复消费这两个词几乎绕不开。我最早遇到的场景很朴素:一个下单接口,用户在弱网环境下点了两次,前端没做防抖,后端也没做校验,结果同一笔订单生成了两条记录,库存扣了两次,最后靠人工对账抹平。这件事之后我意识到,很多所谓的“并发问题”本质并不是高并发压垮了系统,而是同一份请求被做了一遍以上,而系统的状态机并不允许这样。
后来随着系统变复杂,问题形态也变了:消息队列重投导致消费逻辑执行两次、RPC 框架超时重试导致下游服务收到两份相同的调用、定时任务在多节点部署时同时触发、前端轮询把创建型接口打成了“批量创建”。这些问题的共同点在于:调用方认为一次,服务方却执行了多次。接口幂等就是用来把这件事按住的,它的目标不是让请求变慢,而是让重复的请求产生和单次请求完全一样的最终效果。
而幂等之外还有两个紧挨着的需求。一个是分布式锁,多个节点同时想修改同一份资源时,需要一个跨进程的互斥量来做串行化;另一个是限流,瞬时流量超过系统承载能力时,需要在入口处做削峰。这三件事看起来风马牛不相及,但在 Redis 这个技术栈里,它们经常被写在同一套基础组件里,共享同一份连接池、同一套 Lua 脚本加载机制、同一套 key 规范。说白了,它们解决的都是“分布式环境下多个请求之间的协调”问题,只是协调的维度不同:幂等协调的是“同一个请求的重复”,锁协调的是“不同请求对同一资源的争抢”,限流协调的是“请求总量与系统容量的关系”。
1.2 Redis 在这个坐标系里的能力边界
很多新同学会有一个惯性思维:既然 Redis 是内存数据库,响应快,那是不是所有并发协调都能交给它?答案是否定的,但也不能因噎废食。我认为关键要认清 Redis 提供的三样东西:原子命令、可选的过期时间、单线程执行模型带来的命令级顺序性。这三点决定了它非常适合做“短周期的状态标记”和“快速裁决”,但它本身不是真相来源。
具体来说,Redis 适合承担这样的角色:判断某个 key 是否已存在(幂等)、判断某个锁是否已被占用(互斥)、累计某个窗口内的请求次数(限流)。这些判断都是“瞬时裁决”,即使 Redis 数据全部丢失,业务最多出现短暂的不一致,后续也能通过数据库的唯一索引、消息队列的去重表这些兜底措施收敛回来。反过来,如果某个状态一旦丢失就会造成资损或者不可逆的业务后果,那它就不该只存在 Redis 里。
我个人的经验是:Redis 做协调器的前提,永远是数据库里有一道真正的“最后防线”,比如唯一索引、状态机流转约束、版本号。Redis 的幂等是挡在门前的一道帘子,数据库的唯一约束才是门锁。帘子被风吹开了不至于出大事,门锁没了那是真出事。
这条边界搞清楚了,后面三块的方案设计才有底气。下面我按幂等、锁、限流的顺序拆,每一块都给到能直接抄的代码和踩过的坑。
2. 接口幂等的两条拦截路径
2.1 先把幂等键设计明白,再谈代码
写幂等代码之前,最容易翻车的不是技术选型,而是幂等键的表达能力。我见过不少团队上来就写一个idempotent:{userId},结果这个用户所有请求都被判成重复,业务直接瘫掉。幂等键必须能唯一标识“一次业务操作意图”,注意是意图,不是请求本身。
设计上有几层考虑。第一层是业务唯一标识,比如订单号、支付流水号、外部渠道的请求号,这类天然唯一的东西优先用,最省事也最准确。第二层是组合键,当业务没有唯一号时,用“操作类型 + 用户 ID + 业务主体 ID”拼出来,例如“领券 + 用户 1001 + 活动 88”。第三层是请求参数摘要,把请求体的关键字段排序后做 MD5 或 SHA-256,适用于参数完全一致的重复提交。第三层要慎用,参数里如果带了时间戳或者随机串,摘要每次都不同,幂等就失效了。
另外必须提醒一点:幂等键里千万不要拼时间戳到秒级以下的精度。有些同学为了“区分不同请求”,在键里加了当前毫秒数,那这个键永远不可能重复,幂等形同虚设。这类 bug 排查起来特别费劲,因为日志里看每次 key 都不一样,你会以为是 Redis 的问题。
2.2 路径一:提前发令牌,消费即作废
这是最经典也最直观的一种做法,核心思想是“一次操作只能消耗一个令牌”。流程分两步:客户端在进入正式业务前,先调一个申请令牌的接口;服务端生成一个随机串存进 Redis 并设置过期时间,把令牌返给客户端;客户端带着这个令牌发起真正的业务请求,服务端在拦截层校验并删除令牌。
令牌的生成侧很简单,用 UUID 就够了,关键是过期时间要贴合业务的实际操作时长。
public String issueToken(String scene) { String token = UUID.randomUUID().toString().replace("-", ""); String key = "idem:token:" + scene + ":" + token; // 10 分钟足够用户完成一次表单填写,太长会占用内存 stringRedisTemplate.opsForValue().set(key, "1", 10, TimeUnit.MINUTES); return token; }校验侧必须用 Lua,因为“先 GET 判断再 DEL”这两步之间如果有并发请求插进来,两个请求都会读到令牌存在,然后都执行成功,幂等直接失效。这是这条路径最常被写错的地方。
-- KEYS[1] = 令牌 key -- ARGV[1] = 期望的令牌值(可选,做二次校验) if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end if ARGV[1] ~= '' and redis.call('GET', KEYS[1]) ~= ARGV[1] then return -1 end redis.call('DEL', KEYS[1]) return 1返回值约定成三态:1表示令牌有效且已消费,0表示令牌不存在或已被用掉(重复请求),-1表示令牌内容不匹配(可能是伪造或串号)。拦截层根据这三个值给出不同的响应,重复请求返回一个明确的业务码而不是 500,前端才能做正确提示。
这条路径的优点是语义清晰、误判率低,缺点是多一次网络交互。申请令牌和提交业务是两次调用,移动端弱网时体验会有感知。所以它更适合表单提交、支付确认这类低频高价值的操作,不适合高频接口。
2.3 路径二:业务键加 SETNX,一次调用搞定
如果不想多一次握手,那就走第二条路:直接在业务接口上加拦截,用业务唯一键做SET NX EX,成功就放行,失败就判定为重复。这条路径把“判断”和“占位”合并成一个原子命令,性能更好,实现也更紧凑。
我一般会写一个注解加一个切面,注解上声明幂等键的表达式和过期时间。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { // 支持 SpEL,例如 "#req.orderNo" String key(); // 幂等窗口,单位秒 int expire() default 60; // 重复时返回的提示语 String message() default "请勿重复提交"; }切面里的核心逻辑就一行setIfAbsent,但参数选择很讲究:
@Around("@annotation(idempotent)") public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable { String bizKey = SpelUtil.parse(idempotent.key(), pjp); String redisKey = "idem:lock:" + pjp.getSignature().toShortString() + ":" + bizKey; Boolean ok = stringRedisTemplate.opsForValue() .setIfAbsent(redisKey, "1", idempotent.expire(), TimeUnit.SECONDS); if (Boolean.FALSE.equals(ok)) { throw new BizException(409, idempotent.message()); } try { return pjp.proceed(); } catch (Throwable e) { // 业务失败时释放,允许用户重试 stringRedisTemplate.delete(redisKey); throw e; } }这里有个细节值得展开:失败要不要删 key。如果业务执行抛异常了,说明这次操作没有产生效果,把 key 删掉让用户能重试是合理的,否则用户看到“请勿重复提交”会一脸懵,明明刚才报错了。但如果业务是超时异常,情况就麻烦了——超时时业务可能已经成功也可能没成功,此时删 key 会带来风险,不删 key 又会让用户无法重试。我的处理方式是:只有明确的业务异常(参数校验失败、业务规则不满足)才删 key,超时类异常不删,交给上游用查询接口确认状态。这个区别一定要在代码里体现出来,不能一刀切。
expire的取值也要掂量。太短,用户在窗口期外重复提交依然会重复执行;太长,同一笔业务在一段时间内无法再次发起(比如同一订单的多次退款申请,虽然是同一订单号,但属于不同操作,键里必须带上操作类型)。我的经验值是取“业务平均处理时长的 3 到 5 倍”,普通写接口 30 到 60 秒,涉及外部调用的放到 5 分钟。
2.4 拦截层放在网关还是服务内部
这个问题争论很久了。网关做幂等的好处是统一、无侵入,坏处是网关拿不到业务语义,只能用 URL 加请求体做摘要,误判概率高,而且所有请求都过一遍 Redis,网关的压力会明显上升。服务内部做的好处是能拿到业务参数,判断精准,坏处是每个服务都要引一份组件。
我的取舍是:通用防重放(比如带 nonce 的签名校验)放网关,业务幂等放服务内部。理由是这两者的判断依据完全不同,前者看的是请求本身是否被重放过,后者看的是业务状态是否允许再次执行。混在一起做,两边都做不好。
还有一点经常被忽略:拦截层返回的响应体要能被前端识别。幂等拦截返回的应该是“业务成功但本次未执行”的语义,而不是错误。有些团队直接抛 500,前端弹一个红色报错框,用户体验极差。我习惯返回一个固定结构,带上duplicated: true标记和上一次的执行结果摘要,前端据此提示“操作已提交,请勿重复点击”,这比报错友好太多了。
3. 分布式锁:从手写 SETNX 到 Lua,再到 Redisson
3.1 手写锁的第一版为什么不能上生产
最早写分布式锁,大家基本都是这个流程:
Boolean ok = redis.opsForValue().setIfAbsent(lockKey, "1"); if (ok) { redis.expire(lockKey, 30, TimeUnit.SECONDS); try { doBusiness(); } finally { redis.delete(lockKey); } }这段代码有两个致命问题。第一个是setIfAbsent和expire不是原子的,如果进程在两条命令之间挂掉,锁就永不过期,直接变成死锁。第二个是释放锁的时候没校验持有者,A 的业务执行超时(超过 30 秒锁已过期),B 拿到了锁,这时候 A 执行完delete把 B 的锁删掉了,互斥彻底失效。
第一个问题用SET key value NX PX 30000一条命令就能解决,Spring Data Redis 里对应setIfAbsent(key, value, timeout, unit),底层就是这条命令。第二个问题必须靠 Lua 解决,因为“判断值是否属于自己”和“删除”这两步必须原子。
3.2 Lua 方案一:加锁与解锁的原子化
我通常会给锁的 value 生成一个唯一标识(UUID 加线程 ID),这样解锁时能精确判断归属。加锁本身一条命令搞定,重点是解锁脚本:
-- KEYS[1] = 锁 key -- ARGV[1] = 当前持有者的唯一标识 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end返回值1表示释放成功,0表示锁已不属于自己(可能已过期或被别人拿走),这种情况要在日志里打一条 warn,因为它意味着业务执行时间超过了锁的租期,是个需要关注的信号线。
加锁侧的 Java 代码:
public boolean tryLock(String key, String owner, long ttlMillis) { Boolean ok = stringRedisTemplate.opsForValue() .setIfAbsent(key, owner, ttlMillis, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(ok); } public boolean unlock(String key, String owner) { Long r = stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key), owner); return r != null && r == 1L; }脚本通过DefaultRedisScript<Long>定义,Spring 启动时会用EVALSHA执行,脚本首次加载后就不用再传脚本体了,网络开销很小。这里提醒一句,不要把脚本定义成每次 new 一个对象,放在静态常量里,让 Spring 复用 sha1,性能差别在小流量下看不出来,但脚本较多时确实能省下不少带宽。
释放锁一定要放在finally里,而且finally里还要包一层 try-catch,避免释放锁本身抛异常盖住业务异常。
3.3 Lua 方案二:可重入加主动续期
单层锁用上面的方案够用了,但一旦业务里出现嵌套调用(外层方法加了锁,内层方法对同一个 key 又要加锁),单层锁会把自己挡在门外,直接死锁。这时候需要可重入锁,实现思路和 Java 的ReentrantLock一样:用 Hash 结构存“持有者标识 → 重入次数”,加锁时如果是自己就计数加一,解锁时计数减一,减到零才真正删除 key。
加锁脚本:
-- KEYS[1] = 锁 key ARGV[1] = owner ARGV[2] = 租期毫秒 if redis.call('EXISTS', KEYS[1]) == 0 then redis.call('HSET', KEYS[1], ARGV[1], 1) redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then redis.call('HINCRBY', KEYS[1], ARGV[1], 1) redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end return 0解锁脚本:
-- KEYS[1] = 锁 key ARGV[1] = owner ARGV[2] = 租期毫秒 local cnt = redis.call('HGET', KEYS[1], ARGV[1]) if cnt == false then return 0 end if tonumber(cnt) > 1 then redis.call('HINCRBY', KEYS[1], ARGV[1], -1) redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end redis.call('DEL', KEYS[1]) return 1注意每次重入都会重新续期,这让“锁的剩余时间”始终跟着最后一次持有走。这个设计有个副作用:如果内层一直重入,锁就永远不会过期,业务如果卡死在里面,外部也拿不到锁。所以我一般会限制重入深度,超过 5 层直接抛异常,属于防御性编程。
主动续期则是另一个维度的问题。租期设长了,进程崩溃后锁要等很久才释放;设短了,业务没执行完锁就过期了。解决思路是起一个后台线程,在锁快过期时(比如剩余时间小于租期的三分之一)自动延长,这就是所谓的“看门狗”。自己写看门狗的坑很多:线程生命周期和锁要绑定、续期失败要能感知、续期线程本身不能阻塞业务线程。这也是我后来倾向于直接用 Redisson 的原因——它把这套逻辑写好了。
3.4 Redisson 的正确打开方式
Redisson 的价值在于它把可重入、看门狗、公平锁、读写锁、红锁这些能力都封好了,API 用起来和java.util.concurrent非常像。引入依赖后配置一个客户端:
@Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setDatabase(0) .setConnectionMinimumIdleSize(8) .setConnectionPoolSize(32) .setTimeout(3000); return Redisson.create(config); }使用上最典型的一段:
RLock lock = redissonClient.getLock("lock:order:" + orderId); boolean locked = false; try { locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { throw new BizException(429, "操作过于频繁,请稍后再试"); } doBusiness(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(500, "获取锁被中断"); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } }这里有三个必须强调的点。第一,tryLock一定要带等待时间,不带参数的lock()会一直阻塞,一旦锁泄漏,线程池会被瞬间打满。第二,不要随手传 leaseTime。tryLock(3, 30, TimeUnit.SECONDS)这种写法一旦指定了租期,看门狗就失效了,锁到期自动释放,业务还没跑完的话互斥就丢了。要续期就别传租期,让默认的 30 秒加上看门狗来管。第三,unlock必须判isHeldByCurrentThread,因为tryLock超时失败后如果无脑 unlock,会抛IllegalMonitorStateException,把正常的业务异常给盖掉。
isHeldByCurrentThread这个判断还有个隐藏价值:它能帮你发现锁被跨线程误释放的问题。有些同学在 A 线程加锁,把业务丢给线程池执行,然后在线程池里解锁,这种情况判断直接返回 false,问题立刻暴露。锁必须同线程加、同线程放,这是使用纪律。
关于锁粒度,我见过最典型的错误是拿一个用户 ID 去锁所有操作,导致同一用户查订单和改订单互相阻塞。正确的做法是“资源 ID 加操作类型”作为锁 key,比如lock:stock:sku10086、lock:settle:order20240101,粒度越细,冲突越少,吞吐越高。
4. 用 Redis 做限流:从简单计数到令牌桶
4.1 固定窗口计数与它的临界突刺
最简单的限流就是固定窗口计数:给每个时间窗口一个 key,每次请求INCR,第一次的时候设置过期,超过阈值就拒绝。
public boolean allow(String api, int limit, int windowSeconds) { String key = "rate:" + api + ":" + (System.currentTimeMillis() / 1000 / windowSeconds); Long count = stringRedisTemplate.opsForValue().increment(key); if (count != null && count == 1L) { stringRedisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS); } return count != null && count <= limit; }这段代码能跑,但有两个毛病。第一,increment和expire不是原子的,如果进程在这两步之间挂掉,key 会一直存在,后续永远超限。用 Lua 包一下就能解决。第二,固定窗口有临界突刺问题:假设限制是 100 次每秒,窗口是 0 到 1 秒和 1 到 2 秒,如果客户端在 0.9 秒打了 100 次,在 1.1 秒又打了 100 次,虽然每个窗口都没超,但在 0.2 秒的区间内实际打了 200 次,后端的感受就是瞬间被打穿。
这个缺陷在小流量下无关痛痒,因为余量足够,但在容量紧平衡的系统里就是事故导火索。所以固定窗口只适合做粗粒度的保护,比如“每个用户每天最多调用 1000 次”这种跨度很大的限制,窗口边界的影响可以忽略。
4.2 滑动窗口与令牌桶的 Lua 实现
想消除边界突刺,最直接的是滑动窗口。用 ZSET 存每次请求的时间戳,score 用毫秒时间戳,member 用唯一串(毫秒时间戳加随机数,防止同一毫秒的请求被去重)。每次请求先删掉窗口外的记录,再数一遍窗口内的数量。
-- KEYS[1] = 限流 key -- ARGV[1] = 当前时间毫秒 ARGV[2] = 窗口毫秒 ARGV[3] = 阈值 ARGV[4] = 请求唯一标识 local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window) local cnt = redis.call('ZCARD', KEYS[1]) if cnt < limit then redis.call('ZADD', KEYS[1], now, ARGV[4]) redis.call('PEXPIRE', KEYS[1], window) return 1 end return 0滑动窗口的精度高,代价是内存。每个请求都要存一条 ZSET 记录,如果阈值是每秒 1000、窗口是 60 秒,那单个 key 就有 6 万条记录,几千个热点 key 内存就吃紧了。所以它适合阈值不大的接口,比如单用户维度的限制。
令牌桶是另一条路,它关心的不是“过去一段时间请求了多少次”,而是“桶里还有多少令牌”。桶以固定速率补充令牌,请求来的时候取令牌,取不到就拒绝,取到了就消耗一个。它的好处是允许一定程度的突发——桶容量就是允许的最大突发量,长期看平均速率又被限制住。用 Hash 存“当前令牌数”和“上次补充时间”两个字段就够了。
-- KEYS[1] = 桶 key -- ARGV[1] = 容量 ARGV[2] = 每秒补充速率 ARGV[3] = 当前时间毫秒 ARGV[4] = 本次需要令牌数 local cap = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local need = tonumber(ARGV[4]) local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(data[1]) local ts = tonumber(data[2]) if tokens == nil then tokens = cap end if ts == nil then ts = now end local delta = math.max(0, now - ts) / 1000.0 tokens = math.min(cap, tokens + delta * rate) if tokens < need then redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ts) return 0 end tokens = tokens - need redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now) redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate) * 1000 + 1000) return 1写这段脚本要特别注意 Lua 里tonumber对false的处理,HMGET取不到字段返回的是false而不是nil,tonumber(false)会直接报错。所以我先取出来,判nil之后再赋值。这种细节在本地测试时不一定暴露,因为本地第一次调用之后字段就存在了,只有全新 key 的第一炮会踩到。
4.3 现成组件和自研的取舍
限流到底自己写还是上组件,我的判断标准是看限流维度和部署形态。如果只是单机保护,用本地限流器(信号量、令牌桶的实现类)性能最好,完全不用碰网络。如果是集群维度,需要一个全局准确的上限,那必须用共享存储或者专门的限流服务。
网关层的限流插件适合做入口保护,它天然知道请求的来源和路径,配置也集中。但它毕竟在入口处,对“调用下游某个具体服务”的粒度控制不够细。业务层自研的好处是能按业务语义限流,比如“每个商户每秒最多发起 50 笔查询”,这种维度网关很难理解。
一个实际的做法是两层都上:网关做全站级的粗粒度保护,防止突发流量打穿承载能力;业务服务做细粒度的业务限流,保护下游依赖。两层阈值要有梯度,入口阈值要大于业务阈值,这样业务限流先触发,能给出更友好的提示。
另外提醒一个常被忽略的点:限流的 key 要能被回收。我见过一个项目按设备 ID 限流,结果被刷了上百万个临时设备 ID,Redis 内存一夜之间涨了几个 G。这种情况要么给 key 加过期时间,要么在接入层先做设备合法性校验,别让脏流量直接打到限流逻辑里。
5. 常见问题与排查实录
5.1 高频故障速查表
这套东西上线之后,问题往往不出在核心逻辑,而出在各种边界情况。我把自己和团队遇到过的问题整理成一张表,出问题的时候先对着扫一遍,能省下不少时间。
| 现象 | 常见原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 幂等失效,重复请求仍然执行 | 幂等键拼了时间戳/随机数,每次都不同 | 打日志输出最终 key,比对两次请求 | 去掉随机因子,改用业务唯一键 |
| 业务报错后无法重试 | 失败未释放 key,或释放逻辑被吞异常 | 查异常栈是否匹配释放分支 | 明确业务异常才释放,超时不释放 |
| 分布式锁互斥失效 | 加锁用了先 set 后 expire | 检查是否用了setIfAbsent带 TTL | 改为原子命令,锁值带持有者标识 |
| 锁被其他线程释放 | unlock 前未校验持有者 | 查是否抛过IllegalMonitorStateException | 加isHeldByCurrentThread判断 |
| 锁到期业务仍在跑 | 传了 leaseTime,看门狗失效 | 看业务耗时是否接近租期 | 去掉 leaseTime,或按实际耗时调租期 |
| 限流阈值明显不准 | 固定窗口临界突刺 | 统计窗口交界处的请求分布 | 换滑动窗口或令牌桶 |
| 限流 key 内存暴涨 | key 无过期或维度太分散 | SCAN抽样看 key 的 TTL | 补过期时间,收敛 key 维度 |
| Lua 脚本偶发报错 | 首次执行时字段不存在 | 看报错是否集中在全新 key | 用or兜底默认值 |
| 重复请求返回 500 | 拦截层直接抛系统异常 | 看响应体是否带业务码 | 统一返回重复提示的业务码 |
5.2 容量与参数怎么估算
参数拍脑袋是这个领域最常见的隐患。我给几个自己常用的估算方法。
幂等 key 的内存占用,取决于 QPS 和过期时间。假设某个写接口峰值 500 QPS,幂等窗口 60 秒,那同时在存的 key 大约 3 万个,每个 key 连 key 名、value、过期时间这些开销按 100 字节算,总共 3MB 左右,可以忽略。但如果把所有接口都套上幂等,QPS 总和到了 5 万,那就是 300MB,这时候就得考虑缩短窗口或者只对关键接口开启。
锁的关键参数是租期。我的算法是:先压测出执行业务的 P99 耗时,租期取 P99 的 2 到 3 倍,同时必须小于等待超时。比如 P99 是 800 毫秒,租期设 3 秒,等待时间设 1 秒。等待时间大于租期是没意义的,因为等不到那么久锁就该释放了,而业务方通常已经超时放弃。
限流的阈值估算要留余量。真正可靠的数字来自压测,而不是理论推算。我的做法是压到下游依赖开始出现毛刺(数据库连接池等待、下游接口 RT 抖动)的那个点,取它的 70% 作为限流阈值。剩下 30% 是给突发流量和 GC 抖动留的缓冲,宁可平时限制稍微保守一点,也不要在流量高峰被打穿。
5.3 踩过之后才明白的几件事
第一件事,别把幂等和锁混用成一套逻辑。有些项目里幂等直接用分布式锁实现,请求进来先加锁,业务跑完释放。逻辑上没错,但代价是全量请求都要经历一次加锁解锁,而且失败重试的时候锁已释放,重复请求照样能进来。幂等需要的是“留下痕迹”,锁需要的是“临时互斥”,目标不同,实现就不该混在一起。
第二件事,Redis 的时钟和业务机器的时钟可能不一致。滑动窗口和令牌桶都依赖时间戳,如果多台业务机器的本地时间有漂移,限流就会出现忽松忽紧。稳妥的做法是用 Redis 服务端时间(脚本里没法直接拿,但可以在应用层统一从 RedisTIME命令获取,或者干脆由接入层统一注入时间戳),不要依赖各节点自己的System.currentTimeMillis()。
第三件事,脚本要预热。Lua 脚本第一次执行走的是EVAL,之后才走EVALSHA。如果生产环境做了脚本禁用EVAL的加固,第一次调用就会直接失败。稳妥的做法是应用启动时主动SCRIPT LOAD一次,或者至少保证有健康检查流量能提前触发加载,别等到大促高峰才暴露。
第四件事,任何基于 Redis 的协调都要考虑 Redis 本身的故障降级。Redis 挂了,幂等拦截是放行还是拒绝?加锁失败是阻塞还是直接抛错?限流判断异常是放行还是拒绝?我的选择是:幂等失效时放行(因为数据库唯一索引能兜底),加锁失败时拒绝(没有锁就没有互斥,宁可失败也不要脏写),限流判断异常时放行(限流是保护手段,不该成为新的单点)。这三条降级策略一定要写进代码,并且用配置开关控制,不然真出事的时候只能干看着。
第五件事,关于 Redisson 的看门狗,它对业务线程是有要求的——续期任务是绑在加锁线程所在的事件循环上的,如果业务用了异步线程池并且把锁对象跨线程传递,续期就可能失效。这类问题在压测时往往看不出来,只有在长时间运行后偶发锁提前释放。我的建议是锁的加解锁严格限制在同一线程内,跨线程场景老老实实用带租期的方案,接受它可能提前释放的现实,再靠业务层的幂等做二次保护。
最后分享一个排查技巧。遇到“偶尔重复执行”这类幽灵问题,我会在幂等和锁的日志里统一打三个字段:key、操作结果、traceId。然后按 traceId 把一次完整请求链路的所有日志拉出来,对着看两个重复请求的时间差、它们各自拿到的 Redis 返回码。十次里有九次,答案就在那两个返回码的差异上——一个是1,一个是0,说明拦截其实生效了,问题出在更上游(比如请求根本没走拦截器,被前端直连了另一个入口)。这种“先确认拦截是否生效,再往上游追”的排查顺序,比一上来就怀疑 Redis 要高效得多。