人人都会AI编程

23.5 分布式锁、分布式事务基础方案

更新时间:2026-07-11

微服务架构拆分了单体应用之后,原本在同一个进程内可以轻松串行化或事务化处理的操作,现在分散到了多个独立的服务进程甚至不同的物理节点上。如何协调这些并发操作确保数据一致性,就成了必须面对的问题。本节我们聚焦两个关键机制:分布式锁分布式事务基础方案,重点讨论它们在 Node.js 中的落地实践。

23.5.1 分布式锁:确保跨进程的唯一执行

当多个服务实例需要共享一份资源(例如一个文件、一条数据库记录)且操作必须串行时,就会用到分布式锁。典型的场景包括:

  • 定时任务只有一台机器执行,避免重复触发。
  • 库存扣减防止超卖。
  • 同一用户同时只能有一个操作在进行。

基于 Redis 的单节点锁

最简洁的实现是利用 Redis 的 SET key value NX PX milliseconds 命令:NX 表示仅键不存在时才设置(互斥),PX 设置过期时间(避免死锁)。Node.js 中可以使用 ioredis 库。

const Redis = require('ioredis');
const redis = new Redis();

async function acquireLock(key, ttl = 5000) {
  const result = await redis.set(key, 'locked', 'PX', ttl, 'NX');
  return result === 'OK';
}

async function releaseLock(key) {
  await redis.del(key);
}

但释放锁时必须保证 del 的是自己持有的锁,否则可能误删其他客户端的锁。常见做法是设置唯一标识作为 value,释放时用 Lua 脚本原子校验:

const { v4: uuidv4 } = require('uuid');

async function safeAcquire(key, ttl = 5000) {
  const token = uuidv4();
  const ok = await redis.set(key, token, 'PX', ttl, 'NX');
  return ok ? token : null;
}

async function safeRelease(key, token) {
  const lua = `
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else
      return 0
    end
  `;
  await redis.eval(lua, 1, key, token);
}

Redlock:应对主从切换的强一致锁

单节点 Redis 锁在主节点宕机时可能丢失锁信息。Redlock 算法通过多个独立的 Redis 节点(奇数个)多数投票来实现更高的可靠性。Node.js 有成熟的 redlock 包:

npm install redlock ioredis
const Redlock = require('redlock');
const Redis = require('ioredis');

const redlock = new Redlock([
  new Redis({ host: 'redis1' }),
  new Redis({ host: 'redis2' }),
  new Redis({ host: 'redis3' }),
], {
  retryCount: 3,
  retryDelay: 200,
});

async function doExclusiveTask() {
  let lock;
  try {
    lock = await redlock.acquire(['resource:order:1001'], 5000);
    // 执行业务逻辑
  } finally {
    if (lock) await lock.release();
  }
}

Redlock 并非绝对完美,极端网络分区下仍有争议,但对于绝大多数场景已足够可靠。

其他分布式锁方案

  • etcd:强一致的分布式键值存储,通过租约机制实现锁。Node.js 可使用 etcd3@grpc/etcd
  • ZooKeeper:利用临时顺序节点实现公平锁,生态上有 node-zookeeper-client,但社区活跃度较低。
  • 数据库:基于 MySQL 的唯一索引或 SELECT ... FOR UPDATE 实现,不推荐大规模使用。

23.5.2 分布式事务:跨服务的操作协调

传统数据库事务(ACID)依赖同一个数据库实例的锁定和日志,而微服务通常把数据分散在不同数据库甚至不同存储引擎中。分布式事务的目标是让多个服务的数据变更要么全部成功,要么全部回滚。

CAP 理论下的现实取舍

分布式系统必须在一致性、可用性、分区容忍性之间做权衡。多数业务系统选择 AP(高可用和分区容忍),牺牲强一致性,转而追求最终一致性。因此我们讨论的方案几乎都是基于最终一致性的“柔性事务”。

方案一:Saga 模式(补偿事务)

Saga 把一个大事务拆分为一系列本地事务。每个本地事务执行完成后,会触发下一个服务执行本地事务。如果某个步骤失败,Saga 会逆序调用前面成功的步骤对应的“补偿操作”进行回滚。

实现方式分为协同式 Saga(服务间直接消息驱动)和编排式 Saga(由中央协调器 orchestrate)。Node.js 中常采用轻量的协同式,利用消息队列(RabbitMQ、Kafka、Bull)传递事件。

