事务、WATCH 与自动流水线

事务解决“命令按顺序作为一组执行”的问题,流水线解决“减少网络往返”的问题。两者目的不同。

MULTI / EXEC

const [setReply, value] = await client
  .multi()
  .set("nloop:transaction:key", "value")
  .get("nloop:transaction:key")
  .exec();

console.log(setReply, value);

Redis 会依次执行事务中的命令,其他客户端的命令不会插入这组执行过程。

但 Redis 事务与 MySQL 事务不同:

乐观锁 WATCH

当更新依赖旧值时,可以监视键:

async function addPoints(userId: number, points: number): Promise<void> {
  const key: string = `nloop:user:${userId}:points`;

  for (let attempt = 1; attempt <= 3; attempt += 1) {
    await client.watch(key);

    const current: number = Number(await client.get(key) ?? "0");
    const result = await client
      .multi()
      .set(key, String(current + points))
      .exec();

    if (result !== null) {
      return;
    }
  }

  throw new Error("积分更新冲突次数过多");
}

如果 WATCH 后键被其他客户端修改,exec() 返回 null。应用需要重试整个“读取—计算—提交”过程。

WATCH 状态属于连接。高并发下应使用独占连接或连接池,避免不同请求共享监视状态。

优先使用原子命令

上面的积分累加其实不需要 WATCH

await client.incrBy(key, points);

选择顺序通常是:

  1. Redis 已有的原子命令。
  2. 条件参数,例如 NXXX
  3. MULTI / EXEC
  4. WATCH 或 Lua 脚本。

自动流水线

Node-Redis 会将同一个事件循环 tick 中发出的命令自动流水线发送。

推荐用 Promise.all() 明确处理 Promise:

const [name, email, status] = await Promise.all([
  client.hGet("nloop:user:42", "name"),
  client.hGet("nloop:user:42", "email"),
  client.hGet("nloop:user:42", "status"),
]);

反例:

const name = await client.hGet("nloop:user:42", "name");
const email = await client.hGet("nloop:user:42", "email");
const status = await client.hGet("nloop:user:42", "status");

后者产生顺序等待,通常需要更多网络往返。

流水线不是事务

流水线只优化传输:

事务强调执行边界,流水线强调吞吐量。

并发数量也要受控

不要一次 Promise.all() 数十万条命令。应分批处理,例如每批 100 或 500 条,并根据值大小、Redis 延迟和应用内存进行压测。