Redis系列:分布式锁的原理与实现详解
INFO
本文适合谁:需要解决并发控制、分布式系统同步问题的后端开发者。 前置条件:了解 Redis 基本数据类型,阅读过 Redis 基础 API 解读。
一、为什么需要分布式锁?
1.1 单机环境的锁
在单机环境中,我们可以用进程内的锁来解决并发问题:
javascript
const lock = new Mutex()
async function updateCounter() {
await lock.acquire()
try {
const current = await db.get('counter')
await db.set('counter', current + 1)
} finally {
lock.release()
}
}1.2 分布式环境的挑战
但当服务横向扩展到多节点时:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Node A │ │ Node B │ │ Node C │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└────────────┴────────────┘
│
┌────┴────┐
│ Redis │
└─────────┘- Node A 的锁,Node B 看不见
- 三台机器同时操作同一条数据
- 库存超卖、重复下单、数据不一致
分布式锁要解决的问题:让多台机器对共享资源的访问互斥。
1.3 分布式锁的四大特性
| 特性 | 说明 | 重要性 |
|---|---|---|
| 互斥性 | 同一时刻只有一个客户端能持有锁 | ⭐⭐⭐ |
| 无死锁 | 即使持有锁的客户端崩溃,锁也能被正确释放 | ⭐⭐⭐ |
| 可重入 | 同一客户端可以多次获取同一把锁 | ⭐⭐ |
| 高性能 | 加锁/解锁操作延迟低,支持高并发 | ⭐⭐ |
二、分布式锁实现
2.1 基础版实现
javascript
import { RedisClient } from "./redis-client.js";
import { randomUUID } from 'crypto'
class RedisLock {
async getClient() {
return RedisClient.getInstance()
}
/**
* 获取锁
* @param key 锁的键
* @param ttl 锁自动释放的时间(秒)
* @param retryTimes 重试次数
* @param retryDelay 重试间隔
* @returns 释放函数,获取失败会抛出错误
*/
async acquireLock(key, ttl, retryTimes = 3, retryDelay = 100) {
const client = await this.getClient()
const lockKey = `lock:${key}`
const lockValue = randomUUID()
for (let i = 0; i <= retryTimes; i++) {
// NX=仅当键不存在时设置,EX=过期时间(秒)
const acquired = await client.set(lockKey, lockValue, {
condition: 'NX',
expiration: { type: "EX", value: ttl }
})
if (acquired === 'OK') {
// 返回释放函数
return () => this.releaseLock(key, lockValue)
}
if (i < retryTimes) {
await new Promise(resolve => setTimeout(resolve, retryDelay))
}
}
throw new TypeError(`获取锁失败:${lockKey}`)
}
/**
* 释放锁(使用Lua脚本确保原子性)
* @param key 锁的键
* @param lockValue 获取锁时返回的唯一标识
*/
async releaseLock(key, lockValue) {
const client = await this.getClient()
const lockKey = `lock:${key}`
// Lua脚本:只有锁值匹配才删除,保证只释放自己的锁
const script = `
if redis.call("get", KEYS[1]) === ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
const result = await client.eval(script, {
keys: [lockKey],
arguments: [lockValue]
})
return result === 1
}
}
export default new RedisLock()2.2 核心原理拆解
问题一:如何保证互斥?
javascript
// 使用 SET key value NX EX 10
// NX:Not eXists,仅当键不存在时才设置
// EX:Expire,设置过期时间
await client.set(lockKey, lockValue, {
condition: 'NX',
expiration: { type: "EX", value: ttl }
})流程:
- 客户端 A 执行
SET lock:product:1 uuid1 NX EX 10→ 返回OK,获取锁成功 - 客户端 B 执行
SET lock:product:1 uuid2 NX EX 10→ 返回null,获取锁失败 - 客户端 C 执行
SET lock:product:1 uuid3 NX EX 10→ 返回null,获取锁失败
问题二:为什么需要 UUID 作为锁值?
javascript
const lockValue = randomUUID() // 每次获取锁生成唯一值目的:防止误删别人的锁。
场景:
- 客户端 A 获取锁,设置了 10 秒过期时间
- 客户端 A 执行时间过长,10 秒后锁自动释放
- 客户端 B 获取同一把锁(lock:product:1)
- 客户端 A 执行完毕,执行
DEL lock:product:1 - 问题:客户端 A 删除了客户端 B 的锁!
解决:删除前先检查锁值是否匹配,只有匹配才删除。
lua
-- Lua脚本保证检查和删除的原子性
if redis.call("get", KEYS[1]) === ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end问题三:过期时间设多长?
太短的问题:业务还没执行完,锁就释放了
客户端A获取锁(10秒过期)
↓
业务执行中...(需要15秒)
↓
第10秒:锁自动释放
↓
客户端B获取锁
↓
客户端C获取锁(出现两个客户端同时持有锁!)太长的代价:故障后需等待很久才能自动释放
推荐策略:
- 预估业务执行时间的 2-3 倍
- 开启看门狗机制(Redisson 实现),自动续期
问题四:重试机制
javascript
for (let i = 0; i <= retryTimes; i++) {
const acquired = await client.set(lockKey, lockValue, { condition: 'NX', ... })
if (acquired === 'OK') return releaseFunction
if (i < retryTimes) {
await new Promise(resolve => setTimeout(resolve, retryDelay))
}
}参数选择:
retryTimes:3-5 次retryDelay:100-300ms
三、可重入锁实现
3.1 什么是可重入?
javascript
async function outer() {
await lock.acquireLock('resource:1', 30)
try {
await inner() // 同一把锁,递归调用
} finally {
lock.releaseLock('resource:1')
}
}
async function inner() {
await lock.acquireLock('resource:1', 30) // 这里会死锁吗?
try {
console.log('do something')
} finally {
lock.releaseLock('resource:1')
}
}可重入锁:同一个客户端可以多次获取同一把锁,锁的引用计数 +1,释放时引用计数 -1,减到 0 才真正释放。
3.2 可重入实现
javascript
class ReentrantLock {
constructor() {
this.local = new Map() // 本地存储,用于记录重入次数
}
async acquireLock(key, ttl, retryTimes = 3, retryDelay = 100) {
// 同一客户端,可重入(通过 lockKey 识别)
if (this.local.has(key)) {
const entry = this.local.get(key)
entry.count++
return entry.releaseFunc
}
// 首次获取
const releaseFunc = await this._acquireRedisLock(key, ttl, retryTimes, retryDelay)
this.local.set(key, {
count: 1,
releaseFunc
})
return releaseFunc
}
releaseLock(key) {
const entry = this.local.get(key)
if (!entry) return false
entry.count--
if (entry.count <= 0) {
entry.releaseFunc()
this.local.delete(key)
}
return true
}
}Node.js 环境的可重入
Node.js 是单线程执行模型,同一进程内的"重入"实际上是逻辑上的重入。如果有多 worker 进程(如 cluster 模块),仍需借助 Redis 实现可重入(将 count 存入 Redis)。
四、看门狗机制(自动续期)
4.1 问题
javascript
// 客户端获取锁,设置10秒过期
await lock.acquireLock('order:1', 10)
// 但业务执行需要15秒...
await processOrder() // 耗时15秒
// 第10秒锁已自动释放,此时其他客户端可能修改数据
// 第15秒客户端执行完毕,尝试释放锁(但锁已经是别人的了)4.2 看门狗方案
javascript
class WatchdogLock {
constructor(lockTTL = 30, watchdogInterval = 10) {
this.lockTTL = lockTTL
this.watchdogInterval = watchdogInterval
this.holdLocks = new Map()
}
async acquireLock(key, ttl, retryTimes = 3, retryDelay = 100) {
ttl = ttl || this.lockTTL
const releaseFunc = await this._acquireRedisLock(key, ttl, retryTimes, retryDelay)
// 开启看门狗,自动续期
const watchdog = setInterval(async () => {
const client = await this.getClient()
await client.expire(`lock:${key}`, this.lockTTL)
}, this.watchdogInterval * 1000)
// 保存 watchdog timer,用于释放时停止
const releaseWithWatchdog = () => {
clearInterval(watchdog)
return releaseFunc()
}
this.holdLocks.set(key, { releaseFunc: releaseWithWatchdog, watchdog })
return releaseWithWatchdog
}
async _releaseWithWatchdog(key) {
const entry = this.holdLocks.get(key)
if (entry) {
clearInterval(entry.watchdog)
await entry.releaseFunc()
this.holdLocks.delete(key)
}
}
}五、具体业务场景
5.1 订单库存扣减
javascript
import lock from './redis-lock.js'
import { RedisClient } from './redis-client.js'
class OrderService {
async createSeckillOrder(userId, productId) {
const lockKey = `lock:seckill:${productId}`
// 获取锁(防止超卖)
const release = await lock.acquireLock(lockKey, 10, 3, 100)
try {
const client = await RedisClient.getInstance()
// 查询库存
const stock = await client.get(`stock:${productId}`)
if (!stock || parseInt(stock) <= 0) {
throw new Error('库存不足')
}
// 扣减库存
await client.decr(`stock:${productId}`)
// 创建订单
const order = await this.saveOrder({ userId, productId, status: 'pending' })
return order
} finally {
// 释放锁
release()
}
}
}5.2 防止重复提交
javascript
import lock from './redis-lock.js'
import { RedisClient } from './redis-client.js'
class FormSubmissionService {
async submitForm(userId, formData) {
const lockKey = `lock:form:submit:${userId}`
const lockTTL = 5 // 5秒足够
// 尝试获取锁
try {
const release = await lock.acquireLock(lockKey, lockTTL, 0, 0)
try {
const client = await RedisClient.getInstance()
// 检查是否已提交
const exists = await client.exists(`submitted:${userId}`)
if (exists) {
throw new Error('请勿重复提交')
}
// 执行业务
await this.saveForm(userId, formData)
// 标记已提交(设置较长过期时间)
await client.setEx(`submitted:${userId}`, 86400, 1)
} finally {
release()
}
} catch (err) {
if (err.message.includes('获取锁失败')) {
throw new Error('请求过于频繁,请稍后重试')
}
throw err
}
}
}5.3 分布式任务调度
javascript
import lock from './redis-lock.js'
import { RedisClient } from './redis-client.js'
class TaskScheduler {
async executeTask(taskId) {
const lockKey = `lock:task:${taskId}`
const lockTTL = 300 // 任务执行最多5分钟
// 尝试获取锁,确保任务不被重复执行
try {
const release = await lock.acquireLock(lockKey, lockTTL, 0, 0)
const client = await RedisClient.getInstance()
try {
// 检查任务状态
const status = await client.get(`task:status:${taskId}`)
if (status === 'running') {
console.log(`任务 ${taskId} 已在运行中,跳过`)
return
}
// 标记任务开始
await client.setEx(`task:status:${taskId}`, 'running', lockTTL)
// 执行任务
await this.runTask(taskId)
// 标记任务完成
await client.setEx(`task:status:${taskId}`, 'completed', lockTTL)
} finally {
release()
}
} catch (err) {
console.error(`任务 ${taskId} 执行失败:`, err)
}
}
}六、分布式锁 Checklist
| 检查项 | 说明 |
|---|---|
| 锁值唯一性 | 必须使用 UUID,防止误删他人锁 |
| 原子性释放 | 使用 Lua 脚本,保证 check-delete 原子 |
| 过期时间 | 预估业务时间 × 2~3 倍 |
| 重试机制 | 合理设置重试次数和间隔 |
| 幂等性 | 同一业务逻辑可多次执行结果一致 |
| 可重入 | 同一客户端多次获取需计数管理 |
| 看门狗 | 长时间任务需自动续期 |
七、总结与思考
分布式锁是分布式系统中解决并发问题的利器:
- 互斥:SET NX EX 保证唯一持有
- 安全:UUID + Lua 脚本防止误删
- 不死锁:过期时间兜底
- 可重入:引用计数实现(按需)
注意事项:
- Redis 主从模式下,主故障时锁可能丢失(RedLock 算法可缓解)
- 锁粒度要细,太粗会降低并发度
- 优先考虑业务层的幂等性,不要完全依赖锁
