Redis 从入门到精通:分布式锁 —— 从 SETNX 到 Redlock

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。

分布式系统中,多个服务实例常常需要竞争同一资源------比如扣减库存、分配订单号、更新公共配置。如果没有协调机制,并发操作就会导致数据错乱。这时候就需要分布式锁:一把所有实例都认可的"钥匙",谁拿到谁才能操作资源,操作完再还回去。

Redis 是实现分布式锁最流行的工具之一。但它并非一把 SETNX 就万事大吉------死锁、误删、锁过期、主从切换导致的锁丢失......这些都是生产环境里的真实大坑。本文从单实例的简单锁开始,一步步演化到生产级的 Redlock 算法,用 Python 把每个坑都踩一遍,再把它填平。

1. 分布式锁的本质与基本要求

一把合格的分布式锁需要满足:

  • 互斥性:同一时刻只有一个客户端持有锁。

  • 防死锁:即使持有锁的客户端崩溃,锁也能被释放(通过过期时间)。

  • 解铃还须系铃人:只有持锁的客户端才能释放锁,不能误删别人的锁。

  • 高可用:锁服务本身不能是单点,否则服务宕机锁就全部失效。

  • 容错性:在部分节点故障时仍能正确加锁、解锁。

Redis 单线程、高性能、自带过期,天然适合做锁。但要满足上述全部要求,需要经过精心设计。

2. 第一代:SETNX ------ 朴素的起点

最原始的想法:SETNX(SET if Not eXists),键不存在则设置成功,表示获取锁;键已存在则失败,表示锁被别人占用。释放锁就是 DEL 键。

bash 复制代码
# 终端 A
127.0.0.1:6379> SETNX lock:order:1001 A
(integer) 1   # 加锁成功

# 终端 B(同时)
127.0.0.1:6379> SETNX lock:order:1001 B
(integer) 0   # 加锁失败,锁已被 A 持有

Python 版本:

bash 复制代码
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def acquire_lock_v1(lock_name, holder_id):
    """第一代:SETNX 加锁"""
    return r.setnx(lock_name, holder_id)

def release_lock_v1(lock_name):
    """第一代:直接 DEL 释放"""
    r.delete(lock_name)

# 使用
if acquire_lock_v1('lock:order:1001', 'client_A'):
    print('A 获取锁成功')
    # 执行业务...
    release_lock_v1('lock:order:1001')
else:
    print('A 获取锁失败')

致命问题

  1. 死锁 :如果客户端 A 在 DEL 之前崩溃,锁永远无法释放。其他客户端永远拿不到锁。

  2. 误删 :客户端 B 无法区分锁是谁的,可能直接 DEL 掉 A 持有的锁。

3. 第二代:加过期时间 + 持有者标识

3.1 防止死锁------给锁加 TTL

SET key value EX seconds NX(Redis 2.6.12+)一条命令完成加锁并设置过期:

bash 复制代码
def acquire_lock_v2(lock_name, holder_id, expire=30):
    """第二代:加锁带过期时间"""
    return r.set(lock_name, holder_id, nx=True, ex=expire)

# 使用
if acquire_lock_v2('lock:order:1001', 'client_A', expire=30):
    print('A 获取锁成功,30 秒后自动释放')

即使客户端崩溃,锁也会在 30 秒后自动释放,不会死锁。

3.2 防止误删------释放时校验持有者

释放前先检查锁的值是否与自己的 holder_id 匹配。但"检查 + 删除"两步不是原子的,需要 Lua 脚本:

