用 Redis 做分布式锁,我踩过的三个坑

举报
yd_293030668 发表于 2026/11/02 20:31:37 2026/11/02
【摘要】 用 Redis 做分布式锁,我踩过的三个坑去年给一个订单服务加分布式锁,用的就是最常见的 SETNX + EXPIRE 那套,当时觉得逻辑简单,随手就上了。上线后陆续出了三次问题,每次都是深夜被报警叫起来。这三条经验记下来,希望后来人少踩一遍。 背景场景很典型:用户下单时,同一订单不能被重复处理。单机时用一个本地锁 synchronized 就够了,但服务是多实例部署,本地锁拦不住跨实例的...

用 Redis 做分布式锁,我踩过的三个坑

去年给一个订单服务加分布式锁,用的就是最常见的 SETNX + EXPIRE 那套,当时觉得逻辑简单,随手就上了。上线后陆续出了三次问题,每次都是深夜被报警叫起来。这三条经验记下来,希望后来人少踩一遍。

背景

场景很典型:用户下单时,同一订单不能被重复处理。单机时用一个本地锁 synchronized 就够了,但服务是多实例部署,本地锁拦不住跨实例的并发。于是引入 Redis 分布式锁。

最初的实现:

def acquire_lock(key, ttl=10):
    ok = redis.setnx(key, "1")
    if ok:
        redis.expire(key, ttl)
    return ok

现在回头看,这段代码有两个隐藏的致命问题。

坑一:setnx 和 expire 不是原子的

setnx 成功和 expire 之间如果服务宕机,这把锁就永远不会过期,成了死锁。这个坑我当时没遇到(概率低),但确实是隐患。

正确做法是用一条命令完成:

def acquire_lock(key, token, ttl=10):
    # SET key value NX PX ttl —— 原子操作
    return redis.set(key, token, nx=True, px=ttl * 1000)

SET ... NX PX 是 Redis 2.6.12 之后支持的单条原子命令,把设置值和过期时间一次做完,中间没有空窗。

坑二:误删别人的锁

这是真正把我坑惨的一个。加锁时建议给锁存一个唯一的 token(比如 UUID),删除锁前要确认这把锁还是自己的。

错误的删除方式:

def release_lock(key):
    redis.delete(key)   # 危险:可能删掉别人的锁

问题出在超时场景:线程 A 拿到锁,执行业务花了 15 秒,但锁的 TTL 是 10 秒——锁已经自动释放,线程 B 拿到了锁。这时线程 A 执行完,一句 delete(key) 把 B 的锁删了,于是并发又回来了。

正确做法是"检查 token 再删除",而且检查和删除也要保证原子性(用 Lua 脚本):

-- release.lua
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
RELEASE_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end
"""

def release_lock(key, token):
    redis.eval(RELEASE_SCRIPT, 1, key, token)

先比对 token 是不是自己的,是才删。Lua 脚本在 Redis 里是单线程执行的,天然原子。

坑三:业务执行时间超过锁的 TTL

坑二提到的"锁自动释放"根源就是这里。业务耗时不确定,TTL 设短了锁提前失效,设长了又怕宕机后长时间占锁。

我的处理办法是锁续期(watchdog):加锁成功后启动一个后台线程,每隔 TTL/3 的时间检查一次,如果业务还没结束就自动延长锁的过期时间。

import threading

def renew_lock(key, token, ttl=10, stop_event=None):
    def _renew():
        while not stop_event.is_set():
            time.sleep(ttl / 3)
            if stop_event.is_set():
                break
            # 只有锁还是自己的才续期
            redis.eval(
                "if redis.call('get', KEYS[1]) == ARGV[1] then "
                "  return redis.call('expire', KEYS[1], ARGV[2]) "
                "else return 0 end",
                1, key, token, ttl
            )
    th = threading.Thread(target=_renew, daemon=True)
    th.start()
    return th

业务结束后停掉续期线程,再走正常的释放流程。这套机制思路来自 Redisson 的看门狗,自己实现一遍后对它的设计理解深了很多。

补充:为什么不用官方的 Redisson

如果项目本身是 Java 的,直接上 Redisson 就好,它把上面这些问题都封装好了,还实现了可重入锁、公平锁等。我这边是 Python,社区里 redis-py 配 Lua 脚本的方案已经够用,就没额外引组件。能用成熟轮子就别自己造,这是踩完这三个坑之后最深的体会。

小结

分布式锁看着简单,细节全在边界条件上:原子性、锁归属、超时续期。三条原则:

  1. 加锁用 SET NX PX 一条命令,避免竞态
  2. 释放锁必须校验 token 且用 Lua 保证原子,防止误删
  3. 业务可能超时就用续期机制,别指望固定 TTL 能覆盖所有场景

把这三条做扎实,分布式锁基本就不会出大问题了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。