Redis 悲观锁与乐观锁

一、为什么会出现Redis锁机制?

Redis作为高性能分布式内存数据库,常被用于分布式系统的缓存、计数器、分布式任务调度等场景。

当多个客户端(或多个线程/进程)同时操作同一份Redis数据时,会引发并发安全问题:

  • 比如库存扣减时,n请求同时读取到库存为100,都执行 -1 操作,最终库存可能变成 99 而非 100-n;
  • 比如分布式任务调度中,多个节点同时争抢一个任务,导致任务被重复执行。

为了解决分布式场景下的并发冲突,Redis衍生出了悲观锁和乐观锁两种核心锁机制,二者的设计思路完全相反,适用于不同的并发场景。

二、Redis 悲观锁

1.是什么?

思想:悲观地认为每次操作数据都会发生并发冲突,因此在操作数据前,必须先获取锁,确保同一时间只有一个客户端能操作该数据,其他客户端需等待锁释放后才能执行。

Redis中的悲观锁并不是内置功能,而是基于 SETNX (SET if Not eXists)命令 + 过期时间实现的独占锁。

2.底层原理

Redis是单线程模型,所有命令都是原子性执行的,这是悲观锁能实现的核心基础。

  • SETNX key value:仅当key不存在时才会设置成功,返回1;若key已存在,返回0。
  • 为了避免客户端获取锁后崩溃导致锁永久无法释放,必须给锁设置过期时间(通过 EX 参数,或 SETEX 命令)。
  • 锁的释放必须是原子操作:不能直接用 DEL 命令(否则可能误删其他客户端的锁),需通过 Lua 脚本 判断锁的持有者是否为当前客户端,再执行删除。

3.C 语言代码实现(基于 hiredis)

前置条件

安装 hiredis 客户端库: sudo apt-get install libhiredis-dev

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <hiredis/hiredis.h>
#include <unistd.h>

// 锁的键名
#define LOCK_KEY "redis_pessimistic_lock"
// 锁的过期时间(秒)
#define LOCK_EXPIRE 10
// 锁的持有者标识(唯一,比如客户端 IP+PID)
#define LOCK_VALUE "client_127.0.0.1_12345"

// 获取悲观锁
int redis_pessimistic_lock(redisContext *ctx) {
    // 使用 SET 命令实现:SET key value NX EX expire
    // NX:仅当 key 不存在时设置;EX:设置过期时间
    redisReply *reply = redisCommand(ctx, "SET %s %s NX EX %d", LOCK_KEY, LOCK_VALUE, LOCK_EXPIRE);
    if (reply == NULL) {
        printf("获取锁失败:命令执行异常\n");
        return 0;
    }
    // 设置成功返回 OK,失败返回 nil
    int lock_result = (strcmp(reply->str, "OK") == 0) ? 1 : 0;
    freeReplyObject(reply);
    return lock_result;
}

// 释放悲观锁(原子操作,通过 Lua 脚本)
int redis_pessimistic_unlock(redisContext *ctx) {
    const char *lua_script = 
        "if redis.call('get', KEYS[1]) == ARGV[1] then "
        "   return redis.call('del', KEYS[1]) "
        "else "
        "   return 0 "
        "end";
    redisReply *reply = redisCommand(ctx, "EVAL %s 1 %s %s", lua_script, LOCK_KEY, LOCK_VALUE);
    if (reply == NULL) {
        printf("释放锁失败:命令执行异常\n");
        return 0;
    }
    int unlock_result = (reply->integer == 1) ? 1 : 0;
    freeReplyObject(reply);
    return unlock_result;
}

// 模拟业务操作:扣减库存
void deduct_stock(redisContext *ctx) {
    redisReply *reply = redisCommand(ctx, "DECR stock");
    if (reply) {
        printf("库存扣减成功,剩余库存:%lld\n", reply->integer);
        freeReplyObject(reply);
    }
}