以下是一个订单创建的简化示例,使用 Bull 队列实现编排式 Saga:

const Queue = require('bull');

// 订单服务队列
const orderQueue = new Queue('order');
// 库存服务队列
const inventoryQueue = new Queue('inventory');
// 支付服务队列
const paymentQueue = new Queue('payment');

// 订单服务:接收创建订单请求,启动 Saga
async function createOrder(order) {
  const job = await orderQueue.add({ order });
  // 异步等待 Saga 完成或失败
}

orderQueue.process(async (job) => {
  const { order } = job.data;
  // 第一步:锁定库存(本地事务)
  await inventoryQueue.add({ orderId: order.id, amount: order.amount });
});

inventoryQueue.process(async (job) => {
  const { orderId, amount } = job.data;
  try {
    // 执行数据库扣减库存
    await db.decrementStock(amount);
    // 成功后触发支付
    await paymentQueue.add({ orderId, amount });
  } catch (err) {
    // 库存操作失败,通知 Saga 协调器进行补偿(或直接补偿上一步)
    await compensateActivity('order', orderId);
  }
});

paymentQueue.process(async (job) => {
  const { orderId, amount } = job.data;
  try {
    await paymentService.charge(amount);
    // 成功完成
  } catch (err) {
    // 支付失败,补偿:释放库存 + 标记订单失败
    await inventoryCompensation(orderId);
    await orderCompensation(orderId);
  }
});

这个简化版本中,每个服务都通过队列解耦,失败时调用对应的补偿函数。真正的生产实现最好引入中央 Saga 编排引擎,比如 node-sagas 或集成到更通用的工作流引擎中(如 Temporal、Camunda),但这些在 Node.js 生态中尚不成熟,很多时候需要自研简单的编排器。

方案二:借助消息队列的事件驱动最终一致性

很多场景中不需要严格的事务回滚,而是通过可靠消息传递保障最终一致。做法是“本地事务 + 发消息”要保证原子性。

事务发件箱模式(Outbox Pattern):在业务数据库中额外创建一个 outbox 表,当本地事务提交时,同时插入一条待发送的消息记录。一个独立的发送进程不断地从 outbox 中读取未处理的消息,发送到消息队列,然后标记为已发送。这样就避免了“消息发出去了但事务回滚”或“事务提交了但消息没发”的尴尬。

Node.js 实现思路:

// 伪代码:创建订单的本地事务
await db.transaction(async (trx) => {
  await trx.insert('orders', order);
  await trx.insert('outbox', {
    eventType: 'OrderCreated',
    payload: JSON.stringify(order),
    status: 'pending',
  });
});

// 轮询发送器
setInterval(async () => {
  const rows = await db.query("SELECT * FROM outbox WHERE status = 'pending' LIMIT 10");
  for (const row of rows) {
    try {
      await messageQueue.publish(row.eventType, row.payload);
      await db.update('outbox', { status: 'sent' }, { id: row.id });
    } catch (err) {
      // 可重试
    }
  }
}, 1000);

下游服务通过订阅事件更新自身数据,从而实现最终一致。当某个服务处理失败时,可以利用消息队列的重试和死信队列机制保证后续重试,或发送补偿事件。

方案三:TCC 模式(Try-Confirm-Cancel)

TCC 是两阶段提交的变体,要求每个服务提供三个接口:Try 预留资源,Confirm 确认执行,Cancel 释放预留资源。适合金融等需要精确控制的领域。Node.js 实现 TCC 通常需要自己编写协调器逻辑,并结合事务发件箱或状态机来保证幂等和重试。

23.5.3 选择合适的方案

  • 分布式锁:优先使用单节点 Redis + Lua 释放锁,并发较高或需要高可用则引入 Redlock。对强一致性要求极高时考虑 etcd 或 ZooKeeper。
  • 分布式事务:大多数业务优先考虑最终一致性,通过 Saga 或可靠消息实现。实现时务必保证接口幂等性(允许重试不产生副作用),并设计好补偿逻辑。若传统方案过重,可考虑使用专门的 Saga 框架或云厂商提供的分布式事务服务。

在 Node.js 微服务架构中,得益于事件循环和异步特性,实现这些模式往往比在多线程语言中更轻量:消息消费、状态回查和补偿等流程可以很自然地用 async/await 和队列机制组织起来。