Redis 事务机制:Pipeline、ACID、Lua脚本
文章目录
- 一、Redis Pipeline
- 1.1 概念与目的
- 1.2 工作原理
- 1.3 实现机制与使用
- 二、Redis 事务
- 2.1 什么是事务?
- 2.2 Redis 事务的基本命令
- 2.3 乐观锁机制
- 2.4 事务的局限性
- 2.5 应用场景
- 三、Lua 脚本
- 3.1 概念与原理
- 3.2 脚本的加载与执行方式
- 3.3 注意事项
- 四、ACID特性
- 4.1 A 原子性 (Atomicity)
- 4.2 一致性 (Consistency)
- 4.3 隔离性 (Isolation)
- 4.4 持久性 (Durability)
- 五、Redis 发布订阅
- 5.1 定义与目的
- 5.2 应用要点(连接处理)
- 5.3 主要缺点(消息可靠性问题)
- 5.4 核心命令
一、Redis Pipeline
1.1 概念与目的
Redis Pipeline 是一种客户端技术,其核心目的在于节约网络传输时间,提升批量操作的效率。
在默认情况下,客户端每发送一个Redis命令,都需要等待服务器回应后,才能发送下一个命令。这个过程会消耗大量的网络往返时间。Pipeline 的机制是,允许客户端一次性将多个命令请求包打包发送给Redis服务器,然后服务器会按顺序处理这些命令,并一次性将所有的回应包返回给客户端。
这种机制在本质上与 HTTP 1.1 中的“管线化请求”非常类似,都用于提高网络利用率。
1.2 工作原理
在 Pipeline 模式下,客户端会将多条命令打包成一个请求,一次性发送给 Redis:
SET key1 value1
SET key2 value2
GET key1
GET key2
Redis 会顺序执行这些命令,并将结果依次打包返回。
注意:Pipeline 不具备事务性,即使多条命令一起发送,它们之间仍可能被中断,不存在“要么全部成功,要么全部失败”的保证。
1.3 实现机制与使用
在底层实现上,Redis 官方提供了一个名为 hiredis 的客户端驱动库,用于实现与 Redis 的交互。
我们在自己的 C/C++ 服务中引入 hiredis 库,就能使用 Pipeline 机制来批量发送命令,显著减少网络延迟。
Pipeline 的典型使用场景:
- 需要一次执行大量读写操作;
- 对实时性要求高,但每条命令彼此独立;
- 不需要事务一致性。
Pipeline 解决的是性能问题,而不是数据一致性问题。
二、Redis 事务
2.1 什么是事务?
Redis 事务是一组用户定义的数据库操作,这些操作被视为一个完整的逻辑工作单元,要求“要么全部执行,要么全部不执行”。
在单机数据库中,事务的主要目的是保证并发情况下数据的逻辑一致性。
例如:
1. GET gulu --> 100
2. SET gulu 200
如果这两条命令分开执行,在第一次 GET
后如果有其他客户端修改了 gulu
,就可能导致逻辑错误。因此我们希望这两步操作能被当作一个整体执行,这就是事务要解决的问题。
2.2 Redis 事务的基本命令
Redis事务通过四个核心命令实现:
MULTI
:用于开启一个事务。执行后,客户端发送的命令不会立即执行,而是被放入一个队列中。EXEC
:用于提交事务。执行后,服务器会按顺序执行事务队列中的所有命令。DISCARD
:用于取消事务(相当于回滚)。它会清空事务队列,并退出事务状态。WATCH
:是实现 Redis 乐观锁的关键。它可以在MULTI
之前监视一个或多个 key,如果在事务执行(EXEC
)之前,有任何被监视的键被其他客户端修改,那么整个事务将会被取消,EXEC
返回nil
。
执行过程:
127.0.0.1:6379> WATCH gulu
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET gulu 2000
QUEUED
127.0.0.1:6379(TX)> EXEC
(nil) # 因为 gulu 被其他连接修改,事务失败
2.3 乐观锁机制
Redis事务基于乐观锁。它乐观地假设客户端在执行事务过程中,不会有其他客户端同时操作对应的 key,事务不会被干扰,因此不加锁。只在最终事务提交时 Redis 会检查监控的 key 是否被修改。
- 如果被改动,则事务自动取消(返回 nil)。
- 如果没有修改,事务提交成功。
MySQL 采用的就是悲观的,在执行事务过程中加锁,不允许其他连接去操作。
这种机制在多并发环境下能有效避免数据竞争,但如果竞争频繁,事务会多次重试,增加业务复杂度。
2.4 事务的局限性
Redis事务的一个显著特点是不支持回滚。如果事务中的某个命令在执行时出错(例如,对一个字符串键执行列表的LPUSH
操作),这个命令会失败,但事务不会中止,队列中的其他命令依然会继续执行。这意味着 Redis 事务无法保证绝对的原子性。
2.5 应用场景
事务可用于保证一系列逻辑操作的原子性,如:
1. 模拟 zpop 操作(安全删除最小元素)
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
2. 加倍操作(安全更新数值)
WATCH score:10001
val = GET score:10001
MULTI
SET score:10001 val*2
EXEC
不过在实际开发中,这种逻辑通常由 Lua 脚本 实现更高效的原子性执行。
三、Lua 脚本
为了克服事务的局限性,Redis支持使用Lua脚本来实现更复杂的原子操作。
3.1 概念与原理
Redis 服务器内置了一个 Lua 虚拟机。当执行一个 Lua 脚本时,整个脚本会被当作一个单独的命令来处理。在脚本执行期间,Redis 的单线程服务器不会执行任何其他命令或脚本,从而保证了原子性和隔离性。
3.2 脚本的加载与执行方式
Redis 提供两种执行 Lua 的方式:
EVAL
:直接执行一段Lua脚本代码。适用于测试。EVALSHA
:通过脚本内容的SHA1哈希值来执行脚本。
示例脚本(将指定 key 的值加倍):
local key = KEYS[1];
local val = redis.call("get", key);
redis.call("set", key, 2*val);
return 2*val;
还可以优化脚本(防止 key 不存在):
local key = KEYS[1];
local val = redis.call("get", key);
if not val then val = 1000 end;
redis.call("set", key, 2*val);
return 2*val;
在 Redis 中执行:
127.0.0.1:6379> EVAL "127.0.0.1:6379> EVAL "local key = KEYS[1];local val = redis.call("get",key);if not val then val = 1000 end;redis.call("set", key, 2*val);return 2*val;" 1 score" 1 score
(integer) 200
实际业务中的 lua 代码要复杂的多,我们会用一串哈希值来代表 lua 脚本的代码
脚本管理:
SCRIPT LOAD
:将脚本加载到服务器缓存,返回其SHA1值。SCRIPT EXISTS
:检查脚本是否在缓存中。SCRIPT FLUSH
:清空所有脚本缓存。SCRIPT KILL
:用于终止正在执行的长耗时脚本(如陷入死循环)。
在生产环境中,为了节省网络带宽,通常先在服务端缓存脚本(SCRIPT LOAD
),然后使用EVALSHA
通过哈希值调用。
在生产环境中,一般先加载脚本:
script load 'local key = KEYS[1];local val = redis.call("get",key);if not val then val = 1000 end;redis.call("set", key, 2*val);return 2*val;'
然后会返回对应的哈希值:
"fb01696519c3663aa60e7b705e5584e2e8cf5da3"
返回的哈希值可用于之后调用:
evalsha fb01696519c3663aa60e7b705e5584e2e8cf5da3 1 gulu
其中 1
表示键的数量,gulu
为目标键,最终会返回翻倍后的结果(如 (integer) 2000
)。
3.3 注意事项
- 项目启动时预加载:在应用启动建立 Redis 连接后,通过
SCRIPT LOAD
将所有需要用到的 Lua脚本预先加载到Redis服务器,并将返回的SHA1值存储在本地(如HashMap中),后续使用EVALSHA
调用。 - 热更新:如果需要更新脚本,先通过
redis-cli script flush
清空 Redis 中的脚本缓存,然后重新加载新脚本,并通过发布订阅机制通知所有客户端应用重新加载,确保所有节点使用的脚本版本一致。 - 错误处理:Lua脚本虽然原子性执行,但不会自动回滚。如果脚本中间出错,已经执行成功的命令就已经修改了数据库状态,需要开发者在脚本内部自行实现逻辑上的“回滚”或确保逻辑正确。
- 避免阻塞:若 Lua 脚本出现阻塞(如死循环),可通过
script kill
终止脚本执行;同时需避免在脚本中编写过于复杂的逻辑,减少阻塞风险。
# 从文件中读取 lua脚本内容
cat test1.lua | redis-cli script load --pipe
# 加载 lua脚本字符串 生成 sha1
> script load 'local val = KEYS[1]; return val'
"b8059ba43af6ffe8bed3db65bac35d452f8115d8"
# 检查脚本缓存中,是否有该 sha1 散列值的lua脚本
> script exists "b8059ba43af6ffe8bed3db65bac35d452f8115d8"
1) (integer) 1
# 清除所有脚本缓存
> script flush
OK
# 如果当前脚本运行时间过长(死循环),可以通过 script kill 杀死当前运行的脚本
> script kill
(error) NOTBUSY No scripts in execution right now.
四、ACID特性
4.1 A 原子性 (Atomicity)
事务是一个不可分割的工作单位,事务中的操作要么全部成功,要么全部失败(回滚);Redis 不支持回滚;即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕为止。
- Redis事务:不满足。因为即使队列中某个命令执行失败,其他命令仍然会继续执行,没有回滚机制。
- Lua脚本:满足。整个脚本作为一个命令执行,要么全部成功,要么全部失败(脚本出错会停止,但已执行的命令无法撤销)。
4.2 一致性 (Consistency)
事务的前后,所有的数据都保持一个一致的状态,不能违反数据的一致性检测;这里的一致性是指预期的一致性而不是异常后的一致性;
- 逻辑上一致性
- 数据库一致性(完整约束)
所以 Redis 也不满足。Redis 能确保事务执行前后的数据的完整约束(例如,拒绝用字符串命令操作列表键),但是并不满足业务逻辑的一致性。比如转账功能,一个扣钱一个加钱,可能出现扣钱执行错误,加钱执行正确,那么最终还是会加钱成功,系统凭空多了钱。
127.0.0.1:6379> type gulu
string
127.0.0.1:6379> lpush gulu 1 2 3 4
(error) WRONGTYPE Operation against a key holding the wrong kind of value
Lua脚本两者都不保证。例如,在Lua脚本中,如果前半部分逻辑成功修改了数据,后半部分逻辑出错,数据就处于一个中间状态,破坏了业务逻辑的一致性。
4.3 隔离性 (Isolation)
- Redis 是单线程的,因此所有命令都串行执行,天然具备隔离性;
- Lua 脚本也在同一线程中执行,因此也具备隔离性。
4.4 持久性 (Durability)
Redis事务与Lua脚本:通常不满足。只有在使用AOF持久化且配置appendfsync = always
时,每个写命令都会同步到磁盘,才具备持久性。但这个配置会严重性能,生产环境中极少使用,通常使用everysec
或RDB,因此在服务器故障时可能丢失数据。
特性 | Redis 事务 | Lua 脚本 |
---|---|---|
原子性 | 不完全支持(无回滚) | 完全原子 |
一致性 | 仅类型一致性 | 逻辑一致性需自行保证 |
隔离性 | 单线程保证 | 单线程保证 |
持久性 | 取决于 AOF 策略 | 取决于 AOF 策略 |
五、Redis 发布订阅
5.1 定义与目的
Redis 发布订阅(Pub/Sub)是为支持消息多播机制而设计的模块,本质是一种分布式消息队列方案。它的核心作用是让消息生产者(发布者)向指定“频道”发送消息,多个消息消费者(订阅者)可通过订阅该频道接收消息,实现“一对多”的消息传递。
需要注意的是,Redis 发布订阅的消息不保证可达性,若需确保消息一定被接收,可选择 Redis 的 Stream 模块,后者通过持久化机制解决了消息丢失问题。
5.2 应用要点(连接处理)
在实际项目中使用发布订阅功能时,必须为其单独开启一条连接,不能复用执行普通命令的连接。原因在于普通命令连接严格遵循“请求-回应”模式,即客户端发送请求后才能接收 Redis 的回应;而发布订阅需要接收 Redis 主动推送的消息,若复用普通连接,会导致连接模式冲突,无法正常接收推送内容。因此,通常的做法是:一条连接处理 get
/set
等普通命令,另一条独立连接专门处理发布订阅的消息收发。
5.3 主要缺点(消息可靠性问题)
Redis 发布订阅的核心短板在于消息可靠性较低,无法保障消息不丢失,具体体现在三个场景:
- 无消费者时消息丢弃:若生产者向某个频道发送消息,但此时没有任何消费者订阅该频道,Redis 会直接丢弃这条消息,不会暂存。
- 消费者断开期间消息丢失:若某个频道原本有 2 个消费者,其中一个消费者意外断开连接,另一个消费者仍能正常接收消息;但当断开的消费者重新连接后,它无法获取断开期间该频道收到的消息,这部分消息已被永久丢弃。
- Redis 重启后消息丢失:发布订阅的消息不支持持久化,若 Redis 服务器停机重启,所有未被消费的消息(及历史消息)都会被清空,重启后无法恢复。
5.4 核心命令
Redis 提供了一套专门用于发布订阅的命令,按功能可分为订阅、取消订阅、发布、接收四类,具体命令及用途如下:
命令类型 | 命令格式 | 功能说明 |
---|---|---|
订阅类 | subscribe 频道1 频道2 ... | 订阅一个或多个指定的“具体频道”,接收该频道的消息 |
订阅类 | psubscribe 模式频道 | 订阅符合指定模式的“模式频道”(如 news.* 匹配 news.it /news.showbiz 等) |
取消订阅类 | unsubscribe 频道1 频道2 ... | 取消对一个或多个具体频道的订阅 |
取消订阅类 | punsubscribe 模式频道 | 取消对指定模式频道的订阅 |
发布类 | publish 频道 消息内容 | 向指定的具体频道或模式频道发送消息 |
接收类 | message 具体频道 消息内容 | 客户端被动接收具体频道推送的消息(无需主动调用) |
接收类 | pmessage 模式频道 具体频道 消息内容 | 客户端被动接收模式频道推送的消息(无需主动调用,会显示匹配的模式频道和具体频道) |
示例:
1)订阅多个具体频道和一个模式频道:
# 订阅 news.it(科技新闻)、news.showbiz(娱乐新闻)、news.car(汽车新闻)三个具体频道
subscribe news.it news.showbiz news.car
# 订阅所有以 "news." 开头的模式频道(如上述三个具体频道均会被匹配)
psubscribe news.*
2)向指定频道发布消息:
# 向 news.showbiz 频道发布消息 "football team win"
publish news.showbiz 'football team win'
3)客户端接收消息:
执行上述发布命令后,已订阅 news.showbiz
频道或 news.*
模式频道的客户端,会被动收到如下消息(具体频道接收):
message news.showbiz football team win
同时,订阅 news.*
模式频道的客户端还会收到如下消息(模式频道接收):
pmessage news.* news.showbiz football team win