分布式锁折腾手记

分布式锁要先改哪一层?

这次项目要做库存扣减,在单机环境下用锁没问题,但部署多台机器之后,发现库存扣错了:商品库存 100,卖了 101 件。

问题是怎么来的

原来的代码是单机环境:

def reduce_inventory(product_id, quantity):
    product = db.query(Product).get(product_id)
    if product.inventory >= quantity:
        product.inventory -= quantity
        db.commit()
        return True
    return False

多台机器同时请求这个接口,就会出现竞争条件:两台机器同时读到库存是 100,都减去 1,最后库存变成 99,但实际卖了 2 件。

第一反应是用 Redis 分布式锁。

Redis 分布式锁

基础实现

import redis
import time
import uuid

redis_client = redis.Redis(host='localhost', port=6379, db=0)

def acquire_lock(lock_name, acquire_timeout=10, lock_timeout=30):
    identifier = str(uuid.uuid4())
    end_time = time.time() + acquire_timeout

    while time.time() < end_time:
        # SETNX:如果 key 不存在则设置,存在则返回 False
        if redis_client.setnx(lock_name, identifier):
            # 设置过期时间,防止死锁
            redis_client.expire(lock_name, lock_timeout)
            return identifier

        time.sleep(0.001)

    return False

def release_lock(lock_name, identifier):
    # 只有锁的持有者才能释放
    pipeline = redis_client.pipeline()
    while True:
        try:
            pipeline.watch(lock_name)
            if pipeline.get(lock_name) == identifier:
                pipeline.multi()
                pipeline.delete(lock_name)
                pipeline.execute()
                return True
            pipeline.unwatch()
            break
        except redis.exceptions.WatchError:
            continue

    return False

踩坑一:原子性问题

上面的代码有个问题:setnxexpire 是两个操作,如果 setnx 成功但 expire 失败(比如服务器崩溃),锁就不会过期,其他请求永远拿不到锁。

解决:使用 SET 命令的扩展参数,原子性地设置值和过期时间。

def acquire_lock(lock_name, acquire_timeout=10, lock_timeout=30):
    identifier = str(uuid.uuid4())
    end_time = time.time() + acquire_timeout

    while time.time() < end_time:
        # NX:只在 key 不存在时设置
        # EX:设置过期时间(秒)
        if redis_client.set(lock_name, identifier, nx=True, ex=lock_timeout):
            return identifier

        time.sleep(0.001)

    return False

踩坑二:误删别人的锁

原来的释放锁代码:redis_client.delete(lock_name)

如果 A 拿到锁,但因为业务处理时间过长,锁过期了。B 拿到了锁,然后 A 释放了锁,把 B 的锁删了。

解决:用唯一标识,释放时先确认标识。

def release_lock(lock_name, identifier):
    # Lua 脚本保证原子性
    script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    return redis_client.eval(script, 1, lock_name, identifier)

Redis 锁的问题

Redis 分布式锁有几个固有问题:

主从切换导致锁丢失:A 拿到锁,但还没同步到从节点,主节点挂了,从节点升为主节点,B 可以拿到锁。

时钟跳跃:如果服务器时间出现跳跃,锁的过期时间就不准确了。

graph TB subgraph Redis主从问题 A[客户端A] -->|获取锁| B[Redis主节点] B -->|同步失败| C[Redis从节点] B -.->|主节点崩溃| D[从节点升级为主节点] A -.->|锁丢失| D E[客户端B] -->|可以获取锁| D end style A fill:#FFB6C1 style E fill:#90EE90

ZooKeeper 分布式锁

针对 Redis 的这些问题,考虑用 ZooKeeper 实现分布式锁。

ZK 基础实现

from kazoo.client import KazooClient
import time

class ZKLock:
    def __init__(self, hosts, lock_path):
        self.zk = KazooClient(hosts=hosts)
        self.zk.start()
        self.lock_path = lock_path
        self.lock = None

    def acquire(self, timeout=10):
        start_time = time.time()

        while time.time() - start_time < timeout:
            try:
                # 创建临时节点
                self.lock = self.zk.create(
                    self.lock_path,
                    ephemeral=True,
                    makepath=True
                )
                return True
            except Exception:
                time.sleep(0.1)

        return False

    def release(self):
        if self.lock:
            self.zk.delete(self.lock)
            self.lock = None

