什么叫事物???
事务(Transaction)本质是一组操作的集合,核心目标是保证这组操作的「完整性」—— 简单说就是:要么这组操作全部执行成功,要么全部不执行,不会出现「部分执行」的情况。
你可以把事务理解成生活中的「转账场景」:A 给 B 转 100 元,需要执行两步:A 扣 100 元、B 加 100 元。这两步必须同时成功(转账完成),或同时失败(钱都不变),绝对不能出现「A 扣了钱但 B 没收到」的情况,事务就是用来保证这种「要么全成、要么全败」的机制。
事物的四个特性:
原子性是手段:保证事务内的操作要么全做,要么全不做,避免 “部分执行” 的中间态。
一致性是目的:保证事务执行前后数据库始终处于合法状态。数据库必须从一个合法的状态转换到另一个合法的状态,始终满足所有预定义的约束和业务规则。
隔离性是手段:多个并发事务同时执行时,彼此之间相互隔离,一个事务的执行不能被其他事务干扰,其内部操作和数据对其他事务是不可见的。
持久性是保障:一旦事务成功提交,其对数据库的修改就是永久性的,即使系统崩溃、断电,数据也不会丢失。
redis事物和mysql事物的区别
| 特性 | MySQL 事务 | Redis 事务 |
|---|---|---|
| 原子性(Atomicity) | 完全支持:只要有一步失败,所有操作回滚(撤销),回到初始状态 | 仅「弱原子性」:1. 语法错误(如命令写错):EXEC 后全部不执行;2. 运行时错误(如对字符串做哈希操作):错误命令失败,其他命令照常执行(无回滚) |
| 一致性(Consistency) | 严格保证:事务执行前后,数据符合业务规则(如转账后总金额不变) | 仅靠业务逻辑保证:Redis 本身不校验数据一致性,需开发者自己控制 |
| 隔离性(Isolation) | 支持 4 种隔离级别(读未提交、读已提交、可重复读、串行化),解决脏读 / 幻读 / 不可重复读 | 仅「串行执行」:事务内命令按顺序执行,执行期间不会被其他客户端打断,但无隔离级别概念 |
| 持久性(Durability) | 可通过 redo/undo 日志、刷盘策略保证:事务提交后数据永久保存 | 依赖 Redis 持久化(RDB/AOF):若未开启持久化,事务提交后重启 Redis 数据会丢失 |
| 锁机制 | 悲观锁为主(行锁 / 表锁):事务期间会锁定操作的行 / 表,防止并发修改 | 乐观锁(WATCH 命令):仅监控 key,若事务执行前 key 被修改,事务直接放弃执行,无主动加锁 |
| 回滚机制 | 支持回滚(ROLLBACK):失败后撤销所有已执行操作 | 无回滚机制:Redis 认为「运行时错误是开发者的问题」,不提供回滚功能 |
| 适用场景 | 复杂业务逻辑(如订单、支付、账务),强一致性要求 | 简单批量操作(如批量设值、计数),高性能优先,弱一致性要求 |
对于我们熟知的事物是支持ACID的
但是redis的事物有点特殊:不支持回滚操作、可以说是弱原子性,无一致性无隔离性无持久性
Redis 事务适合用于命令打包执行,减少网络往返,保证命令的顺序执行。
它不适合用于需要强一致性和复杂业务逻辑的场景(如金融交易),这些场景应该使用支持 ACID 的关系型数据库。
设计者认为在事物当中出现语法错误问题应当是你应用者的问题,你应该自行在业务层面进行管理
以下是以前官方文档的解释,但是后来删除了

EXEC命令会触发事务中所有命令的执行,因此如果客户端在事务过程中、调用EXEC命令之前与服务器断开连接,那么所有操作都不会被执行;反之,如果EXEC命令被调用,则所有操作都会被执行。在使用 AOF(仅追加文件)时,Redis 会确保通过一次write(2)系统调用将整个事务写入磁盘。然而,如果 Redis 服务器崩溃,或者被系统管理员强制终止,就有可能只注册了部分操作。Redis 在重启时会检测到这种情况,并以错误状态退出。使用redis-check-aof工具可以修复 AOF 文件,移除不完整的事务,从而让服务器能够重新启动。
以上exec命令就是开始执行事物
redis事物的实现
基本操作
| 操作目标 | 对应命令 | 作用说明 |
|---|---|---|
| 开启事务 | MULTI | 标记当前客户端进入「事务状态」,后续命令入队而非立即执行(仅对当前客户端生效) |
| 执行并关闭事务 | EXEC | 执行队列中的所有命令,执行完毕后自动清空队列、退出事务状态 |
| 放弃并关闭事务 | DISCARD | 清空队列、直接退出事务状态,不执行任何命令 |
| 被动关闭 | 客户端断开连接 | 客户端连接关闭(如超时、网络中断),Redis 会自动清理该客户端的事务队列,重置状态 |


