项目实战 # redis已发布

Redis 分布式锁实战:从基础实现到生产环境避坑

在单体应用中,如果多个线程同时修改同一份数据,可以使用 `synchronized`、`ReentrantLock` 等机制控制并发。但当应用部署为多个实例时,这些锁就无法解决跨进程的并发问题...

2024.12.1415 分钟阅读
Redis 分布式锁实战:从基础实现到生产环境避坑
Redis 分布式锁实战:从基础实现到生产环境避坑 · Cover

在单体应用中,如果多个线程同时修改同一份数据,可以使用 synchronized、ReentrantLock 等机制控制并发。但当应用部署为多个实例时,这些锁就无法解决跨进程的并发问题。

例如,一个 NestJS 服务部署了 10 个 Pod,用户同时发起多个请求,要求同一笔订单只能处理一次。即使每个 Pod 内部都使用了 Mutex,也无法阻止其他 Pod 同时执行相同的业务逻辑。

这时候就需要分布式锁。

Redis 具有高性能、支持原子操作、提供过期时间等特性,因此经常被用来实现分布式锁。不过,分布式锁并不是简单地执行一次 SET NX 就万事大吉。锁的获取、释放、过期、续期,以及 Redis 故障时的行为,都需要仔细考虑。

一、为什么需要分布式锁

假设有一个库存为 1 的商品,两个请求几乎同时购买。

请求 A:查询库存,库存为 1
请求 B:查询库存,库存为 1

请求 A:扣减库存,库存变为 0
请求 B:扣减库存,库存变为 -1

由于两个请求同时读取到了旧库存,最终出现超卖。

类似的问题还包括:

  • 定时任务在多个应用实例中重复执行。
  • 同一笔订单被多个消费者同时处理。
  • 多个请求同时刷新缓存,导致数据库承受瞬时压力。
  • 同一个用户的积分、余额等数据被并发修改。
  • 多个服务实例同时执行某个只允许单实例执行的任务。

需要注意,分布式锁不是所有并发问题的首选解决方案。

例如,库存扣减可以通过数据库条件更新解决:

UPDATE product
SET stock = stock - 1
WHERE id = 1001
  AND stock > 0;

应用检查受影响的行数即可判断扣减是否成功。对于单条数据库记录,这通常比引入分布式锁更加直接。

当业务确实需要跨进程协调,或者需要控制一段较复杂的临界区时,Redis 分布式锁才更有价值。

二、Redis 分布式锁的基本原理

一个最基本的分布式锁,需要满足三个要求:

  1. 互斥性:同一时刻,只有一个持有者能够成功获取同一把锁。
  2. 自动过期:持有锁的进程崩溃后,锁不能永久存在。
  3. 安全释放:一个请求只能释放自己持有的锁,不能误删其他请求的锁。

Redis 的 SET 命令可以一次完成锁的创建和过期时间设置。

SET lock:order:1001 unique-token NX EX 30

其中:

  • lock:order:1001:锁的名称,同一业务资源需要使用相同的锁名称。
  • unique-token:当前请求生成的唯一标识。
  • NX:仅当这个键不存在时才设置成功。
  • EX 30:锁的过期时间为 30 秒。

如果锁不存在,Redis 会原子地创建这个键并设置过期时间,返回 OK。如果锁已经存在,则获取失败。

这里有一个关键细节:必须将 NX 和过期时间放在同一次 SET 操作中。

不要使用下面这种写法:

SET lock:order:1001 unique-token
EXPIRE lock:order:1001 30

如果程序在 SET 成功后、EXPIRE 执行前崩溃,就可能留下一个没有过期时间的锁,导致后续请求一直无法获取锁。

为什么锁需要唯一标识

假设请求 A 获取了一把锁,但业务处理超时。锁过期后,请求 B 成功获取了同一把锁。

此时请求 A 恢复执行并尝试释放锁。

如果释放逻辑只是:

DEL lock:order:1001

那么 A 就可能把 B 的锁删除。

因此,锁的值必须是每次获取锁时生成的唯一令牌,例如 UUID。释放锁时,只有 Redis 中的令牌与当前请求持有的令牌一致,才允许删除。

这解决了一个很重要的问题:锁的持有者身份校验。

三、实现一个正确的基础分布式锁

1. 获取锁

Node.js 中可以使用 ioredis 操作 Redis。

import Redis from 'ioredis';
import { randomUUID } from 'node:crypto';

const redis = new Redis(process.env.REDIS_URL!);