int main() {
    // 连接 Redis
    redisContext *ctx = redisConnect("127.0.0.1", 6379);
    if (ctx == NULL || ctx->err) {
        printf("Redis 连接失败:%s\n", ctx ? ctx->errstr : "连接对象为空");
        return 1;
    }
    printf("Redis 连接成功\n");

    // 1. 初始化库存(仅执行一次)
    redisCommand(ctx, "SET stock 100");

    // 2. 尝试获取锁
    int retry_count = 0;
    int max_retry = 5;
    int lock_acquired = 0;
    while (retry_count < max_retry && !lock_acquired) {
        lock_acquired = redis_pessimistic_lock(ctx);
        if (!lock_acquired) {
            printf("获取锁失败,重试中...(%d/%d)\n", retry_count + 1, max_retry);
            sleep(1);
            retry_count++;
        }
    }
    if (!lock_acquired) {
        printf("多次重试后仍未获取锁,放弃操作\n");
        redisFree(ctx);
        return 1;
    }
    printf("成功获取锁\n");

    // 3. 执行核心业务:扣减库存
    deduct_stock(ctx);

    // 4. 释放锁
    if (redis_pessimistic_unlock(ctx)) {
        printf("锁释放成功\n");
    } else {
        printf("锁释放失败(可能锁已过期或被其他客户端持有)\n");
    }

    // 关闭连接
    redisFree(ctx);
    return 0;
}

编译运行

bash 复制代码
gcc redis_pessimistic_lock.c -o lock_demo -lhiredis
./lock_demo

4.优缺点

优点 缺点
实现简单,逻辑直观,能完全保证数据一致性 加锁会导致并发度降低,其他客户端需等待锁释放
适用于写操作频繁的场景 存在死锁风险(需通过过期时间避免)
锁的粒度可控(可针对单个 key 加锁) 可能出现锁竞争,导致性能瓶颈

5.适用场景

  • 写操作远多于读操作的场景(如库存扣减、订单创建);
  • 对数据一致性要求极高的分布式场景(如分布式事务)。

三、Redis乐观锁

1.是什么?

核心思想:乐观地认为每次操作数据都不会发生并发冲突,因此操作时不提前加锁,而是在更新数据时,检查数据的版本号(或哈希值)是否被其他客户端修改过。

如果版本号一致,说明数据未被修改,执行更新;如果版本号不一致,说明数据已被修改,放弃当前更新或重试。

Redis 乐观锁的核心实现是 WATCH 命令 + 事务( MULTI/EXEC )。

2.底层实现原理

  • WATCH key [key ...] :监控一个或多个 key,在事务执行前,如果这些 key 被其他客户端修改,那么当前事务会被打断(执行失败)。
  • MULTI :开启事务,后续命令会被放入事务队列,不会立即执行。
  • EXEC :执行事务队列中的命令。如果监控的 key 未被修改,事务执行成功;否则,事务返回 nil ,执行失败。
  • 本质:基于 CAS(Compare And Swap,比较并交换) 机制实现,无锁但能保证并发安全。

3.C 语言代码实现(基于 hiredis)

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <hiredis/hiredis.h>
#include <unistd.h>

// 模拟业务操作:乐观锁实现库存扣减
int deduct_stock_optimistic(redisContext *ctx) {
    // 1. WATCH 监控库存 key
    redisReply *watch_reply = redisCommand(ctx, "WATCH stock");
    if (watch_reply == NULL) {
        printf("WATCH 命令执行失败\n");
        return 0;
    }
    freeReplyObject(watch_reply);

    // 2. 获取当前库存
    redisReply *get_reply = redisCommand(ctx, "GET stock");
    if (get_reply == NULL || get_reply->type == REDIS_REPLY_NIL) {
        printf("获取库存失败\n");
        freeReplyObject(get_reply);
        return 0;
    }
    int current_stock = atoi(get_reply->str);
    freeReplyObject(get_reply);
    if (current_stock <= 0) {
        printf("库存不足,无法扣减\n");
        // 取消监控
        redisCommand(ctx, "UNWATCH");
        return 0;
    }

    // 3. 开启事务
    redisReply *multi_reply = redisCommand(ctx, "MULTI");
    freeReplyObject(multi_reply);

    // 4. 向事务队列中添加扣减命令
    redisReply *decr_reply = redisCommand(ctx, "DECR stock");
    freeReplyObject(decr_reply);

    // 5. 执行事务
    redisReply *exec_reply = redisCommand(ctx, "EXEC");
    if (exec_reply == NULL) {
        printf("事务执行异常\n");
        return 0;
    }
    // 事务执行成功:返回数组;失败:返回 nil
    int exec_result = (exec_reply->type == REDIS_REPLY_ARRAY && exec_reply->elements > 0) ? 1 : 0;
    if (exec_result) {
        printf("库存扣减成功,剩余库存:%lld\n", ((redisReply*)exec_reply->element[0])->integer);
    } else {
        printf("库存扣减失败:数据已被其他客户端修改\n");
    }
    freeReplyObject(exec_reply);
    return exec_result;
}