一个客户端在开启事物,一个在查询完全查不到,因为事物中的命令还未执行,只是插入队列


执行完之后才可以查到
源码实现+理解
// 客户端结构体(server.h)
typedef struct client {
// 事务相关核心字段
multiState mstate; // 事务状态(包含命令队列)
int flags; // 客户端标记,如 CLIENT_MULTI(是否进入事务)
// 其他字段:套接字、当前数据库、命令缓冲区等
} client;
// 事务状态结构体(server.h)—— 存储事务队列
typedef struct multiState {
multiCmd *commands; // 指向事务命令数组的指针
int count; // 队列中命令的数量
int minreplicas; // 事务持久化相关(min-replicas-to-write)
time_t minreplicas_timeout; // 超时时间
} multiState;
// 单个事务命令节点(server.h)—— 队列中的每个元素
typedef struct multiCmd {
robj **argv; // 命令参数数组(如 SET k1 v1 的 argv[0]="SET", argv[1]="k1", argv[2]="v1")
int argc; // 参数个数
struct redisCommand *cmd; // 指向命令对应的 redisCommand 结构体(如 setCommand 函数)
} multiCmd;
每个客户端都有一个multiState结构体,相当于每个客户端都有一个队列
客户端采用multi命令进入队列,相当于进入事物,接下来的命令就是打包,但未执行
// t_multi.c - MULTI 命令处理函数
void multiCommand(client *c) {
// 1. 检查是否已在事务中,避免重复执行 MULTI
if (c->flags & CLIENT_MULTI) {
addReplyError(c,"MULTI calls can not be nested");
return;
}
// 2. 标记客户端进入事务状态
c->flags |= CLIENT_MULTI;
// 3. 返回成功响应
addReply(c,shared.ok);
}
CLIENT_MULTI是客户端flags中的一个位标记,只要置位,后续命令就会进入「入队逻辑」而非「立即执行」;- 这一步不创建队列,只是标记状态,队列会在第一条命令入队时动态初始化。
// server.c - 处理客户端命令的核心函数
int processCommand(client *c) {
// ... 省略命令解析、权限检查等逻辑 ...
// 判断是否处于事务状态,且当前命令不是事务控制命令
if (c->flags & CLIENT_MULTI &&
c->cmd->proc != execCommand &&
c->cmd->proc != discardCommand &&
c->cmd->proc != multiCommand &&
c->cmd->proc != watchCommand)
{
// 执行命令入队逻辑
queueMultiCommand(c);
// 入队成功,返回 "QUEUED" 响应
addReply(c,shared.queued);
return C_OK;
}
// 非事务状态:直接执行命令
call(c,CMD_CALL_FULL);
return C_OK;
}
// t_multi.c - 命令入队的核心函数
void queueMultiCommand(client *c) {
multiCmd *mc;
int j;
// 1. 扩展事务命令数组(动态扩容,初始大小为4,满了则翻倍)
c->mstate.commands = zrealloc(c->mstate.commands,
sizeof(multiCmd)*(c->mstate.count+1));
// 2. 指向新的命令节点
mc = c->mstate.commands + c->mstate.count;
// 3. 复制命令参数(Redis 会复制参数对象,避免外部修改)
mc->argc = c->argc;
mc->argv = zmalloc(sizeof(robj*)*c->argc);
for (j = 0; j < c->argc; j++) {
mc->argv[j] = incrRefCount(c->argv[j]); // 增加引用计数
}
// 4. 关联命令对应的执行函数(如 SET 对应 setCommand)
mc->cmd = c->cmd;
// 5. 队列计数+1
c->mstate.count++;
}
// t_multi.c - EXEC 命令处理函数
void execCommand(client *c) {
int j;
multiCmd *mc;
int old_flags = c->flags;
// 1. 检查是否处于事务状态
if (!(c->flags & CLIENT_MULTI)) {
addReplyError(c,"EXEC without MULTI");
return;
}
// 2. 检查 WATCH 的 key 是否被修改(乐观锁逻辑)
if (c->flags & CLIENT_DIRTY_CAS) {
// 有 key 被修改,事务取消,返回空
addReply(c,shared.nullmultibulk);
discardTransaction(c); // 清空队列、重置状态
return;
}
// 3. 遍历事务队列,按顺序执行所有命令
addReplyArrayLen(c,c->mstate.count); // 先返回结果数组的长度
for (j = 0; j < c->mstate.count; j++) {
mc = c->mstate.commands+j;
// 3.1 重置客户端的 argv/argc,指向当前要执行的命令
c->argc = mc->argc;
c->argv = mc->argv;
c->cmd = mc->cmd;
// 3.2 执行命令(核心:call 函数调用命令的执行逻辑)
call(c,CMD_CALL_FULL);
// 3.3 执行后恢复参数(避免内存泄漏)
c->argv = c->orig_argv;
c->argc = c->orig_argc;
}
// 4. 执行完毕,清理事务状态
discardTransaction(c);
// 5. 恢复客户端标记
c->flags = old_flags & ~(CLIENT_MULTI|CLIENT_DIRTY_CAS);
}
// t_multi.c - 清理事务状态(清空队列、释放内存)
void discardTransaction(client *c) {
int j;
multiCmd *mc;
// 1. 释放所有入队命令的参数内存
for (j = 0; j < c->mstate.count; j++) {
mc = c->mstate.commands+j;
for (int k = 0; k < mc->argc; k++) {
decrRefCount(mc->argv[k]); // 减少引用计数,释放对象
}
zfree(mc->argv);
}
// 2. 释放命令数组内存
zfree(c->mstate.commands);
// 3. 重置事务状态
c->mstate.commands = NULL;
c->mstate.count = 0;
}
- 执行前先检查
WATCH的 key 是否被修改(CLIENT_DIRTY_CAS标记),如果被修改则直接取消事务,返回nil;- 遍历队列时,会将客户端的
argv/argc/cmd临时替换为队列中当前命令的信息,然后调用call函数执行命令(和非事务命令的执行逻辑完全一致);- 执行过程中不会处理其他客户端的命令(Redis 单线程模型保证),因此实现了串行执行;
- 执行完毕后调用
discardTransaction清空队列、释放内存,重置事务状态;- 执行结果会按入队顺序打包成数组返回,这就是
EXEC后看到的多个结果的原因。
MULTI SET k1 v1 # 入队成功
SETT k2 v2 # 入队失败,返回错误
EXEC # Redis 检测到入队错误,直接返回错误,所有命令都不执行
MULTI SET k1 v1 # 入队成功(String 类型)
HGET k1 name #入队成功(语法合法)
SET k2 v2 # 入队成功
EXEC # 执行:
# 1. SET k1 v1 成功
# 2. HGET k1 name 失败(类型不匹配)
# 3. SET k2 v2 成功
redis的超卖场景
对于平时的多线程编程,我们采用加锁去访问数据库
如果这里不加锁,读取个数的时候,先返回>0,然后再if语句里面,另一个又去读取就会导致商品个数等于负数,不符合一致性
那么是否可以单单利用事物的特点去支持呢???
仅仅需要把操作放在事物之间,但是if语句这里redis是没有这种命令操作的,那么需要通过lua脚本(前面提到过lua脚本是redis的可扩展性,redis支持lua编写原子性操作)
-- 扣减库存的Lua脚本(核心逻辑) -- KEYS[1]:库存key(如 stock:iphone) -- 返回值:1=扣减成功,0=库存不足 local stock_key = KEYS[1] -- 1. 获取当前库存(转为数字,避免nil) local current_stock = tonumber(redis.call('GET', stock_key)) -- 2. 库存不存在/库存不足,返回0 if not current_stock or current_stock <= 0 then return 0 end -- 3. 库存充足,扣减1,返回1 redis.call('DECR', stock_key) return 1// 定义Lua脚本(解决超卖的核心逻辑) const std::string SECKILL_LUA = R"( local stock_key = KEYS[1] local current_stock = tonumber(redis.call('GET', stock_key)) -- 库存不存在或不足,返回0 if not current_stock or current_stock <= 0 then return 0 end -- 库存充足,扣减并返回1 redis.call('DECR', stock_key) return 1 )"; // Redis执行Lua脚本的函数 int executeLuaScript(redisContext* ctx, const std::string& luaScript, const std::string& key) { // 方式1:直接用EVAL执行脚本字符串(简单易上手) // 参数说明: // EVAL + 脚本内容 + KEYS数组长度 + 具体key redisReply* reply = (redisReply*)redisCommand( ctx, "EVAL %s %d %s", luaScript.c_str(), 1, // KEYS数组的长度(这里只有1个key) key.c_str() ); if (ctx->err) { std::cerr << "Redis命令执行失败:" << ctx->errstr << std::endl; return -1; } int result = -1; // 判断返回值类型(脚本返回1/0,是整数类型) if (reply->type == REDIS_REPLY_INTEGER) { result = reply->integer; } else { std::cerr << "脚本返回值类型错误" << std::endl; } // 释放reply资源(必须!否则内存泄漏) freeReplyObject(reply); return result; }让用户把多个 Redis 命令封装成一个 “原子脚本”,执行期间不被其他命令打断,同时支持自定义逻辑(如条件判断、循环)。这样通过lua脚本解决了redis天然没有判断命令的缺陷,通过这样的操作,把命令打包成事物,可以判断,这样就能解决超卖问题
watch监控事物
之前提到过源码解析的时候,watch,如果发现数据有被修改则放弃执行事物
在事物还未被exec的时候,客户端2去set key 333,导致key被修改,至此版本号变更
当事物被exec的时候,先判断watch的key是否被修改了,当前被修改了,所以取消事物
watch的原理
这里相当于乐观锁,对应的就是悲观锁,乐观锁和悲观锁值得是某一类锁的一种特性
锁类型 核心思想 通俗比喻 悲观锁 假设一定会发生并发冲突,提前加锁,锁住资源不让别人用,直到自己操作完释放 去洗手间先反锁门,怕有人进来 乐观锁 假设不会发生并发冲突,不加锁,操作时只检查资源是否被修改过,冲突则重试 去洗手间不锁门,进门先看有没有人,有人就等 对于悲观锁,成本肯定高,乐观锁成本低,redis的watch就是基于版本号的形式实现的乐观锁
- 悲观锁:“先锁后做”,假设冲突一定会发生,主动加锁阻塞,适合冲突多、要求高一致性的场景(如 MySQL);
- 乐观锁:“先做后验”,假设冲突不会发生,不加锁只校验,适合冲突少、要求高性能的场景(如 Redis);
- 实际开发中,Redis 优先用 Lua 脚本(原子性乐观锁),MySQL 优先用悲观锁(行锁),核心是 “按场景选锁”。
对于redis,高并发场景下lua脚本比watch更好,因为watch你需要不断的重复
- 重试风暴:高并发下(如 10w+/s 请求),几乎每个请求的
EXEC都会返回nil(因为库存 key 被频繁修改),导致大量请求陷入 “WATCH→EXEC→重试→再 WATCH” 的循环;- 资源浪费:每次重试都会占用 Redis 的连接、CPU 资源,最终导致 Redis 负载飙升,响应延迟增加;
维度 WATCH + 事务(乐观锁) Lua 脚本 原子性 依赖乐观锁,可能被打断(需重试) 天然原子性,执行期间不可打断 网络开销 多次往返(WATCH→MULTI→EXEC→重试) 1 次往返(发送脚本 / 执行) 重试成本 高并发下重试率极高,资源浪费 无重试,一次执行完成 性能上限 低(1w/s 左右开始性能下降) 高(10w+/s 仍稳定) 代码复杂度 需处理重试逻辑(如循环、休眠) 逻辑封装在脚本,客户端只需处理结果 集群兼容性 WATCH 不支持 Redis 集群 支持集群(规范使用 KEYS 数组即可) 错误率 高(重试失败、连接超时) 低(仅脚本语法 / 参数错误)
延申问题:CAC的ABA问题
总结

对于一致性,数据库redis本身是无法保证的,需要程序员在业务层面手动去维护,redis就像一个无情的机器,执行合法的命令



对于悲观锁,成本肯定高,乐观锁成本低,redis的watch就是基于版本号的形式实现的乐观锁
359



被折叠的 条评论
为什么被折叠?