bash 复制代码
release_lock_lua = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
"""

release_lock = r.register_script(release_lock_lua)

def acquire_and_release():
    lock_name = 'lock:order:1001'
    holder = 'client_A'

    if r.set(lock_name, holder, nx=True, ex=30):
        print(f'{holder} 获取锁成功')
        # 业务逻辑...
        result = release_lock(keys=[lock_name], args=[holder])
        print(f'释放结果: {result}')  # 1 表示成功删除
    else:
        print('获取锁失败')

acquire_and_release()

还有问题吗? 有。如果业务执行时间超过了锁的过期时间(30 秒),锁自动释放了,其他客户端拿到锁,原客户端还在操作,就破坏了互斥性。这就需要锁续期

4. 第三代:锁续期(Watchdog)

如果业务还在执行,锁快过期了,就自动延长过期时间。这就是 Watchdog(看门狗) 机制。Redisson(Java)中就有经典的看门狗实现。

我们在 Python 中用一个后台线程来实现:

bash 复制代码
import threading
import time
import uuid

class RedisLockWithWatchdog:
    """带看门狗的分布式锁"""
    
    def __init__(self, redis_client, lock_name, expire=30, renew_interval=None):
        self.redis = redis_client
        self.lock_name = lock_name
        self.holder = str(uuid.uuid4())
        self.expire = expire
        self.renew_interval = renew_interval or max(expire // 3, 1)  # 每 expire/3 秒续期
        self._renew_thread = None
        self._stop_renew = threading.Event()

    def acquire(self):
        """获取锁"""
        if self.redis.set(self.lock_name, self.holder, nx=True, ex=self.expire):
            self._start_watchdog()
            return True
        return False

    def _start_watchdog(self):
        """启动看门狗线程,定期续期"""
        self._stop_renew.clear()
        self._renew_thread = threading.Thread(target=self._watchdog_loop, daemon=True)
        self._renew_thread.start()

    def _watchdog_loop(self):
        """续期循环"""
        renew_script = self.redis.register_script("""
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("EXPIRE", KEYS[1], ARGV[2])
            else
                return 0
            end
        """)
        while not self._stop_renew.wait(self.renew_interval):
            result = renew_script(keys=[self.lock_name], args=[self.holder, self.expire])
            if result:
                print(f'[看门狗] 续期成功,延长 {self.expire}s')
            else:
                print('[看门狗] 锁已不属于自己,停止续期')
                break

    def release(self):
        """释放锁"""
        self._stop_renew.set()
        if self._renew_thread:
            self._renew_thread.join(timeout=2)
        release_script = self.redis.register_script("""
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("DEL", KEYS[1])
            else
                return 0
            end
        """)
        return release_script(keys=[self.lock_name], args=[self.holder])

    def __enter__(self):
        if self.acquire():
            return self
        raise Exception('获取锁失败')

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.release()


# 使用示例
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def process_order():
    try:
        with RedisLockWithWatchdog(r, 'lock:order:1001', expire=10) as lock:
            print('获取锁成功,开始处理订单...')
            time.sleep(15)  # 模拟长业务,超过 expire
            print('订单处理完成')
    except Exception as e:
        print(e)

process_order()

输出:

bash 复制代码
获取锁成功,开始处理订单...
[看门狗] 续期成功,延长 10s
[看门狗] 续期成功,延长 10s
订单处理完成

即使业务执行了 15 秒(超过初始的 10 秒 TTL),锁也没有过期,因为看门狗在第 3 秒和第 6 秒续期了。

5. 第四代:Redlock ------ 分布式锁的巅峰

5.1 单实例锁的致命缺陷

即使加了 Watchdog,上述方案仍有一个根本性缺陷:锁存储在单个 Redis 实例上,一旦这个实例宕机,锁就丢失了

如果使用主从架构,主节点宕机后从节点提升,但从节点可能还没同步到锁的数据,导致锁丢失,两个客户端同时认为拿到了锁。

Redlock 是 Redis 作者 antirez 提出的算法,在多个独立的 Redis 节点上同时加锁,只要超过半数节点加锁成功,才认为获取锁成功。

5.2 Redlock 算法流程

  1. 获取当前时间戳(毫秒)。

  2. 依次向 N 个 Redis 节点(建议 5 个)请求加锁,使用相同的 keyvalue 和 TTL。

  3. 客户端设置一个超时时间(远小于锁的 TTL),如果某个节点没及时响应,则立即尝试下一个节点。

  4. 统计加锁成功的节点数。如果成功数 ≥ N/2 + 1(即大多数),且总耗时 < 锁的 TTL,则认为获取锁成功。

  5. 锁的有效时间 = 锁的 TTL - 总耗时。

  6. 如果加锁失败(未达到大多数或耗时过长),则向所有节点发送释放锁请求。

bash 复制代码
客户端
  │
  ├── 加锁请求 ──> Redis-1 ✅
  ├── 加锁请求 ──> Redis-2 ✅
  ├── 加锁请求 ──> Redis-3 ✅
  ├── 加锁请求 ──> Redis-4 ❌ (超时)
  └── 加锁请求 ──> Redis-5 ✅
  │
  4/5 成功 > 半数 → 获取锁成功

5.3 Python Redlock 实现

我们使用 redlock-py 库(也可手写,这里展示原理):

bash 复制代码
import redlock
import time

# 配置 5 个独立的 Redis 节点(生产环境需要不同机器)
# 这里演示:可在不同端口启动多个 Redis
nodes = [
    {'host': 'localhost', 'port': 6379, 'db': 0},
    {'host': 'localhost', 'port': 6380, 'db': 0},
    {'host': 'localhost', 'port': 6381, 'db': 0},
    {'host': 'localhost', 'port': 6382, 'db': 0},
    {'host': 'localhost', 'port': 6383, 'db': 0},
]

# 创建 Redlock 实例
dlm = redlock.Redlock(nodes, retry_count=3, retry_delay=0.2)

# 加锁
lock_name = 'lock:critical:task'
my_lock = None

try:
    # 尝试获取锁,TTL=10000ms (10s)
    my_lock = dlm.lock(lock_name, 10000)
    if my_lock:
        print(f'获取 Redlock 成功! 锁值: {my_lock.resource}')
        # 执行业务...
        time.sleep(2)
    else:
        print('获取 Redlock 失败')
except redlock.MultipleRedlockException as e:
    print(f'Redlock 异常: {e}')
finally:
    if my_lock:
        dlm.unlock(my_lock)
        print('锁已释放')

手写 Redlock 核心逻辑(理解原理):

bash 复制代码
import uuid
import time
import redis

class SimpleRedlock:
    """简化版 Redlock 实现"""
    
    def __init__(self, nodes, retry_count=3, retry_delay=0.2):
        """
        nodes: [{'host': 'localhost', 'port': 6379, 'db': 0}, ...]
        """
        self.nodes = [
            redis.Redis(host=n['host'], port=n['port'], db=n.get('db', 0),
                       decode_responses=True, socket_timeout=0.5)
            for n in nodes
        ]
        self.quorum = len(self.nodes) // 2 + 1
        self.retry_count = retry_count
        self.retry_delay = retry_delay

    def lock(self, resource, ttl_ms=10000):
        """获取分布式锁"""
        value = str(uuid.uuid4())
        
        for attempt in range(self.retry_count + 1):
            start_time = int(time.time() * 1000)
            success_count = 0
            
            # 向所有节点尝试加锁
            for node in self.nodes:
                try:
                    if node.set(resource, value, nx=True, px=ttl_ms):
                        success_count += 1
                except Exception as e:
                    print(f'节点 {node} 加锁异常: {e}')
            
            elapsed = int(time.time() * 1000) - start_time
            
            # 加锁成功条件:多数节点成功,且耗时未超 TTL
            if success_count >= self.quorum and elapsed < ttl_ms:
                return {
                    'value': value,
                    'validity': ttl_ms - elapsed  # 剩余有效时间
                }
            
            # 加锁失败,释放已成功的节点
            self._unlock_all(resource, value)
            
            # 等待后重试
            if attempt < self.retry_count:
                time.sleep(self.retry_delay * (attempt + 1))  # 退避
        
        return None

    def unlock(self, resource, value):
        """释放锁"""
        self._unlock_all(resource, value)

    def _unlock_all(self, resource, value):
        """向所有节点发送释放锁请求"""
        unlock_script = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
        """
        for node in self.nodes:
            try:
                node.eval(unlock_script, 1, resource, value)
            except Exception:
                pass  # 忽略释放失败(节点可能已宕机)


