Redis幂等、分布式锁与限流:高并发接口防重与并发控制 1. 先把这三个需求摆到台面上它们其实是同一类问题我在项目里第一次把Redis 幂等、分布式锁、限流这三样东西同时用上是在一个下单接口上。当时的情况很典型用户手抖点了两下提交前端没做防抖服务端也没做任何校验结果两笔订单、两份库存扣减、两条发货单财务那边对账对了整整两天。后来加需求又碰上定时任务在多实例部署下重复执行、第三方回调重复推送、大促时某个查询接口被打爆。这三件事表面上看是三个需求实际上底层都在解决同一个问题在分布式环境下对某件事只能发生一次或者某段时间内只能发生 N 次这件事达成共识而单机时代的synchronized、AtomicInteger、ConcurrentHashMap在这种场景下全部失效因为它们的作用域只在一个 JVM 进程里。Redis之所以成为这类问题的默认答案核心在于它是少数几个能在毫秒级延迟下提供跨进程原子操作的组件。SET key value NX PX 30000这一条命令里同时包含了不存在才写入和设置过期时间两个语义并且是原子的这一条命令就撑起了幂等和锁两套方案的基础。至于限流本质也是计数 过期只不过计数的窗口形状不同。这篇内容适合三类人看一是刚接触分布式场景、被重复请求搞过的后端同学二是已经在用 Redis 但方案写得不踏实的比如锁解锁没收 value 校验、幂等只做了个SETNX没管过期三是想搞清楚 Redisson 到底替你干了什么不想只会lock()和unlock()的。下面我从设计取舍讲到具体代码从踩过的坑讲到参数怎么算尽量把每一步为什么这么写说透。2. 整体方案怎么拆先定边界再定粒度2.1 三个需求的边界划分别混着用新手最容易犯的错是把这三个东西的职责搞混比如用分布式锁去实现幂等、用幂等去替代限流。我先把边界划清楚后面讲代码的时候你才知道自己在写哪一层。需求核心目标作用范围失败时的表现典型场景幂等同一次业务意图重复执行结果一致单次业务请求、单条消息重复扣款、重复下单、重复发券支付回调、下单提交、消息消费分布式锁同一时刻只允许一个执行者进入临界区跨实例的并发控制超卖、并发覆盖、重复调度库存扣减、定时任务、缓存重建限流单位时间内请求量不超过阈值接口、用户、IP 维度服务雪崩、下游被打挂秒杀、登录、开放接口这三个的时间尺度也不一样幂等关注的是这一次请求和上一次请求是不是同一个窗口可能长达几十秒甚至几分钟分布式锁关注的是我执行这几百毫秒里别人别进来限流关注的是这一秒、这一分钟的总量。把时间尺度想清楚key 的 TTL 就自然有数了。还有一个常见误区幂等不等于加锁。加锁只能保证同一时刻不并发但两个请求一前一后串行进来锁是能拿到的业务照样执行两遍。所以幂等必须依赖状态凭证而不是互斥这一点想不通代码写出来就是错的。2.2 为什么是 Redis不是数据库唯一索引或 ZooKeeper数据库唯一索引做幂等其实是最可靠的方案落库即约束不需要额外组件MySQL 的INSERT ... ON DUPLICATE KEY用起来很顺手。但它有两个硬伤一是性能高频接口走唯一索引冲突会产生大量异常和回滚主从同步也有压力二是时机很多重复请求在进入数据库之前就该被拦掉比如重复支付回调等你查到唯一键冲突时前面的外部调用可能已经发出去了。ZooKeeper 的临时顺序节点做锁很优雅会话断开自动释放一致性也强。但它的写入是走 ZAB 协议的吞吐比 Redis 低一个数量级而且我们线上服务已经有 Redis 集群了为了一个锁再维护一套 ZK 集群运维成本不划算。我个人的判断标准是如果只是锁和计数这种短暂状态用 Redis如果是必须绝对可靠的元数据用数据库只有对一致性有极端要求的选主场景才考虑 ZK 或 etcd。Redis 的短板也要说清楚主从异步复制主节点宕机时可能丢锁过期时间是逻辑时间不是真正的到期即删。所以严格的场景下Redis 锁必须配合业务侧的兜底校验比如库存扣减最终还是要靠数据库的update stock set num num - 1 where num 1这种带条件的原子更新来保证不超卖Redis 锁只是把并发量挡在数据库之前减少冲突不是唯一防线。这个认知不到位早晚出事故。2.3 key 设计的三条约定方案落地前先把 key 规范定下来不然半年后没人看得懂 Redis 里存的是什么。前缀分层业务域:功能:标识例如idem:order:U10086:SK202405、lock:stock:SK202405、limit:api:order:submit。冒号分层的好处是可以用SCAN前缀匹配做排查也方便在监控里按前缀看命中和内存。值要有身份锁的 value 存一个随机 UUID 或实例ID线程ID幂等的 value 存业务结果摘要或状态。永远不要用固定值否则解锁时可能删掉别人的锁。TTL 必填且要有上限没有 TTL 的 key 是定时炸弹。幂等 key 一般 5 秒到 5 分钟看业务窗口锁的 TTL 一般覆盖业务 P99 的 2 到 3 倍限流 key 的 TTL 等于一个窗口长度再加一点冗余。提示所有 key 都要能在监控面板里按前缀看到 QPS 和内存占用。曾经有个idem:前缀的 key 因为前端重试逻辑写错一天写入了几百万条且 TTL 设成了 7 天直接把集群内存打到了 80%这个教训很值钱。3. 接口幂等把重复请求挡在业务逻辑之外3.1 先判断哪些接口需要幂等别一刀切全加不是所有接口都值得做幂等。GET查询天然幂等加了一层纯属浪费PUT全量更新如果只是覆盖天然幂等真正需要处理的是有副作用的写操作下单、支付、退款、发券、扣库存、发消息、第三方回调、消息队列消费。我更倾向于按副作用强度分级级别特征处理方式弱副作用重复执行只多一条日志或统计不做处理中副作用重复执行产生多余数据但可回滚幂等键 短 TTL强副作用涉及资金、库存、权益幂等键 唯一索引双保险举个具体例子。发券接口重复执行会让用户多领一张券属于强副作用必须处理埋点上报接口重复上报只是统计稍微虚高属于弱副作用做幂等反而增加复杂度。分级做完你会发现真正需要写代码的接口其实没几个剩下的都是过度设计。3.2 令牌机制的第一种放法客户端先取票提交时带票这是最经典的双阶段令牌方案进入业务页面时先调一次GET /token拿到一张票提交时把票放在请求头X-Idempotent-Token里服务端校验并消费这张票。为什么这样设计因为取票这一步天然把用户操作和网络重试分开了。用户点第一次提交票被消费手抖点第二次票已经不存在服务端直接拒绝。而票是在页面加载时申请的重复点击不会重新申请票语义上是干净的。服务端校验的核心是比对并删除必须原子这就得靠 Lua-- KEYS[1] 令牌 keyARGV[1] 客户端提交的 token 值 local stored redis.call(GET, KEYS[1]) if stored ARGV[1] then redis.call(DEL, KEYS[1]) return 1 else return 0 end这里为什么不能写成GET之后再DEL因为两步之间有时间窗口两个并发请求可能同时GET到相同的值都判定通过然后都执行DEL幂等直接失效。Redis 单线程执行 Lua 脚本是原子的所以把比对和删除打包进去才能杜绝这个窗口。这是必须用 Lua 的第二个场景第一个是锁解锁后面会讲。服务端封装成 AOP 注解会更省事业务代码只加一行Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Idempotent { String prefix() default idem:token; long expireSeconds() default 300; String message() default 请勿重复提交; }切面里做的事从请求头取 token为空直接抛异常构造 key执行 Lua返回 0 抛业务异常返回 1 放行。注意令牌要在业务执行前消费而不是业务成功后再删。因为如果业务抛异常后你想让用户重试那就应该让客户端重新取票而不是复用旧票。这个顺序搞反会出现业务失败但票没了和业务失败票还在重试时又执行一次两种混乱。3.3 令牌机制的第二种放法服务端根据业务参数自算幂等键第一种方案需要前端配合很多老系统改不动或者调用方是第三方根本不给你取票的机会。这时候用服务端自算幂等键把请求里能唯一标识业务意图的字段拼起来做一次哈希直接当 key 用SET NX占位。拼什么字段是这套方案的灵魂。以订单提交为例我一般用用户ID 业务类型 客户端流水号。第三个字段很关键客户端生成一个 UUID 放在请求头同一次点击的重试会带同一个 UUID用户重新下单会生成新的。这样既挡住了网络层的自动重试也放过了用户真实的二次下单意图。如果客户端实在不可控退化方案是用户ID 业务类型 请求参数摘要但要注意有些参数是每次都变的比如时间戳、随机数得先剔除掉否则幂等键永远不重复等于没做。我见过一个实现把整个请求体做 MD5结果请求体里带了个traceId每次都不同幂等完全失效上线后才发现。public boolean tryAcquire(String bizKey, long seconds) { String key idem:biz: bizKey; Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofSeconds(seconds)); return Boolean.TRUE.equals(ok); }setIfAbsent底层就是SET key value NX PX一条命令搞定不存在SETNX加EXPIRE两步之间的死锁风险。老代码里经常看到这种写法// 反例不要这么写 if (redis.setnx(key, 1)) { redis.expire(key, 60); // 如果这行没执行key 永久存在 }用SETNX加EXPIRE两步中间如果进程被 kill 或者抛异常key 就变成了永不过期的死键之后所有同 key 的请求全被挡住。这个坑我在一个老项目里见过最后是手工SCAN加DEL清了两万多条脏数据才恢复。3.4 两种放法怎么选以及几个必须处理的边界选型上我的经验是有前端页面、需要提示用户正在处理中的用取票方案交互体验好纯接口、调用方是第三方或者老客户端的用自算键方案改造面小。两者也可以叠加取票挡住页面重复点击自算键挡住接口重放。边界问题有几个必须处理第一幂等键的 TTL 要覆盖业务的最长重试窗口。有些客户端的重试策略是 30 秒内重试 3 次那你 TTL 设 5 秒第二次重试时 key 已经过期幂等失效。我一般设 5 到 10 分钟业务方有明确重试策略的按策略来。第二幂等只保证不重复执行不保证一定成功。如果第一次执行到一半失败了key 还在用户重试会被拒绝用户会觉得点了没反应。解决办法是记录执行状态key 的 value 存PROCESSING、SUCCESS、FAIL重试时先查 valuePROCESSING返回处理中FAIL允许覆盖重试SUCCESS直接返回上次结果。这就从幂等升级到了幂等 结果一致性代价是要多存一点状态。第三别把幂等键和业务数据混在一起。有些实现图省事直接用业务主键当幂等键结果业务数据一删幂等键也跟着没了历史重复请求又能打进来。幂等键应该是独立的命名空间生命周期比业务数据短但比请求周期长。注意幂等做在网关层还是业务层要明确。如果多个业务共用一套网关拦截key 的组成必须带上业务标识否则 A 业务的 token 可能被 B 业务误消费。我就见过一个公共组件把 key 写成idem:加 token两个团队共用之后一个团队的请求把另一个团队的令牌删了排查了半天才发现。4. 分布式锁从手写 SET NX 到 Redisson 的完整演进4.1 手写第一版SET NX PX 加随机值只解决了一半问题最朴素的加锁就是一条命令SET lock:stock:SK202405 e3b0c442-... NX PX 30000这条命令同时做了三件事key 不存在才写入、值是唯一的随机串、30 秒后自动过期。返回OK表示拿到锁返回nil表示被别人占了。加锁这一步本身没有问题问题全在解锁。早期我写的解锁是这样的// 反例 public void unlock(String key) { redisTemplate.delete(key); }问题在于线程 A 拿锁后业务卡住30 秒到了锁自动释放线程 B 拿到锁开始执行这时线程 A 业务跑完回来delete删的是 B 的锁。接着线程 C 又拿到锁B 和 C 并发执行锁彻底失效。这就是误删别人的锁是分布式锁最经典的坑。正确做法是解锁前先校验 value 是不是自己的。但GET和DEL分开写又有并发问题A 校验通过后、执行DEL前锁刚好过期B 拿到锁A 的DEL又删了 B 的。所以必须用 Lua 把比对 删除打包成原子操作-- KEYS[1] 锁的 keyARGV[1] 当前线程持有的随机值 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 endJava 侧封装private 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 boolean unlock(String key, String requestId) { Long result redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key), requestId); return result ! null result 1L; }这一步做完锁的正确性就基本到位了。但还有个要命的问题业务执行时间超过锁的 TTL。业务跑 45 秒锁 30 秒就过期了第二个线程进来两个线程并发执行前面的努力全白费。4.2 手写第二版Lua 实现续期思路对了但实现很别扭解决超时问题有两条路一是把 TTL 设得足够大比如 5 分钟代价是如果进程崩了锁要 5 分钟才释放严重影响可用性二是在业务执行期间自动续期也就是看门狗。手写续期通常是这样拿到锁之后起一个后台线程每TTL/3毫秒执行一次续期 Lua同样带 value 校验-- KEYS[1] 锁 keyARGV[1] 持有者标识ARGV[2] 新的 TTL毫秒 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(PEXPIRE, KEYS[1], ARGV[2]) else return 0 end校验 value 是必须的否则会出现锁已经被别人拿走了我还在给人家的锁续命这种荒唐事相当于把别人的锁无限期锁死。自己实现的坑在于调度和生命周期管理续期线程什么时候启动、什么时候停止、业务异常时怎么保证线程退出、线程池要不要复用、应用关闭时怎么优雅停机。这些细节写起来非常啰嗦而且容易出内存泄漏。我见过一个实现续期线程用的是new Thread业务方法抛异常时 finally 里只unlock没有中断线程跑了几小时之后应用堆里堆了几千个僵尸线程在做无用的续期调用。到这里你会发现手写锁其实已经是能跑但不好维护的状态了。工业级的分布式锁要处理可重入、公平性、阻塞等待、续期、异常安全、可监控这些自己做一遍的成本远高于引入一个成熟库。4.3 Redisson把上面所有细节封装成一个 RLockRedisson 的核心价值不是帮你调 Redis而是把上面那些坑全部踩过一遍并封好了。用法很简单RLock lock redissonClient.getLock(lock:stock: skuId); boolean locked false; try { locked lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后再试); } // 业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(获取锁被中断); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } }两个细节值得说isHeldByCurrentThread()判断是必须的防止没拿到锁却去解锁Redisson 里没持有锁去unlock会抛IllegalMonitorStateExceptiontryLock带等待时间避免无限阻塞把线程池打满。它的看门狗机制是我最看重的部分。当你调用不带leaseTime的lock()或tryLock(waitTime, unit)时Redisson 默认给锁设 30 秒 TTL然后启动一个后台任务每隔30/3 10秒检查一次如果当前线程还持有锁就重置 TTL 回 30 秒。业务跑多久锁就续多久只要进程活着锁就不会莫名释放进程一挂续期停了30 秒后锁自动释放不会死锁。这里有个非常容易踩的坑如果你显式传了leaseTime看门狗就不生效了。// 看门狗生效自动续期 lock.tryLock(3, TimeUnit.SECONDS); // 看门狗不生效30 秒后必定释放业务超时就丢锁 lock.tryLock(3, 30, TimeUnit.SECONDS);我见过有同学为了保险把leaseTime写成 30 秒结果业务里有个批量操作跑了 40 秒锁提前释放导致并发排查了很久才发现是这个参数的问题。结论是拿不准业务耗时就别传 leaseTime让看门狗兜底明确知道业务最长耗时再传一个比它大的值。Redisson 还有几个能力值得了解锁类型特点适用场景RLock普通锁默认非公平可重入绝大多数互斥场景getFairLock按请求顺序获取需要严格先来后到的排队getReadWriteLock读写分离读读不互斥缓存重建、配置刷新getMultiLock多个独立锁组合成一个跨多个资源的原子加锁getSemaphore/getCountDownLatch信号量、闭锁并发度控制、任务编排可重入的实现值得一提Redisson 用 Hash 结构存锁key 是锁名field 是UUID:threadIdvalue 是重入次数。同一线程再进来时HINCRBY加 1 并重置过期时间解锁时HINCRBY -1减到 0 才真正DEL。这也是为什么解锁必须用isHeldByCurrentThread判断跨线程解锁会操作到别人的 field。注意Redisson 的unlock()必须写在finally里但要先判断isHeldByCurrentThread否则在未获锁的分支里会抛异常把真正的业务异常覆盖掉日志里看到的全是状态异常找不到根因。4.4 锁的粒度、超时时间怎么定粒度要按最小冲突单元来定。锁整张库存表所有商品互相阻塞吞吐极低锁到skuId级别不同商品互不影响这才是对的。我见过把userId拼进锁 key 的看似更精细但如果业务是扣某个 SKU 的总库存那人人一把锁等于没锁超卖照旧。参数推算走这三步拉监控看这个业务链路的 P99 耗时假设是 800ms锁的 TTL 取 P99 的 3 到 5 倍也就是 3 到 4 秒给峰值留余量等待时间取用户能忍多久接口场景一般 200 到 500ms超过就让用户重试别让他干等。如果用了看门狗TTL 由 Redisson 自动管但等待时间仍然要自己定而且必须定不能是tryLock()无参版本否则高并发下线程会大量堆在等待队列里连接池先被打满。线程池打满之后的连锁反应是接口超时、上游重试、请求量翻倍最后整个服务不可用这个雪崩链路在线上是很常见的。5. 限流最朴素的计数往往是最够用的5.1 固定窗口计数一条 INCR 撑起 80% 的场景限流最怕一上来就上复杂算法。我实际的经验是绝大多数接口用固定窗口就够了代码量极小性能极好理解成本极低。-- KEYS[1] 限流 keyARGV[1] 窗口毫秒数ARGV[2] 阈值 local current redis.call(INCR, KEYS[1]) if tonumber(current) 1 then redis.call(PEXPIRE, KEYS[1], ARGV[1]) end if tonumber(current) tonumber(ARGV[2]) then return 0 end return 1这里用 Lua 的原因是INCR和PEXPIRE必须一起原子完成。如果分两步第一次INCR返回 1 之后进程崩了没设过期时间这个 key 就永远存在那个维度的限流从此永久生效所有请求全被拒。这类事故在小团队里是真实发生过的。key 的构造决定了限流的维度按接口就是limit:api:order:submit按用户就是limit:user:U10086:login按 IP 就是limit:ip:1.2.3.4组合维度就是limit:api:order:submit:U10086。维度选错效果完全不同只按接口限单个用户拿脚本刷照样能把接口打满只按用户限十万用户同时来照样崩。一般是双层限流接口总量 单用户各自一套阈值。固定窗口有一个众所周知的缺陷临界突刺。窗口 1 分钟阈值 100在第 59 秒来 100 个请求第 61 秒又来 100 个两个窗口各自合规但有 2 秒内实际打了 200 个瞬时压力翻倍。如果下游抗不住这种突刺就得换成滑动窗口。5.2 滑动窗口与令牌桶用 ZSET 和 Lua 搞定滑动窗口用有序集合实现score 存时间戳每次请求先清掉窗口外的旧记录再数一遍剩下的-- KEYS[1] 限流 key -- ARGV[1] 当前时间戳毫秒ARGV[2] 窗口长度毫秒 -- ARGV[3] 阈值ARGV[4] 本次请求的唯一 ID local now tonumber(ARGV[1]) local window tonumber(ARGV[2]) local limit tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, now - window) local count redis.call(ZCARD, KEYS[1]) if count limit then redis.call(ZADD, KEYS[1], now, ARGV[4]) redis.call(PEXPIRE, KEYS[1], window) return 1 end return 0代价是内存和 CPU 都上去了每个请求一条 ZSET 记录10 万 QPS 的接口窗口内就是几十万条内存占用是固定窗口的几十倍。所以我的原则是只有对突刺敏感的接口才用滑动窗口比如支付、风控普通查询接口用固定窗口成本低得多。再往上是令牌桶允许一定程度的突发桶容量同时限制长期平均速率。Redis 官方有个redis-cell模块用 GCRA 算法实现令牌桶一条命令就能完成限流判断CL.THROTTLE user:U10086 99 100 60 1参数依次是key、初始已用配额、窗口内总量、窗口秒数、本次消耗量。返回数组里有是否放行、剩余配额、重置时间、重试等待秒数直接能拿来做响应头X-RateLimit-Remaining和Retry-After。用这个模块的好处是不用自己维护时间戳精度也好坏处是得单独装模块很多托管 Redis 服务不一定开放。如果装不了就用 Lua 自己实现简化版令牌桶存上次刷新时间和当前令牌数两个字段每次请求算出这段时间新增的令牌再判断够不够扣。5.3 限流放在哪一层位置比算法更重要很多人把限流写死在业务代码里if (count limit) return;结果一个接口失效整条链路崩。我的经验是分层放层级手段作用接入层网关限流插件、连接数限制挡住明显异常流量保护后端应用层Redis 计数器 拦截器按业务维度精确控制资源层线程池隔离、连接池上限防止单个接口拖垮全局下游保护熔断降级下游挂了快速失败网关层的限流插件配置简单通常按路由维度设 QPS 上限好处是业务代码零侵入坏处是维度粗只能按路径做不了单用户这种细粒度。所以常见的组合是网关按路径挡住总量应用层用 Redis 按用户维度做精细控制。限流被触发后的返回策略也很讲究。直接返回 500 是最差的做法客户端会立刻重试流量翻倍。正确做法是返回 429 状态码带Retry-After头告诉客户端多久后重试同时在客户端做指数退避。我还会在服务端按limit:前缀打点把被限流的 key 和次数报上去这样能一眼看出是哪个用户、哪个接口在被刷比事后翻日志高效得多。注意限流阈值不要拍脑袋。上线前先跑一轮压测找到单机能稳定承载的 QPS然后按实例数乘一个 0.7 的安全系数再分到各个维度上。阈值定得比实际承载高等于没限定得太低正常业务被误杀用户投诉比雪崩还难解释。6. 常见问题与排查实录6.1 问题速查表这套东西上线之后我在监控和工单里反复看到的问题基本是下面这些整理成表方便对号入座现象大概率原因排查动作处理办法重复请求仍被执行两次幂等 key 拼错字段两次请求算出不同 key打日志输出最终 key比对两次请求剔除时间戳、随机数等可变字段用户点一次提示重复提交取票接口被前端重复调用票被提前消费查取票接口调用量是否异常前端把取票放在页面初始化不要放提交前锁一直不释放接口全部超时TTL 设得过长或续期线程没停TTL lock:xxx看剩余时间改用看门狗检查 finally 分支偶发并发进入临界区解锁没校验 value误删别人的锁检查解锁是否走 Lua换成带回 value 校验的 Lua服务重启后锁还在用了不带 TTL 的SETNXTTL返回 -1 就是死键全量SCAN清理代码改SET NX PX限流阈值内也被拒计数 key 没设过期历史累计未清GET limit:xxx看值补PEXPIRE用 Lua 原子化高峰期 Redis 内存暴涨滑动窗口 ZSET 记录过多或幂等 key TTL 过长按前缀看内存排行缩短 TTL粗粒度接口换固定窗口单个线程 CPU 飙高循环里频繁加锁解锁或者tryLock无参阻塞线程栈看阻塞点提高锁粒度设置合理等待时间6.2 几个血泪教训第一个幂等和事务的顺序。幂等标记必须在业务事务外层或者之前完成不能放在事务里。因为事务回滚会把幂等标记一起回滚重复请求照样能进来。我见过把SETNX写在Transactional方法内部的一次回滚之后幂等彻底失效而且这种 bug 平时看不出来只在异常路径触发测试很难覆盖。第二个锁的范围覆盖了整个方法。有些同学图省事直接给Service的整个方法加锁方法里包含了三次远程调用和一个慢查询锁持有时间 3 秒以上QPS 直接被压到个位数。正确做法是把锁的范围缩到最核心的那几行读操作、日志、远程调用全部挪到锁外面进锁之前把需要的数据准备好出锁之后再做后续处理。第三个限流 key 用了用户输入的原始值。有个接口直接把 URL 里的userId拼进 key结果被人传了个超长字符串导致 Redis 里出现几百 KB 的巨型 key集群槽位倾斜。所有进入 key 的外部输入都要先做长度校验和字符白名单超过 64 字符的直接拒绝这个规则写进公共组件里别指望每个业务都记得。第四个不要迷信 Redis 锁的强一致性。主从切换的瞬间锁可能丢。如果你的业务绝对不能容忍这种极小概率的并发那就必须有数据库层的最终防线比如条件更新、乐观锁版本号。Redis 锁是性能优化手段不是正确性保证手段这个定位摆正了很多争论就没意义了。第五个压测要看 P99 和错误率别只看平均值。平均值 20ms 看起来很美P99 可能是 800ms而锁的 TTL 是按平均值算的一到高峰期就全超时。我现在的习惯是每次调锁参数前先把 P99 和最大值拉出来按最大值留一倍余量宁可锁久一点也不要丢锁。最后分享一个我在实际使用中的习惯给这三套东西各建一个监控面板。锁看获取成功率、平均持有时间、等待超时次数幂等看命中次数、按业务域分布限流看触发次数、按 key 维度排行。这三个面板挂在大屏上出现异常时不用连服务器翻日志一眼就知道是哪把锁、哪个接口、哪个用户出了问题。这套监控的成本不到半天但省下来的排查时间是以天计的。