int main() {
    // 连接 Redis
    redisContext *ctx = redisConnect("127.0.0.1", 6379);
    if (ctx == NULL || ctx->err) {
        printf("Redis 连接失败:%s\n", ctx ? ctx->errstr : "连接对象为空");
        return 1;
    }
    printf("Redis 连接成功\n");

    // 初始化库存
    redisCommand(ctx, "SET stock 100");

    // 乐观锁扣减库存(可重试多次)
    int retry_count = 0;
    int max_retry = 3;
    int success = 0;
    while (retry_count < max_retry && !success) {
        success = deduct_stock_optimistic(ctx);
        if (!success) {
            printf("重试扣减库存...(%d/%d)\n", retry_count + 1, max_retry);
            retry_count++;
            usleep(100000); // 休眠 100ms
        }
    }

    // 关闭连接
    redisFree(ctx);
    return 0;
}

编译运行

bash 复制代码
gcc redis_optimistic_lock.c -o optimistic_demo -lhiredis
./optimistic_demo

4.关键注意事项

  • UNWATCH 命令:当事务执行失败或不需要执行事务时,需调用 UNWATCH 取消监控,否则 WATCH 会一直占用资源。
  • 重试机制:乐观锁执行失败后,通常需要重试(因为冲突概率较低),重试前需重新获取最新数据。
  • 不适合写频繁场景:如果并发写冲突率高,会导致大量事务重试,反而降低性能。

5.优缺点

优点 缺点
无锁竞争,并发度极高,性能优于悲观锁 冲突率高时会频繁重试,导致性能下降
实现轻量,无需维护锁的生命周期 无法保证绝对的原子性,仅能保证最终一致性
适用于读操作频繁的场景 逻辑相对复杂,需要处理重试逻辑

6.适用场景

  • 读操作远多于写操作的场景(如商品详情页缓存、用户信息查询);
  • 并发冲突概率低的场景(如个人数据修改)。

四、悲观锁 vs 乐观锁 对比

纬度 悲观锁 乐观锁
思想 假设冲突一定会发生,提前加锁 假设冲突不会发生,事后检查
实现方式 SETNX + 过期时间 + Lua脚本 WATCH + MULTI / EXEC (CAS机制)
并发性能 低(锁竞争导致阻塞) 高(无锁阻塞,冲突时重试)
数据一致性 强一致性 最终一致性
适用场景 写多读少 读多写少
死锁风险 有(需过期时间规避)

五、面试级高频考点

  1. Redis 悲观锁为什么要用 Lua 脚本释放?

    答:避免误删锁问题。如果直接用 DEL 命令,可能出现:客户端 A 获取锁后超时,锁自动释放,客户端 B 获取锁,此时客户端 A 执行 DEL 会删除客户端 B 的锁。Lua 脚本可以原子性地判断锁的持有者,再执行删除。

  2. WATCH 命令的底层原理?

    答:Redis 为每个被 WATCH 的 key 维护一个版本号,当 key 被修改时,版本号递增。执行 EXEC 时,Redis 会检查监控 key 的版本号是否变化,若变化则事务失败。

  3. 分布式场景下,Redis 锁如何解决主从复制的坑?

    答:主从复制存在数据同步延迟,如果主节点宕机,从节点升级为主节点,可能导致锁丢失。解决方案是使用 RedLock 算法(多主节点部署,需获取半数以上节点的锁才算成功)。

相关推荐
用户99045017780095 小时前
若依AI项目专属域名ruoyiai.cn限时出售,打造AI开发领域品牌标杆
面试
晚风_END6 小时前
Linux|服务器运维|diff和vimdiff命令详解
linux·运维·服务器·开发语言·网络
HIT_Weston6 小时前
83、【Ubuntu】【Hugo】搭建私人博客:文章目录(二)
linux·运维·ubuntu
.普通人6 小时前
树莓派4Linux 单个gpio口驱动编写
linux
100分简历6 小时前
2026年求职简历模板大全推荐
面试·职场和发展·编辑器·求职招聘·职场发展
luckily灬6 小时前
Docker执行hello-world报错&Docker镜像源DNS解析异常处理
linux·docker
Go高并发架构_王工6 小时前
Redis未来展望:Redis 7.0新特性与技术发展趋势
数据库·redis·缓存
REDcker7 小时前
C++ 崩溃堆栈捕获库详解
linux·开发语言·c++·tcp/ip·架构·崩溃·堆栈
技术小李...7 小时前
Linux7.2安装Lsync3.1.2文件同步服务
linux·lsync