async function acquireLock(
  lockKey: string,
  ttlSeconds: number,
): Promise<string | null> {
  const token = randomUUID();

  const result = await redis.set(
    lockKey,
    token,
    'EX',
    ttlSeconds,
    'NX',
  );

  return result === 'OK' ? token : null;
}

调用示例:

const token = await acquireLock('lock:order:1001', 30);

if (!token) {
  throw new Error('业务正在处理中,请稍后重试');
}

try {
  // 执行需要互斥的业务逻辑
  await processOrder('1001');
} finally {
  await releaseLock('lock:order:1001', token);
}

这里的 finally 很重要。无论业务逻辑成功还是抛出异常,都应该尝试释放锁。

但还要注意:如果业务执行时间超过了锁的有效期,锁可能已经被其他请求重新获取。因此,finally 并不能代替安全的释放逻辑。

2. 使用 Lua 脚本安全释放锁

Redis 可以通过 Lua 脚本将“检查锁的持有者”和“删除锁”作为一个原子操作执行。

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

Node.js 中可以这样调用:

async function releaseLock(
  lockKey: string,
  token: string,
): Promise<boolean> {
  const script = `
    if redis.call('GET', KEYS[1]) == ARGV[1] then
      return redis.call('DEL', KEYS[1])
    else
      return 0
    end
  `;

  const result = await redis.eval(
    script,
    1,
    lockKey,
    token,
  );

  return result === 1;
}

为什么这里需要 Lua?

如果把操作拆成两条命令:

GET 锁,发现 token 属于自己
                ↓
          发生并发切换
                ↓
       锁过期,被其他请求获取
                ↓
          DEL 删除新锁

即使先判断了令牌,也可能在判断与删除之间发生竞态。

Lua 脚本在 Redis 中执行时,不会被其他客户端的命令插入执行过程,因此检查和删除能够作为一个整体完成。

安全释放锁是 Redis 分布式锁的基础要求,而不是可选优化。

四、锁的过期时间与业务执行时间

设置过期时间能够避免锁永久占用,但也带来了一个问题:业务执行时间可能超过锁的有效期。

假设锁的 TTL 为 10 秒:

0 秒:A 获取锁
8 秒:A 仍在执行任务
10 秒:A 的锁过期
11 秒:B 获取锁
12 秒:A、B 同时执行临界区

此时,虽然 Redis 中同一时刻只有一个有效锁,但业务层面已经发生并发执行。

这说明一个重要事实:

锁的过期时间只能限制锁的有效期,不能自动终止已经开始执行的业务代码。

1. 合理设置 TTL

如果业务执行时间相对稳定,可以根据实际耗时设置过期时间,并留出一定余量。

例如,普通数据库操作通常可以设置较短的 TTL;批量导入、远程调用等耗时较长的任务,则需要结合实际执行时间评估。

但不能简单地把 TTL 设置得越长越好。锁过期时间太长,进程异常退出后,其他请求需要等待更久才能继续执行。

2. 锁续期机制

对于执行时间无法准确预估的任务,可以考虑锁续期。

基本思路是:

  1. 获取锁时生成唯一令牌。
  2. 记录锁的持有状态。
  3. 在锁过期之前,检查锁是否仍属于自己。
  4. 只有令牌匹配时,才延长过期时间。
  5. 任务结束后停止续期,并安全释放锁。

续期也必须使用 Lua 脚本保证原子性:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
    return 0
end

其中 ARGV[2] 是新的 TTL,单位为秒。

不能直接执行 EXPIRE,否则可能延长其他请求持有的锁。

实际生产中,续期实现还需要考虑进程暂停、事件循环阻塞、Redis 网络故障、续期失败和任务取消等情况。续期失败后,应尽可能停止继续执行可能破坏数据一致性的操作。

如果使用成熟的锁库,通常应优先使用其续期机制,而不是自行维护一套定时器。

五、Redis 主从切换带来的问题

即使获取锁和释放锁都正确实现,也不代表分布式锁已经具备严格的分布式一致性保证。

考虑一个 Redis 主从架构:

客户端 A
   |
   v
Redis 主节点
   |
   | 异步复制
   v
Redis 从节点

可能发生这样的过程:

  1. A 在主节点成功获取锁。
  2. 锁尚未复制到从节点。
  3. 主节点突然宕机。
  4. 从节点被提升为新的主节点。
  5. 新主节点上不存在这把锁。
  6. B 成功获取同一把锁。

此时 A 可能仍在执行任务,A 与 B 便有机会同时进入临界区。

