Skip to content

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 }
})

流程

  1. 客户端 A 执行 SET lock:product:1 uuid1 NX EX 10 → 返回 OK,获取锁成功
  2. 客户端 B 执行 SET lock:product:1 uuid2 NX EX 10 → 返回 null,获取锁失败
  3. 客户端 C 执行 SET lock:product:1 uuid3 NX EX 10 → 返回 null,获取锁失败

问题二:为什么需要 UUID 作为锁值?

javascript
const lockValue = randomUUID()  // 每次获取锁生成唯一值

目的:防止误删别人的锁。

场景

  1. 客户端 A 获取锁,设置了 10 秒过期时间
  2. 客户端 A 执行时间过长,10 秒后锁自动释放
  3. 客户端 B 获取同一把锁(lock:product:1)
  4. 客户端 A 执行完毕,执行 DEL lock:product:1
  5. 问题:客户端 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 脚本防止误删
  • 不死锁:过期时间兜底
  • 可重入:引用计数实现(按需)

注意事项

  1. Redis 主从模式下,主故障时锁可能丢失(RedLock 算法可缓解)
  2. 锁粒度要细,太粗会降低并发度
  3. 优先考虑业务层的幂等性,不要完全依赖锁