更高效的 ZK 锁

上面的实现有个问题:所有客户端都竞争同一个锁节点,羊群效应明显。

改进:使用顺序临时节点。

class BetterZKLock:
    def __init__(self, hosts, lock_path):
        self.zk = KazooClient(hosts=hosts)
        self.zk.start()
        self.lock_path = lock_path
        self.current_node = None

    def acquire(self, timeout=10):
        # 创建顺序临时节点
        self.current_node = self.zk.create(
            f"{self.lock_path}/lock-",
            ephemeral=True,
            sequence=True,
            makepath=True
        )

        # 检查是不是序号最小的节点
        start_time = time.time()
        while time.time() - start_time < timeout:
            children = self.zk.get_children(self.lock_path)
            children.sort()

            if children[0] == self.current_node.split('/')[-1]:
                return True

            # 监听前一个节点
            index = children.index(self.current_node.split('/')[-1])
            prev_node = f"{self.lock_path}/{children[index-1]}"

            # 等待前一个节点释放
            event = self.zk.handler.event_object()
            self.zk.exists(prev_node, watch=event.set)

            event.wait(timeout)

        return False

    def release(self):
        if self.current_node:
            self.zk.delete(self.current_node)
            self.current_node = None

每个客户端创建自己的顺序节点,只监听前一个节点,避免了羊群效应。

graph TB subgraph ZooKeeper锁 A[客户端A<br/>创建 lock-001] --> A1[最小序号<br/>获取锁] B[客户端B<br/>创建 lock-002] --> B1[监听 lock-001] C[客户端C<br/>创建 lock-003] --> C1[监听 lock-002] A1 -->|锁释放| D[lock-001删除] D --> B1 B1 -->|lock-001删除| B2[lock-002是最小<br/>获取锁] style A fill:#90EE90 style B fill:#FFD700 style C fill:#FFB6C1 end

Redis vs ZooKeeper

特性RedisZooKeeper
性能
可靠性中(主从切换可能丢锁)高(CP 系统保证)
实现复杂度简单复杂
适用场景高性能要求、能容忍锁丢失一致性要求高、不允许锁丢失

实践中的选择

选 Redis 的场景

  • 高并发、性能要求高
  • 可以容忍极少数情况下的锁丢失
  • 团队对 Redis 更熟悉
  • 锁的持有时间短

选 ZooKeeper 的场景

  • 一致性要求高,不允许锁丢失
  • 锁的持有时间较长
  • 需要复杂的锁机制(如读写锁)
  • 已经在用 ZK 做服务发现

其他选择

etcd:基于 Raft 协议,一致性和性能都比较好,适合生产环境。

Redlock:Redis 官方提供的分布式锁算法,但争议很大。

from redlock import Redlock

redlock = Redlock([
    {"host": "redis1.example.com", "port": 6379},
    {"host": "redis2.example.com", "port": 6379},
    {"host": "redis3.example.com", "port": 6379}
])

lock = redlock.lock("resource_name", 1000)
try:
    # 临界区
finally:
    redlock.unlock(lock)

Redlock 的核心思想是:在多个 Redis 实例上获取锁,只有在大多数实例上成功才算成功。但这个方案有争议,主要问题是时钟依赖和锁的安全性。

写在最后

分布式锁这东西,理论讲再多不如实际踩一次坑。

但也不是什么场景都需要分布式锁。能靠数据库乐观锁解决的,就别上分布式锁。能靠消息队列串行化的,就别用锁。

分布式系统的复杂性本来就高,引入分布式锁会增加复杂度,权衡清楚再上。


这次库存扣减问题,最后用 ZooKeeper 解决了。中间试过 Redis,但发现可靠性不够。选什么方案,还是看业务要求。

版权声明: 本文首发于 指尖魔法屋-分布式锁折腾手记https://blog.thinkmoon.cn/post/50-distributed-lock-redis-zookeeper/) 转载或引用必须申明原指尖魔法屋来源及源地址!