Redis 的主从复制通常是异步的,因此,主从切换可能导致锁状态丢失。单纯使用 SET NX EX 并不能彻底消除这个风险。

1. Redlock 是什么

Redlock 是 Redis 提出的一种多节点分布式锁算法。

它的基本思路是:在多个相互独立的 Redis 节点上尝试获取同一把锁,只有在规定时间内获得多数节点的锁,并且扣除获取过程耗费的时间后,剩余有效时间仍然大于零,才认为获取成功。

例如,使用 5 个独立节点时,通常需要至少 3 个节点成功。

算法还要求处理获取失败时的清理、节点请求耗时、锁的有效时间,以及客户端时钟等问题。

Redlock 比只依赖一个 Redis 主节点的简单锁方案考虑了更多故障场景,但它并不意味着在任意网络分区、长时间进程暂停或时钟异常下都能提供绝对安全保证。它的安全性和适用条件也存在讨论。

因此,是否使用 Redlock,需要结合业务风险、部署拓扑和一致性要求决定,不能只因为节点数量更多就认为它一定适合。

2. 为什么还需要 fencing token

对于支付、资金、库存、任务调度等对一致性要求较高的场景,仅依赖锁的 TTL 和客户端令牌可能不够。

原因是:一个客户端即使已经失去了锁,也可能因为进程暂停、网络延迟等原因恢复执行,并继续向数据库或其他系统写入数据。

这时可以考虑 fencing token(栅栏令牌)。

每次获取锁时,系统分配一个严格递增的令牌。例如:

A 获取锁,令牌为 41
A 暂停,锁失效
B 获取锁,令牌为 42
B 写入数据
A 恢复执行,尝试使用令牌 41 写入

如果下游存储能够拒绝小于当前最新令牌的写操作,那么 A 的过期操作就可以被拦截。

但需要注意:普通 Redis INCR 生成的递增值,并不会自动解决所有故障场景。若要获得可靠的栅栏语义,令牌生成和下游校验都必须有明确的正确性保证,不能仅仅给锁增加一个数字就算完成。

对于资金和库存等场景,优先考虑数据库事务、唯一约束、乐观锁、条件更新,以及能够在写入端校验栅栏令牌的方案。

六、在 NestJS 中使用分布式锁

在 NestJS 项目中,可以将锁的获取和释放封装为独立服务,避免业务代码重复编写 Lua 脚本。

例如,使用 ioredis 实现一个简化的 RedisLockService:

import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { randomUUID } from 'node:crypto';

@Injectable()
export class RedisLockService {
  constructor(private readonly redis: Redis) {}

  async acquire(
    key: string,
    ttlSeconds = 30,
  ): Promise<string | null> {
    const token = randomUUID();

    const result = await this.redis.set(
      key,
      token,
      'EX',
      ttlSeconds,
      'NX',
    );

    return result === 'OK' ? token : null;
  }

  async release(
    key: string,
    token: string,
  ): Promise<boolean> {
    const script = `
      if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('DEL', KEYS[1])
      end
      return 0
    `;

    const result = await this.redis.eval(
      script,
      1,
      key,
      token,
    );

    return result === 1;
  }
}

这里假设 Redis 实例已经通过 NestJS 的依赖注入机制正确注册。

业务服务可以这样使用:

async handleOrder(orderId: string) {
  const key = `lock:order:${orderId}`;
  const token = await this.lockService.acquire(key, 30);

  if (!token) {
    throw new Error('订单正在处理中');
  }

  try {
    await this.orderService.process(orderId);
  } finally {
    await this.lockService.release(key, token);
  }
}

这只是一个基础版本。它没有自动续期、等待重试、获取超时、进程崩溃恢复等功能,也不能解决主从切换导致的锁丢失问题。

另外,实际项目中还应确保 Redis 客户端的注入方式、连接生命周期和异常处理符合项目使用的 NestJS 版本。

分布式锁要不要等待

获取锁失败后,业务通常有三种选择:

  • 立即失败:适合请求重复、竞争激烈且允许用户重试的接口。
  • 短暂重试:适合偶尔发生竞争的任务,但必须设置总等待时间和退避策略。
  • 排队处理:适合不要求立即执行的任务,可以使用 Kafka、Bull 等消息队列。

不要无限循环执行 SET NX。这种做法不仅可能造成大量 Redis 请求,还会增加系统延迟,甚至在高竞争时放大故障。

七、分布式锁的常见踩坑

