分布式锁和分布式事务常被混在一起说。锁能防住并发的读改写,可它的 TTL 会过期、持有者会被冻住,过期之后会怎样?锁能不能保证多步写入要么都做要么都不做?这篇用真实的 Redis、MySQL 和 SIGSTOP / kill -9,把这几个问题一个个量出来。
写法 | 最终值(应为 800) | 丢失的更新 | 总用时 | 操作/秒 --------------------------+-------------+-------+--------+----- 不加锁(SELECT 再 UPDATE) | 101 | 699 | 0.12 s | 6657 Redis 锁 + SELECT 再 UPDATE | 800 | 0 | 0.86 s | 928 一条原子 UPDATE v = v + 1 | 800 | 0 | 0.09 s | 9274
不加锁丢 699 次更新,加 Redis 锁一次不丢,但比数据库原子 UPDATE 慢约 10 倍。
没有任何防护(读 → 写):
-3 ms [A] LOCKED fence=1
-3 ms [A] READ v=0
498 ms [B] LOCKED fence=2
498 ms [B] READ v=0
499 ms [B] WROTE v=1
1004 ms [A] WROTE v=1
两个客户端各加了 1 次,计数器应该是 2,实际是 1 —— 一次更新悄悄丢了,两边都以为自己是在锁保护下写的
写之前检查“锁还是不是我的”,检查通过后被冻住:
-307 ms [A] LOCKED fence=1
-307 ms [A] READ v=0
-1 ms [A] CHECK lock still mine = True
-1 ms [A] CHECK_PASSED
196 ms [B] LOCKED fence=2
196 ms [B] READ v=0
197 ms [B] WROTE v=1
1006 ms [A] WROTE v=1
两个客户端各加了 1 次,计数器应该是 2,实际是 1 —— 一次更新悄悄丢了,两边都以为自己是在锁保护下写的持有者被冻住、锁到期后,别的客户端拿到锁并写入;持有者醒来不知道锁已失效,照样写,更新被悄悄覆盖。写前检查锁也堵不住,检查与写之间有空隙。
-4 ms [A] LOCKED fence=1
-3 ms [A] READ v=0
502 ms [B] LOCKED fence=2
502 ms [B] READ v=0
503 ms [B] WROTE v=1 fence=2 受影响行数=1
1006 ms [A] WROTE v=1 fence=1 受影响行数=0 —— 被存储端拒绝(令牌太旧)
计数器 = 1,存储里记录的最大令牌 = 2:A 的写入被明确拒绝(它知道自己失败了,可以重来),而不是悄悄覆盖 B 的结果加锁时拿一个单调递增的令牌,存储端在写入时比较,旧令牌的写入被明确拒绝。
持锁的转账进程在“已扣款、未入账”时被 kill -9;此刻账户: [(1, 900), (2, 1000)] ,锁 lock:xfer 还剩 790 ms 1 秒后:别的进程拿到了锁(返回令牌 成功),账户仍是 [(1, 900), (2, 1000)],两个账户的钱合计 1900(应为 2000) —— 锁到期只是放行了别人,扣掉的 100 没有谁去回滚
持锁者崩溃后锁到期放行别人,已完成的那一步不会回滚;多步写入的原子性靠事务或补偿。
写法 | 操作/秒(3 轮中位数) | 3 轮范围 | 延迟中位数 | 延迟 p99 | 最终值(应为 2400) -------------------------------------+--------------+-------------+---------+----------+------------- Redis 锁 + SELECT 再 UPDATE | 3512 | 3199 ~ 3551 | 0.25 ms | 41.81 ms | 2400 SELECT ... FOR UPDATE 再 UPDATE(一个事务) | 6172 | 5552 ~ 6806 | 1.11 ms | 3.15 ms | 2400 一条原子 UPDATE v = v + 1 | 9123 | 7159 ~ 9434 | 0.81 ms | 1.47 ms | 2400 数据库自己的行锁 / 原子更新比外面再套一个 Redis 锁便宜,而且不会有“锁过期”的问题;外部锁主要用在被保护的资源没有这种原子能力的时候
数据库行锁 / 原子更新比再套一层 Redis 锁快,也没有锁过期的问题。