Redis 事务
Redis 事务
Redis 仅支持“非严格”的事务。所谓“非严格”是指:Redis 事务保证“全部执行命令”;但是,Redis 事务“不支持回滚”。
关键词:
事务
、ACID
、MULTI
、EXEC
、DISCARD
、WATCH
Redis 事务简介
什么是 ACID
ACID 是数据库事务正确执行的四个基本要素。
- 原子性(Atomicity)
- 事务被视为不可分割的最小单元,事务中的所有操作要么全部提交成功,要么全部失败回滚。
- 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
- 一致性(Consistency)
- 数据库在事务执行前后都保持一致性状态。
- 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
- 隔离性(Isolation)
- 一个事务所做的修改在最终提交以前,对其它事务是不可见的。
- 持久性(Durability)
- 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
- 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。
一个支持事务(Transaction)中的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction processing)当中无法保证数据的正确性。
- 只有满足一致性,事务的执行结果才是正确的。
- 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
- 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
- 事务满足持久化是为了能应对系统崩溃的情况。
Redis 事务的特性
Redis 的事务总是支持 ACID 中的原子性、一致性和隔离性, 当服务器运行在 AOF 持久化模式下, 并且 appendfsync
选项的值为 always
时, 事务也具有持久性。
但需要注意的是:Redis 仅支持“非严格”的事务。这里的“非严格”,其实指的是 Redis 事务只能部分保证 ACID 中的原子性。
- Redis 事务保证全部执行命令 - Redis 事务中的多个命令会被打包到事务队列中,然后按先进先出(FIFO)的顺序执行。事务在执行过程中不会被中断,当事务队列中的所有命令都被执行完毕之后,事务才会结束。
- Redis 事务不支持回滚 - 如果命令执行失败不会回滚,而是会继续执行下去。
Redis 官方的事务特性文档给出的不支持回滚的理由是:
- Redis 命令只会因为错误的语法而失败,或是命令用在了错误类型的键上面。
- 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。
Redis 事务应用
MULTI
、EXEC
、DISCARD
和 WATCH
是 Redis 事务相关的命令。
事务可以一次执行多个命令, 并且有以下两个重要的保证:
- 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。
Redis 有天然解决这个并发竞争问题的类 CAS 乐观锁方案:每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。
MULTI
MULTI
命令用于开启一个事务,它总是返回 OK 。
MULTI
执行之后, 客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行, 而是被放到一个队列中, 当 EXEC 命令被调用时, 所有队列中的命令才会被执行。
以下是一个事务例子, 它原子地增加了 foo
和 bar
两个键的值:
> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1
EXEC
EXEC
命令负责触发并执行事务中的所有命令。
- 如果客户端在使用
MULTI
开启了一个事务之后,却因为断线而没有成功执行EXEC
,那么事务中的所有命令都不会被执行。 - 另一方面,如果客户端成功在开启事务之后执行
EXEC
,那么事务中的所有命令都会被执行。
MULTI
和 EXEC
中的操作将会一次性发送给服务器,而不是一条一条发送,这种方式称为流水线,它可以减少客户端与服务器之间的网络通信次数从而提升性能。
DISCARD
当执行 DISCARD
命令时, 事务会被放弃, 事务队列会被清空, 并且客户端会从事务状态中退出。
示例:
> SET foo 1
OK
> MULTI
OK
> INCR foo
QUEUED
> DISCARD
OK
> GET foo
"1"
WATCH
WATCH
命令可以为 Redis 事务提供 check-and-set (CAS)行为。
被 WATCH
的键会被监视,并会发觉这些键是否被改动过了。 如果有至少一个被监视的键在 EXEC
执行之前被修改了, 那么整个事务都会被取消, EXEC
返回 nil-reply
来表示事务已经失败。
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
使用上面的代码, 如果在 WATCH
执行之后, EXEC
执行之前, 有其他客户端修改了 mykey
的值, 那么当前客户端的事务就会失败。 程序需要做的, 就是不断重试这个操作, 直到没有发生碰撞为止。
这种形式的锁被称作乐观锁, 它是一种非常强大的锁机制。 并且因为大多数情况下, 不同的客户端会访问不同的键, 碰撞的情况一般都很少, 所以通常并不需要进行重试。
WATCH
使得 EXEC
命令需要有条件地执行:事务只能在所有被监视键都没有被修改的前提下执行,如果这个前提不能满足的话,事务就不会被执行。
WATCH
命令可以被调用多次。对键的监视从 WATCH
执行之后开始生效,直到调用 EXEC
为止。
用户还可以在单个 WATCH
命令中监视任意多个键,例如:
redis> WATCH key1 key2 key3
OK
取消 WATCH 的场景
当 EXEC
被调用时, 不管事务是否成功执行, 对所有键的监视都会被取消。另外, 当客户端断开连接时, 该客户端对键的监视也会被取消。
使用无参数的 UNWATCH
命令可以手动取消对所有键的监视。 对于一些需要改动多个键的事务, 有时候程序需要同时对多个键进行加锁, 然后检查这些键的当前值是否符合程序的要求。 当值达不到要求时, 就可以使用 UNWATCH
命令来取消目前对键的监视, 中途放弃这个事务, 并等待事务的下次尝试。
使用 WATCH 创建原子操作
WATCH
可以用于创建 Redis 没有内置的原子操作。
举个例子,以下代码实现了原创的 ZPOP
命令,它可以原子地弹出有序集合中分值(score
)最小的元素:
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
Lua 脚本
为什么使用 Lua
前面提到了,Redis 仅支持“非严格”的事务。
Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。
Redis 执行 Lua 是原子操作。因为 Redis 使用串行化的方式来执行 Redis 命令, 所以在任何特定时间里, 最多都只会有一个脚本能够被放进 Lua 环境里面运行, 因此, 整个 Redis 服务器只需要创建一个 Lua 环境即可。由于,Redis 执行 Lua 具有原子性,所以常被用于需要原子性执行多命令的场景。
不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。
如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。
另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。
Redis 脚本命令
命令 | 说明 |
---|---|
EVAL | EVAL 命令为客户端输入的脚本在 Lua 环境中定义一个函数, 并通过调用这个函数来执行脚本。 |
EVALSHA | EVALSHA 命令通过直接调用 Lua 环境中已定义的函数来执行脚本。 |
SCRIPT_FLUSH | SCRIPT_FLUSH 命令会清空服务器 lua_scripts 字典中保存的脚本, 并重置 Lua 环境。 |
SCRIPT_EXISTS | SCRIPT_EXISTS 命令接受一个或多个 SHA1 校验和为参数, 并通过检查 lua_scripts 字典来确认校验和对应的脚本是否存在。 |
SCRIPT_LOAD | SCRIPT_LOAD 命令接受一个 Lua 脚本为参数, 为该脚本在 Lua 环境中创建函数, 并将脚本保存到 lua_scripts 字典中。 |
SCRIPT_KILL | SCRIPT_KILL 命令用于停止正在执行的脚本。 |
Redis 执行 Lua 的工作流程
为了在 Redis 服务器中执行 Lua 脚本, Redis 在服务器内嵌了一个 Lua 环境(environment), 并对这个 Lua 环境进行了一系列修改, 从而确保这个 Lua 环境可以满足 Redis 服务器的需要。
Redis 服务器创建并修改 Lua 环境的整个过程由以下步骤组成:
- 创建一个基础的 Lua 环境, 之后的所有修改都是针对这个环境进行的。
- 载入多个函数库到 Lua 环境里面, 让 Lua 脚本可以使用这些函数库来进行数据操作。
- 创建全局表格
redis
, 这个表格包含了对 Redis 进行操作的函数, 比如用于在 Lua 脚本中执行 Redis 命令的redis.call
函数。 - 使用 Redis 自制的随机函数来替换 Lua 原有的带有副作用的随机函数, 从而避免在脚本中引入副作用。
- 创建排序辅助函数, Lua 环境使用这个辅佐函数来对一部分 Redis 命令的结果进行排序, 从而消除这些命令的不确定性。
- 创建
redis.pcall
函数的错误报告辅助函数, 这个函数可以提供更详细的出错信息。 - 对 Lua 环境里面的全局环境进行保护, 防止用户在执行 Lua 脚本的过程中, 将额外的全局变量添加到了 Lua 环境里面。
- 将完成修改的 Lua 环境保存到服务器状态的
lua
属性里面, 等待执行服务器传来的 Lua 脚本。
Redis 执行 Lua 的要点
- Redis 服务器专门使用一个伪客户端来执行 Lua 脚本中包含的 Redis 命令。
- Redis 使用脚本字典来保存所有被
EVAL
命令执行过, 或者被SCRIPT_LOAD
命令载入过的 Lua 脚本, 这些脚本可以用于实现SCRIPT_EXISTS
命令, 以及实现脚本复制功能。 - 服务器在执行脚本之前, 会为 Lua 环境设置一个超时处理钩子, 当脚本出现超时运行情况时, 客户端可以通过向服务器发送
SCRIPT_KILL
命令来让钩子停止正在执行的脚本, 或者发送SHUTDOWN nosave
命令来让钩子关闭整个服务器。 - 主服务器复制
EVAL
、SCRIPT_FLUSH
、SCRIPT_LOAD
三个命令的方法和复制普通 Redis 命令一样 —— 只要将相同的命令传播给从服务器就可以了。 - 主服务器在复制
EVALSHA
命令时, 必须确保所有从服务器都已经载入了EVALSHA
命令指定的 SHA1 校验和所对应的 Lua 脚本, 如果不能确保这一点的话, 主服务器会将EVALSHA
命令转换成等效的EVAL
命令, 并通过传播EVAL
命令来获得相同的脚本执行效果。
Redis + Lua 实现分布式锁
Redis 应用 Lua 的一个经典使用场景是实现分布式锁。其实现有 3 个重要的考量点:
- 互斥(只能有一个客户端获取锁)
- 不能死锁
- 容错(只要大部分 redis 节点创建了这把锁就可以)
对应的 Redis 指令如下:
setnx
-setnx key val
:当且仅当 key 不存在时,set 一个 key 为 val 的字符串,返回 1;若 key 存在,则什么都不做,返回 0。expire
-expire key timeout
:为 key 设置一个超时时间,单位为 second,超过这个时间锁会自动释放,避免死锁。delete
-delete key
:删除 key
注意:
不要将
setnx
和expire
作为两个命令组合实现加锁,这样就无法保证原子性。如果客户端在setnx
之后崩溃,那么将导致锁无法释放。正确的做法应是在setnx
命令中指定expire
时间。
(1)申请锁
SET resource_name my_random_value NX PX 30000
执行这个命令就 ok。
NX
:表示只有key
不存在的时候才会设置成功。(如果此时 redis 中存在这个 key,那么设置失败,返回nil
)PX 30000
:意思是 30s 后锁自动释放。别人创建的时候如果发现已经有了就不能加锁了。
(2)释放锁
释放锁就是删除 key ,但是一般可以用 lua
脚本删除,判断 value 一样才删除:
-- 删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end