参考:
Scripting with Lua——译文
Redis进阶 - 事务:Redis事务详解

Redis事务与传统事务的区别

说到传统的事务,也就是 ACID,先回顾一下它的特性:

  • 原子性Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
  • 一致性Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;
  • 隔离性Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
  • 持久性Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

而 Redis 事务通常不能看作传统的事务:
Redis 事务的本质是一组命令的集合。它支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

以下是它们特性的对比:

特性 MySQL事务 Redis事务
原子性 强原子性:要么全部成功,要么全部回滚 弱原子性:要么全部执行,要么全部不执行,但执行中若命令错误,事务中后续未执行命令仍会继续执行
一致性 强一致性:保证事务前后数据符合业务约束 无一致性保障:Redis无数据约束,操作失败可能导致数据逻辑错误
隔离性 支持多种隔离级别(读未提交、读已提交、可重复读、串行化) 天然”串行隔离”:Redis是单线程模型,所有命令按顺序执行,无需额外隔离机制
持久性 强持久性:事务提交后数据永久写入磁盘(依赖redo log) 无直接持久性:依赖RDB/AOF持久化,事务本身不保证持久化

可以看出,Redis 的事务对比数据库的 ACID,不同的地方主要是在于 A(Atomic),以及由 A 导致的 C(Consistency)。因为事务中如果有一个命令失败了,Redis 的事务是不会帮你自动回滚的,并且事务中剩下的命令会继续执行下去直到结束事务。这样的操作可能会导致一致性的问题。

Redis事务的上位替代选择

所以在我看来,Redis 的事务的作用,更像是要求先处理一个客户端请求的命令队列(Redis 是单线程工作机制),直到这个命令队列被执行完(保证过程中不会被其他客户端的请求命令打断插入),才去处理其他客户端请求的命令。

redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

这样的话,使用 Lua 脚本可以完美替代 Redis 事务。并且有更多的操作空间。

官方介绍:
Redis 允许用户在服务器上上传和执行 Lua 脚本。脚本可以使用程序控制结构,并在执行时使用大部分命令来访问数据库。由于脚本在服务器中执行,因此从脚本读写数据非常高效。

Redis 保证脚本的原子执行。在执行脚本期间,所有服务器活动在其整个运行时都会被阻塞。这些语义意味着脚本的所有效果要么尚未发生,要么已经发生。
脚本提供了在许多情况下非常有价值的几个特性,包括

  • 通过在数据所在位置执行逻辑来提供局部性。数据局部性降低了整体延迟并节省了网络资源
  • 确保脚本原子执行的阻塞语义。
  • 能够组合 Redis 缺失或过于小众而无法成为其一部分的简单功能。

Lua 允许你在 Redis 内部运行部分应用程序逻辑。此类脚本可以跨多个键执行条件更新,可能原子地组合多种不同的数据类型。
脚本在 Redis 中由嵌入式执行引擎执行。目前,Redis 支持单个脚本引擎,即 Lua 5.1 解释器。完整的文档请参考Redis Lua API 参考页面。

虽然服务器执行它们,但 Eval 脚本被视为客户端应用程序的一部分,因此它们没有名称、版本或持久化。因此,如果脚本丢失(例如服务器重启、故障转移到副本后),应用程序可能需要在任何时候重新加载所有脚本。自版本 7.0 起,Redis 函数提供了一种替代的可编程性方法,允许使用额外的编程逻辑扩展服务器本身。

总的来说,把 Redis 事务的命令,全写到一个 Lua 脚本就完事了,这种操作更加节省网络IO,更加灵活。

如何让Redis事务有回滚的能力

如上所说,Redis事务没有类似 InnoDB 的 undo logMVCC 的机制,所以肯定是无法完成自动版本回滚的。

但是如果我一定要 Redis 有数据回滚这个功能,可以实现吗?
答案也许是可以的。虽然 Redis 原生事务无法在执行错误后自动阻止后续命令执行,但可以通过 Lua脚本实现这个功能(这就是上位替代的原因之一)

在 Redis 中,如果某个命令执行失败,Redis 会记录错误并返回的,我们完全可以利用这个返回结果来判断。
我们可以 LUA 脚本中,在执行写操作前,记录原值,添加执行后返回的结果判断,如果第一步失败,直接返回错误,就不执行后续命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 示例:原子操作,失败则全部回滚
local key = KEYS[1]
local value = ARGV[1]

-- 检查原始值
local original = redis.call("GET", key)

-- 尝试更新
local set_result = redis.call("SET", key, value)
if set_result ~= "OK" then
-- 如果SET失败,回滚并返回错误
redis.call("SET", key, original)
return "ROLLBACK"
end

-- 如果需要执行更多操作,可以在这里继续
-- 例如:更新另一个键
local update_result = redis.call("INCR", "counter")
if update_result == nil then
-- 如果INCR失败,回滚第一个操作
redis.call("SET", key, original)
return "ROLLBACK"
end

return "SUCCESS"

当然这个方案并不是毫无缺点。

  1. 当这个业务比较复杂,涉及的数据比较多、处于并发量高的时候,性能是大大降低的,原因是 Rsdis 的单线程工作机制
  2. Redis本身没有 InnoDB 的 redo log 机制,当命令执行出错时,这个时候如果者Redis 进程终止了或宕机了,是不能完成上一次的数据回滚的,虽然有AOF日志恢复当时的数据,但你可能不知道当时的命令有没有执行错误。

所以如果没有非常强的数据一致性要求,这个自己实现的 Redis 回滚机制其实并不是很推荐()