# 使用 SimpleRedlock
# 需要先启动 5 个 Redis 实例(不同端口)
# 为演示,这里假设已存在
redlock = SimpleRedlock(nodes, retry_count=2)

lock_info = redlock.lock('lock:critical:task', ttl_ms=5000)
if lock_info:
    print(f'获取锁成功,剩余有效时间: {lock_info["validity"]}ms')
    # 业务处理...
    time.sleep(1)
    redlock.unlock('lock:critical:task', lock_info['value'])
    print('锁已释放')
else:
    print('获取锁失败')

6. Redlock 争议与最佳实践

Redlock 并非毫无争议。分布式系统专家 Martin Kleppmann 曾撰文指出 Redlock 存在安全性问题。核心争议在于:时钟跳跃GC 停顿可能导致锁失效。

  • 时钟跳跃:如果某个节点的系统时钟被 NTP 调整,可能导致锁提前过期。

  • GC 停顿:客户端在 GC(垃圾回收)时暂停,锁过期了业务还在执行。

反方建议 :使用 ZooKeeperetcd 这类基于共识算法的强一致性存储来实现锁。

实际业界选择

  • 大多数互联网公司仍广泛使用 Redis 做分布式锁(简单、高性能、已有基础设施)。

  • 对一致性要求极高的场景(金融交易、订单状态机),建议使用 ZooKeeper/etcd。

  • 折中方案:使用 redlock-py 库并配合单调递增的 fencing token,每次获取锁返回一个递增的 token,资源服务可以通过比对 token 来拒绝过期的锁。

