用 Docker 搭建分布式锁的完整流程
最近有个跑在两台机器上的定时任务出事了:两边同一时刻各执行了一遍,数据库里同一批订单被写了两份,日志一对时间戳,分毫不差。以前单机跑的时候,靠数据库唯一索引就能糊弄过去,现在上到两台就不行了,得正经上分布式锁。方案里 Redis 算最省事的,但我不想为了一把锁在服务器上装个 Redis,就用 Docker 起容器。动手前一搜,全是讲 Redlock 理论的,为什么就没有一篇能直接照着敲的呢?可能是我瞎没找到吧... 折腾了一晚上总算跑通了,下面是实测可用的完整流程。
第一步:把 Redis 跑起来
首先拉镜像 docker pull docker.1ms.run/library/redis:7.2,我这里用了加速域名,不需要的或者拉的时候报错了,可以把 docker.1ms.run/library 删掉,直接 redis:7.2,没准儿你看到这篇文章的时候这个加速域名已经不可用了。
然后启动:
docker run -d --name redis-lock \
-p 6379:6379 \
-v ~/redis-lock-data:/data \
redis:7.2 redis-server --requirepass "换成你的密码" --appendonly yes
-d 后台运行,-v 把数据目录映射到宿主机,容器删了数据还在。密码一定要设,Redis 裸奔在公网上被扫去挖矿的案例一搜一大把,别心存侥幸。
容器跑起来过后,验证一下活没活:
docker exec -it redis-lock redis-cli -a 换成你的密码 ping
返回 PONG 就成了,前面会提示一串命令行传密码不安全的警告,不用管,能 ping 通就行。
第二步:加锁和解锁
分布式锁听起来玄乎,单机版的核心其实就一条命令:
SET lock:order-task <uuid> NX PX 30000
NX 表示 key 不存在才能设置成功,谁先设置成功谁持有锁,后来的直接拿到 nil;PX 30000 是 30 秒过期时间,这个必须带,不然持有锁的进程崩了,锁就永远死在那了,谁都拿不到。value 里放 UUID,释放锁的时候要用它判断这把锁是不是自己的——这里有个大坑:如果你图省事直接 DEL,可能把自己锁过期后、被别人接手的锁给误删了。所以释放必须用 Lua 脚本保证原子性:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
Redis 执行脚本是原子的,GET 和 DEL 中间不会插进别的命令,这样才能删得干净。
第三步:写段代码测一测
用 Python 举例,先把逻辑封一下:
import redis
import uuid
r = redis.Redis(host="127.0.0.1", port=6379,
password="换成你的密码", decode_responses=True)
def acquire(key, expire_ms=30000):
token = str(uuid.uuid4())
if r.set(key, token, nx=True, px=expire_ms):
return token
return None
def release(key, token):
script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
return r.eval(script, 1, key, token) == 1
然后开两个终端,前后脚各执行一次下面的代码:
token = acquire("lock:order-task")
if token:
print("拿到锁了,开始干活")
time.sleep(10) # <--- 模拟任务执行,先别释放
release("lock:order-task", token)
else:
print("拿锁失败,锁被别人占着")
不出问题的话,就是一个终端打印“拿到锁了”,另一个打印“拿锁失败”。想验证过期时间也好办,把 PX 改成 3000,让第一个进程 sleep 十秒再释放,三秒过后第二个进程就能拿到锁了。
不放心单机?加几台搞 Redlock
单点 Redis 总有挂的时候,主从切换的那几秒里锁可能就丢了。Redlock 的思路是往多个互相独立的 Redis 上同时加锁,拿到大多数才算成功,比如 3 台里至少 2 台。先起两个额外实例(加上前面那个一共 3 台):
docker run -d --name redis-lock-2 -p 6382:6379 \
redis:7.2 redis-server --requirepass "换成你的密码"
docker run -d --name redis-lock-3 -p 6383:6379 \
redis:7.2 redis-server --requirepass "换成你的密码"
说明一下,我这是在同一台机器上开 3 个容器图个演示,真上生产要分到不同机器,不然机器一挂全完,锁形同虚设。网上现成的 redlock 库好几年没更新了,可能是我瞎没找到维护得好的吧... 干脆手写一个,几十行的事:
clients = [redis.Redis(host="127.0.0.1", port=p,
password="换成你的密码", decode_responses=True)
for p in (6379, 6382, 6383)]
def redlock_acquire(key, expire_ms=30000):
token = str(uuid.uuid4())
ok = sum(1 for c in clients if c.set(key, token, nx=True, px=expire_ms))
return token if ok >= 2 else None
def redlock_release(key, token):
for c in clients:
# 逐台执行前面那个 Lua 释放脚本即可
...
标准的 Redlock 还有获取锁耗时不能超过锁时长的校验、加锁失败要把已到手的锁全部回滚,我这里为了能一眼看懂做了简化,原理就这么回事,需要严格实现直接抄 Redis 官方文档里的伪代码就行。
几个容易忽略的点
- 过期时间别拍脑袋定,得大于任务最长执行时间,实在估不准就起个后台线程定期续期,也就是常说的看门狗。
- 拿不到锁别傻等,
while True里裸重试容易把自己绕死,加上重试次数或者总超时上限。 - key 命名规范点,比如
lock:服务名:任务名,出问题的时候去 Redis 里KEYS lock:*一眼就能看到谁占着。 - 密码别硬编码,走环境变量,
docker run -e REDIS_PASSWORD=xxx传进去。 - 最后泼盆冷水:能靠数据库唯一索引、乐观锁解决的问题,就别引入 Redis 了,多一个组件多一个炸点,锁越简单越好。
这套东西现在跑在我一台 2 核 4G 的小机器上,三个容器加起来内存占用不到 100M,目前稳着呢,先这样。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。