分布式锁折腾手记
分布式锁要先改哪一层?
这次项目要做库存扣减,在单机环境下用锁没问题,但部署多台机器之后,发现库存扣错了:商品库存 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
踩坑一:原子性问题
上面的代码有个问题:setnx 和 expire 是两个操作,如果 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 可以拿到锁。
时钟跳跃:如果服务器时间出现跳跃,锁的过期时间就不准确了。
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
每个客户端创建自己的顺序节点,只监听前一个节点,避免了羊群效应。
Redis vs ZooKeeper
| 特性 | Redis | ZooKeeper |
|---|---|---|
| 性能 | 高 | 中 |
| 可靠性 | 中(主从切换可能丢锁) | 高(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/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。