一、为什么会出现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机制) |
| 并发性能 | 低(锁竞争导致阻塞) | 高(无锁阻塞,冲突时重试) |
| 数据一致性 | 强一致性 | 最终一致性 |
| 适用场景 | 写多读少 | 读多写少 |
| 死锁风险 | 有(需过期时间规避) | 无 |
五、面试级高频考点
-
Redis 悲观锁为什么要用 Lua 脚本释放?
答:避免误删锁问题。如果直接用 DEL 命令,可能出现:客户端 A 获取锁后超时,锁自动释放,客户端 B 获取锁,此时客户端 A 执行 DEL 会删除客户端 B 的锁。Lua 脚本可以原子性地判断锁的持有者,再执行删除。
-
WATCH 命令的底层原理?
答:Redis 为每个被 WATCH 的 key 维护一个版本号,当 key 被修改时,版本号递增。执行 EXEC 时,Redis 会检查监控 key 的版本号是否变化,若变化则事务失败。
-
分布式场景下,Redis 锁如何解决主从复制的坑?
答:主从复制存在数据同步延迟,如果主节点宕机,从节点升级为主节点,可能导致锁丢失。解决方案是使用 RedLock 算法(多主节点部署,需获取半数以上节点的锁才算成功)。