事务与分布式锁:ACID、Redis 锁和并发控制
从数据库事务的 ACID 与隔离级别出发,掌握 Redis 锁、乐观并发、租约失效和幂等设计的工程边界。
事务与分布式锁:ACID、Redis 锁和并发控制
本文整理自
25年下半年面试真题预测.pdf第 2.16、2.17 节。PDF 中的代码更适合作为概念示例;本文补充令牌校验、租约失效、数据库一致性和故障恢复边界,避免把“加了 Redis 锁”误认为事务已经完成。
1. 事务先回答 ACID
事务把一组相关操作作为一个业务单元提交。面试中应先说明四个属性,再说明它们依赖的实现机制:
| 属性 | 含义 | 常见实现或约束 |
|---|---|---|
| Atomicity 原子性 | 操作要么全部成功,要么全部回滚 | undo log、数据库回滚、事务边界 |
| Consistency 一致性 | 提交前后都满足约束和业务不变量 | 约束、事务逻辑、校验、补偿 |
| Isolation 隔离性 | 并发事务互不产生不允许的中间影响 | 锁、MVCC、隔离级别 |
| Durability 持久性 | 提交成功后结果可在故障恢复后保留 | redo log、WAL、持久化策略 |
一致性不是数据库单独“送给”应用的属性。数据库可以保证约束和日志,但跨服务调用、消息投递和外部支付仍需要幂等、重试、Outbox 或补偿流程共同维护业务不变量。
1.1 隔离级别和异常
不同数据库的默认隔离级别和实现细节不同,回答时不要把下表当成绝对承诺:
| 隔离级别 | 可能看到的现象 | 典型风险 |
|---|---|---|
| Read Uncommitted | 读到其他事务未提交的数据 | 脏读、不可重复读、幻读 |
| Read Committed | 只能读到已提交数据 | 同一事务两次读取可能不同 |
| Repeatable Read | 同一事务读取的已有记录较稳定 | 范围查询的幻读和写冲突依赖数据库实现 |
| Serializable | 并发效果接近串行执行 | 吞吐下降、锁等待增加 |
脏读是读到未提交数据;不可重复读是同一事务两次读到同一行的不同版本;幻读是范围查询中行集合发生变化。MVCC 通过版本快照减少读写阻塞,锁则用于保护写入和需要串行化的范围;二者通常结合使用。
1.2 事务边界怎么划
事务应尽量覆盖一个明确的不变量,并保持短小:
校验输入和权限(事务外)
-> 开启事务
-> 锁定或条件更新必要记录
-> 写入相关表/Outbox
-> 提交
-> 事务外发送通知、刷新缓存或触发异步任务
不要在数据库事务中调用不受控的 HTTP 服务、等待用户输入或执行长时间计算。外部副作用应通过 Outbox、状态机或补偿机制与数据库提交建立可靠联系。
2. 数据库写入优先使用约束和条件更新
很多“需要分布式锁”的场景,先用数据库原子操作就足够。例如扣减库存可以把条件写进 SQL:
UPDATE inventory
SET available = available - :quantity,
version = version + 1
WHERE sku = :sku
AND available >= :quantity
AND version = :version;
检查受影响行数:为 0 表示库存不足或版本冲突,客户端可以重新读取或返回明确业务错误。唯一索引、外键和检查约束也比“先查再写”的应用层锁更可靠。
跨服务流程可以采用:
- 幂等键表:为每个业务请求保存唯一键和处理结果,重复请求直接返回已记录结果;
- Outbox:在同一数据库事务中写业务数据和待发送事件,再由可靠 worker 投递;
- 状态机:为订单、支付等流程定义允许的状态迁移,重复或乱序事件不能越过状态边界;
- 补偿:无法使用分布式事务时,记录失败步骤并执行可重试的反向动作。
3. Redis 锁的最小正确实现
3.1 获取锁
单 Redis 实例上,常见的互斥锁是“唯一令牌 + 原子 set-if-absent + 租约 TTL”:
import crypto from 'node:crypto'
type LockHandle = {
key: string
token: string
}
async function acquireLock(
redis: { set: Function },
key: string,
ttlMs = 10_000,
): Promise<LockHandle | null> {
const token = crypto.randomUUID()
const result = await redis.set(key, token, 'NX', 'PX', ttlMs)
return result === 'OK' ? { key, token } : null
}
NX 保证只有不存在时才写入,PX 防止进程崩溃后永久占锁。锁值必须是当前持有者的随机令牌,不能只存固定字符串,否则旧持有者可能删除新持有者的锁。
3.2 安全释放
“先 GET 再 DEL”不是原子操作:GET 和 DEL 之间可能刚好过期并被另一个持有者重新获取。释放时要在 Redis 内部比较令牌并删除:
const releaseScript = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
`
async function releaseLock(
redis: { eval: Function },
handle: LockHandle,
) {
return redis.eval(releaseScript, 1, handle.key, handle.token)
}
业务代码必须在 finally 中尝试释放,但释放失败时不能把已经完成的业务重新执行一遍:
const handle = await acquireLock(redis, `lock:order:${orderId}`)
if (!handle) throw new Error('LOCK_BUSY')
try {
await updateOrder()
} finally {
await releaseLock(redis, handle)
}
3.3 TTL 不是万能保证
锁是租约,不是永久所有权。下面的时间线会造成并发写入:
A 获得租约(TTL=10s)
-> A 因 GC/网络暂停超过 10s
-> B 获得同一资源的新租约
-> A 恢复并继续写入
因此,要求严格顺序的下游系统应使用fencing token(栅栏令牌):每次获得租约生成递增序号,下游只接受不小于当前序号的写入。若无法让下游校验令牌,Redis 锁只能降低并发概率,不能证明绝对互斥。
锁实现还要考虑:
- 关键区执行时间的上限和 TTL 余量;
- 是否需要安全续租,续租必须验证令牌且有最大租期;
- 获取失败后的截止时间、退避和随机抖动;
- 资源 key 的租户、版本和粒度,避免不同业务意外共用;
- 进程崩溃、Redis 重启、网络分区和主从切换后的行为。
4. Redis 的乐观并发控制
不一定要阻塞等待锁。对于冲突概率较低、操作时间短的读改写,可以使用 WATCH + MULTI/EXEC:
async function transfer(
redis: any,
fromKey: string,
toKey: string,
amount: number,
) {
await redis.watch(fromKey, toKey)
try {
const balance = Number(await redis.get(fromKey) ?? 0)
if (balance < amount) {
await redis.unwatch()
throw new Error('INSUFFICIENT_BALANCE')
}
const transaction = redis.multi()
transaction.decrby(fromKey, amount)
transaction.incrby(toKey, amount)
const result = await transaction.exec()
if (result === null) throw new Error('VERSION_CONFLICT')
return result
} catch (error) {
await redis.unwatch()
throw error
}
}
如果被监视的 key 在 EXEC 前发生变化,事务会失败并返回冲突;调用方应在有限次数内重新读取、退避后重试。MULTI/EXEC 会把命令作为一组提交,但 Redis 不提供关系数据库式的任意命令回滚;命令本身出错时,已执行的其他命令不会自动恢复。
适用判断:
- 读多写少、冲突低、可以重试:优先乐观并发;
- 冲突高、临界区短、必须串行:考虑租约锁或数据库行锁;
- 需要跨多个资源和外部系统的强一致:优先数据库事务/约束或专门的协调服务,不要只叠加 Redis 锁。
5. 读写锁和多节点锁
5.1 读写锁
读写锁允许多个读者并发,但写者需要排斥读者和其他写者。用 Redis 自己拼读写锁时,需要原子地检查写锁、登记读者、设置过期时间,并处理读者崩溃后的清理;一个简单的“读计数器 + 写标志”容易出现死锁、饥饿和计数泄漏。
只有在确实存在大量并行读和少量互斥写、且团队能维护完整故障测试时才考虑实现。很多业务用短 TTL 的版本化缓存、数据库快照或单写者队列更简单。
5.2 Redlock 的边界
Redlock 通过多个独立 Redis 节点获取多数租约,并考虑网络耗时和时钟漂移。它可以提高某些部署下的可用性,但不是“加了多数节点就得到共识系统”:
- 节点独立性、故障模型、网络分区和时钟假设必须成立;
- 租约剩余时间要扣除获取耗时和漂移,不能拿到锁后无限执行;
- 业务写入仍需要 fencing token、幂等或版本校验;
- 对金融、库存等强一致场景,应评估数据库行锁、ZooKeeper/etcd 等共识型协调服务或业务状态机。
面试中不要只背“至少两个节点同意”。应先说业务不变量和故障模型,再说明 Redlock 是否满足要求,以及无法满足时的替代方案。
6. 锁的粒度、顺序和故障恢复
6.1 缩小临界区
把输入解析、网络请求和大计算放在锁外,只在真正读改写共享状态的部分加锁。锁粒度越大,吞吐越低,超时和死锁概率越高;粒度过细则可能增加协调开销和锁顺序复杂度。
6.2 统一顺序避免死锁
需要同时锁定多个资源时,按稳定顺序排序 key:
const keys = [accountA, accountB].sort()
// 总是按 keys[0] -> keys[1] 获取,释放时反向或统一由 finally 处理
获取锁要有总 deadline,不要无限循环;重试使用指数退避和抖动。进程重启后应能从数据库状态或事件日志恢复,不能依赖内存里的“我持有哪把锁”。
6.3 幂等是锁的最后一道保障
即使锁实现正确,也可能发生客户端重试、消息重复投递或进程在提交后崩溃。写接口应携带业务幂等键,数据库保存唯一约束和最终结果;消费者记录消息 ID 或版本,重复事件直接返回已处理结果。
7. 一个转账场景怎么设计
以“账户 A 向账户 B 转账”为例:
- 校验金额、账户归属和幂等键;
- 开启数据库事务,按账户 ID 的固定顺序加行锁或使用版本条件更新;
- 检查余额,扣减 A、增加 B,并写入转账记录和 Outbox 事件;
- 提交事务;
- 事务外投递通知,失败时由 Outbox worker 重试;
- 查询接口根据转账记录和状态返回结果,重复请求使用幂等键复用原结果。
Redis 锁可以减少跨实例的重复进入,但不能替代第 2-3 步的数据库约束。若业务只在 Redis 中维护余额,则必须明确持久化、故障恢复、复制和审计策略;不能把示例代码直接升级成支付系统。
8. 面试回答模板
先定义业务不变量和一致性目标
-> 判断数据库原子更新/约束是否足够
-> 需要协调时选择悲观锁、乐观锁或租约锁
-> 说明令牌、TTL、释放原子性、重试和死锁处理
-> 补充进程暂停、网络分区、幂等、fencing token 和恢复方案
-> 最后说监控指标、压测和故障演练
高频追问
Q: 为什么不能先 GET 再 DEL Redis 锁?
A: GET 和 DEL 之间锁可能过期并被别人重新获取,旧持有者会误删新锁。应把“比较令牌并删除”放在 Lua 或其他原子操作中。
Q: 设置了 TTL 后一定不会死锁吗?
A: TTL 能在持有者崩溃后释放租约,但不能保证临界区内永远只有一个有效写者。执行暂停超过 TTL 时旧持有者可能恢复写入,需要 fencing token、版本条件或下游幂等约束。
Q: Redis MULTI/EXEC 是不是事务回滚?
A: 它把命令作为一组排队执行,并不等同于关系数据库的任意回滚;命令错误不会自动撤销之前已执行的命令。需要原子条件时可用 Lua 或重新设计数据结构。
Q: Redlock 能保证分布式强一致吗?
A: 不能脱离故障模型做绝对保证。它依赖节点独立、时间和网络假设,仍需考虑租约过期和陈旧写入。强一致业务应优先使用数据库约束、共识型协调服务、fencing token 和幂等状态机。
Q: 什么时候根本不需要分布式锁?
A: 如果可以用数据库唯一索引、条件更新、版本号、单线程队列或幂等事件解决不变量,优先这些更容易验证的机制。锁应是明确故障模型下的最后选择,而不是所有并发问题的默认答案。
9. 监控与验证清单
- 锁获取成功率、等待时间、持有时间和超时次数;
- 续租失败、释放失败、冲突重试和死锁回滚次数;
- 业务幂等命中率、重复消息数和状态迁移拒绝数;
- 数据库锁等待、事务回滚、慢查询和连接池饱和度;
- Redis 延迟、主从切换、网络分区和 key 过期事件;
- 压测中注入进程暂停、Redis 不可用、重复请求和消息乱序,确认最终状态满足业务不变量。