7. 动手试试

  1. Watchdog 模拟:设置锁过期 5 秒,业务睡眠 12 秒,观察看门狗续期效果。打印续期次数和最终锁释放状态。

  2. 并发竞争:启动 10 个线程同时竞争一个锁,统计获取成功的次数(应为 1),验证互斥性。

  3. Redlock 节点故障:用 Docker 启动 5 个 Redis,运行 SimpleRedlock,手动停掉其中 2 个节点,验证仍然可以加锁(3/5 > 半数)。

  4. 锁误删防护 :用两个不同 holder_id 尝试释放对方的锁,验证 Lua 脚本正确拦截。

预期效果:看门狗延长锁 TTL;10 个线程只有一个获取锁成功;半数节点存活时 Redlock 仍能正常工作;误删锁被拒绝。

8. 总结

我们从一个简单的 SETNX 出发,一步步打造了一把生产级分布式锁:

分布式锁没有银弹,关键是根据业务场景选择合适的方案。对 90% 的场景,SET NX EX + Lua 释放 + Watchdog 已经足够。对跨数据中心、金融级一致性要求的场景,再考虑 Redlock 或 ZooKeeper。

下一篇,我们将迎来 Redis 5.0 的重磅特性------Redis Stream,用它构建生产级的可靠消息队列。

想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维 !

相关推荐
云计算磊哥@1 小时前
运维开发宝典027-MySQL03数据库的增删改查
运维·数据库·运维开发
李白的天不白1 小时前
数据库的CEUD
数据库·sql·oracle
zyl837211 小时前
前后端高并发解决方案
java·redis
linux修理工2 小时前
kafka积压
数据库·分布式·kafka
i220818 Faiz Ul2 小时前
药店管理|基于springboot + vue药店管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·美食分享系统
不吃土豆的马铃薯2 小时前
C++ 正则表达式入门详解
linux·服务器·网络·数据库·c++·正则表达式
sulikey2 小时前
数据库系统概论 - 定义与查询 期末速成课笔记
数据库·笔记·期末考试·数据查询·期末速成·数据库系统概论·数据定义
nan madol2 小时前
PolarDB 分布式版(PolarDB-X)
数据库
西凉的悲伤2 小时前
redis-windows 安装 redis 到 windows 电脑
java·windows·redis·redis-windows