跳到正文
前端知识库
后端与网络

事务与分布式锁:ACID、Redis 锁和并发控制

从数据库事务的 ACID 与隔离级别出发,掌握 Redis 锁、乐观并发、租约失效和幂等设计的工程边界。

9 分钟事务 · 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 转账”为例:

  1. 校验金额、账户归属和幂等键;
  2. 开启数据库事务,按账户 ID 的固定顺序加行锁或使用版本条件更新;
  3. 检查余额,扣减 A、增加 B,并写入转账记录和 Outbox 事件;
  4. 提交事务;
  5. 事务外投递通知,失败时由 Outbox worker 重试;
  6. 查询接口根据转账记录和状态返回结果,重复请求使用幂等键复用原结果。

Redis 锁可以减少跨实例的重复进入,但不能替代第 2-3 步的数据库约束。若业务只在 Redis 中维护余额,则必须明确持久化、故障恢复、复制和审计策略;不能把示例代码直接升级成支付系统。

8. 面试回答模板

先定义业务不变量和一致性目标
-> 判断数据库原子更新/约束是否足够
-> 需要协调时选择悲观锁、乐观锁或租约锁
-> 说明令牌、TTL、释放原子性、重试和死锁处理
-> 补充进程暂停、网络分区、幂等、fencing token 和恢复方案
-> 最后说监控指标、压测和故障演练

高频追问

Q: 为什么不能先 GETDEL 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 不可用、重复请求和消息乱序,确认最终状态满足业务不变量。