1. 使用固定字符串作为锁值

错误做法:

SET lock:task running NX EX 30

所有客户端使用相同的锁值,无法区分谁是锁的真正持有者。

正确做法是每次获取锁都生成独立令牌,并在释放时校验。

2. 获取锁与设置 TTL 分开执行

分成两条命令存在进程崩溃窗口。应使用一条带 NX 和过期时间的 SET 命令。

3. 直接删除锁

不能直接 DEL,必须通过 Lua 脚本校验令牌后再删除。

4. 认为锁过期就代表业务停止

Redis 删除锁键,不会中断持有者正在执行的 JavaScript 代码、数据库事务或远程请求。

如果业务可能超过 TTL,就需要合理设置过期时间、考虑续期,或者使用更强的写入端一致性控制。

5. 把分布式锁当成幂等方案

分布式锁主要用于控制并发,不等于业务幂等。

例如,支付回调重复到达时,即使每次都正确获取和释放锁,业务代码仍可能因为重试、超时和状态判断不严谨而重复处理。

关键业务通常还需要幂等键、唯一约束、状态机、数据库事务等机制。

6. 忽略 Redis 故障和网络超时

当 Redis 请求超时时,客户端不一定能确定操作是否已经在 Redis 中执行成功。

对于获取锁请求,不能简单地将网络超时等同于获取失败后立即进入临界区。应该按获取失败处理,或采用能够安全确认持锁状态的实现,并为重试设置边界。

释放锁请求超时也不代表释放一定失败。应结合令牌校验和过期机制处理,而不是盲目删除锁键。

7. 忽略业务自身的并发控制

即使使用了分布式锁,也应在数据库层保留必要的约束和一致性控制。

例如,余额更新应使用事务和正确的条件更新;创建业务单据时应使用唯一约束防止重复;消费消息时应考虑重复投递和幂等处理。

分布式锁可以降低竞争,但不应该成为唯一的数据正确性保障。

八、生产环境如何选择方案

不同业务场景需要不同的并发控制手段。

场景优先考虑的方案
单条库存扣减数据库条件更新或事务
防止订单重复创建幂等键、唯一约束、事务
定时任务避免多实例重复执行Redis 锁或成熟的任务调度框架
缓存重建、热点数据刷新Redis 锁,配合缓存过期策略
消息消费防止重复处理消费幂等、业务唯一约束
长时间运行的任务有续期能力的成熟锁库,或任务队列
高一致性资金操作数据库事务、状态机、幂等控制;必要时采用栅栏令牌等机制
需要严格协调且不能容忍锁丢失的系统评估具备相应一致性语义的协调服务及写入端校验

实际工程中,还需要关注以下几个方面:

  • 锁粒度:尽量使用 lock:order:1001 这种资源级锁,避免一把锁阻塞所有业务。
  • 等待策略:明确获取锁失败后的行为,避免无限重试。
  • 监控指标:记录获取成功率、竞争次数、持锁时长、续期失败、释放失败和异常超时。
  • 日志追踪:记录业务资源 ID、锁 key、令牌标识和请求 ID,便于排查并发问题。注意不要在日志中泄露敏感信息。
  • 故障演练:验证进程被杀、Redis 重启、网络超时、业务执行超时等情况下,系统是否仍然能够保护数据。
  • 正确性边界:明确锁能解决哪些竞争问题,以及哪些问题必须由数据库或下游系统兜底。

总结

Redis 分布式锁的基础并不复杂,核心是使用 SET NX EX 原子获取锁,使用唯一令牌标识持有者,并通过 Lua 脚本安全释放锁。

真正困难的是生产环境中的边界问题:任务执行超过 TTL、进程暂停、Redis 主从切换、网络请求超时,以及业务重试带来的重复操作。

因此,设计分布式锁时,不应该只问“怎么把锁加上”,还应该问:

  1. 锁过期后,旧持有者是否可能继续执行?
  2. Redis 故障或主从切换时,锁状态丢失会不会造成数据错误?
  3. 获取锁失败、续期失败和释放超时后,业务应该如何处理?
  4. 即使锁失效,数据库是否仍然能够保证关键数据正确?
  5. 业务是否还需要幂等、事务、唯一约束或栅栏令牌?

对于普通的缓存重建、定时任务协调等场景,Redis 分布式锁通常是一个实用的工具;对于支付、资金和高一致性数据写入,则应该将它视为并发控制的一部分,而不是唯一的安全屏障。

#redis
ZZC

ZZC

写代码,也写一些生活。