Redis-基础篇-⑤事务
Redis-基础篇-⑤事务
学习核心
Redis 事务概念核心
- Redis 如何实现事务?
- Redis 的事务机制能保证哪些属性?(结合ACID说明)
- Redis 事务总结
MySQL 与 Redis 事务的区别
学习资料
- todo redis 在项目中的使用:https://blog.csdn.net/qq_55675216/article/details/139691269
Redis 事务
事务的基本特性:ACID
A(原子性):事务中的所有操作是一个原子单元,所有的操作要么都完成,要么都不完成
C(一致性):事务中的数据从一个状态到另一个状态的转化,数据库中的数据在事务执行前后是一致的
I(隔离性):隔离性是针对并发事务而言,隔离性要求多个并发事务执行应该不受影响
D(持久性):事务一旦提交,数据库中的数据就会被固化到磁盘中永久保存
基于对事务基本特性的了解,然后分析Redis是如何实现相应的事务机制。
1.Redis如何实现事务?
事务的执行过程包含三个步骤,Redis 提供了 MULTI、EXEC 两个命令来完成这三个步骤
- 【步骤1】客户端要使用
MULTI
命令显式地表示一个事务的开启 - 【步骤2】客户端把事务中本身要执行的具体操作(例如增删改等Redis提供的数据读写命令)发送给服务器端。这些命令虽然被客户端发送到了服务器端,但 Redis 实例只是把这些命令暂存到一个命令队列中,并不会立即执行
- 【步骤3】客户端向服务器端发送提交事务的命令(
EXEC
命令),让数据库实际执行【步骤2】中发送的具体操作。当服务器端收到 EXEC 命令后,才会实际执行命令队列中的所有命令
案例:使用 MULTI 和 EXEC 执行一个事务的过程
# 初始化数据
set a:stock 5
set b:stock 10
# 开启事务
127.0.0.1:6379> MULTI
OK
# 将a:stock减1,
127.0.0.1:6379> DECR a:stock
QUEUED
# 将b:stock减1
127.0.0.1:6379> DECR b:stock
QUEUED
# 实际执行事务
127.0.0.1:6379> EXEC
1) (integer) 4
2) (integer) 9
假设 a:stock、b:stock 两个键的初始值是 5 和 10。在 MULTI 命令后执行的两个 DECR 命令,是把 a:stock、b:stock 两个键的值分别减 1,它们执行后的返回结果都是 QUEUED,这就表示,这些操作都被暂存到了命令队列,还没有实际执行。等到执行了 EXEC 命令后,可以看到返回了 4、9,这就表明,两个 DECR 命令已经成功地执行了。
通过使用 MULTI 和 EXEC 命令,可以实现多个操作的共同执行,但是这符合事务要求的 ACID 属性吗?基于此,可以进一步分析Redis事务机制可以保证哪些属性?
2.Redis的事务机制能保证哪些属性?
原子性(部分保证)
如果事务正常执行,没有发生任何错误,MULTI 和 EXEC 配合使用,就可以保证多个操作都完成。但是,如果事务执行发生错误了,原子性还能保证吗?可以通过三种场景进行分析
(1)Redis 事务原子性场景分析
场景1:在执行 EXEC 命令前,客户端发送的操作命令本身就有错误(比如语法错误,使用了不存在的命令),在命令入队时就被 Redis 实例判断出来了
在命令入队的时候,Redis报错记录下这个错误,此时还能继续提交命令操作。等到执行了 EXEC 命令之后,Redis 就会拒绝执行所有提交的命令操作,返回事务失败的结果,事务中的所有命令都不会再被执行了,保证了原子性。
# 初始化数据
set a:stock 5
set b:stock 10
# 事务示例(依次执行)
// 1.开启事务
MULTI
// 2.发送事务中的第一个操作,但是Redis不支持该命令,返回报错信息((error) ERR unknown command `PUT`, with args beginning with: `a:stock`, `5`, )
PUT a:stock 5
// 3.发送事务中的第二个操作,这个操作是正确的命令,Redis把该命令入队(返回:QUEUED)
DECR b:stock
// 4.实际执行事务,但是之前命令有错误,所以Redis拒绝执行((error) EXECABORT Transaction discarded because of previous errors.)
EXEC
在整个事务操作过程中,有两个指令需要执行,指令1是错误的(Redis会提示错误)、指令2是正确的(Redis会将其加入队列),但在实际EXEC执行的时候,整个事务都被放弃执行,进而确保原子性操作
场景2:事务操作入队列时,命令和操作的数据类型不匹配,但Redis实例没有检查出错误
**事务操作入队时,命令和操作的数据类型不匹配,但 Redis 实例没有检查出错误。**但是,在执行完 EXEC 命令以后,Redis 实际执行这些事务操作时,就会报错。不过,需要注意的是,虽然 Redis 会对错误命令报错,但还是会把正确的命令执行完。在这种情况下,事务的原子性就无法得到保证了
# 初始化数据
set a:stock 5
set b:stock 10
# 事务示例(依次执行)
// 1.开启事务
MULTI
// 2.发送事务中的第一个操作,LPOP命令操作的数据类型不匹配,此时并不报错,正常入队列(返回:QUEUED)
LPOP a:stock
// 3.发送事务中的第二个操作,这个操作是正确的命令,Redis把该命令入队(返回:QUEUED)
DECR b:stock
// 4.实际执行事务,事务的第一个操作执行报错
EXEC
# 事务执行之后返回消息
# Value
1 WRONGTYPE Operation against a key holding the wrong kind of value
2 9
在传统的数据库(例如MySQL)执行事务的时候会提供回滚机制,当事务执行发生错误时,事务中的所有操作都会撤销,已经修改的数据也会被恢复到事务执行前的状态。但是从执行结果分析,这个事务在实际执行的时候报错了,但错误的操作执行失败,正确地操作还是执行成功的。实际上Redis并没有提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果
# 初始化数据
set a:stock 5
# 事务示例(依次执行)
// 1.开启事务
MULTI
// 2.发送事务中的第一个操作,对a:stock执行减1操作(正常入队,返回:QUEUED)
DECR a:stock
// 3.发送事务中的第二个操作,执行DISCARD命令,主动放弃事务(将暂存的命令队列清空,达不到回滚的效果)
DISCARD
// 4.再次读取a:stock的值(事务被放弃了,还是初始化的5)
get a:stock
场景3:在执行事务的 EXEC 命令时,Redis 实例发生了故障,导致事务执行失败
在这种情况下,如果 Redis 开启了 AOF 日志,那么,只会有部分的事务操作被记录到 AOF 日志中。需要使用 redis-check-aof 工具检查 AOF 日志文件,这个工具可以把未完成的事务操作从 AOF 文件中去除。基于此,当使用 AOF 恢复实例后,事务操作不会再被执行,从而保证了原子性。
如果 AOF 日志并没有开启,那么实例重启后,数据也都没法恢复了,此时,也就谈不上原子性了。
(2)Redis 对事务原子性属性的保证情况
- 命令入队时就报错(例如操作指令错误等),会放弃事务执行,保证原子性;
- 命令入队时没报错(例如操作指令对应的操作类型不匹配,但命令可以正常入队列),实际执行时报错,不保证原子性;
- EXEC 命令执行时实例发生故障,如果开启了 AOF 日志,可以保证原子性;
一致性(有保证)
事务的一致性保证会受到错误命令、实例故障的影响。参考对原子性的异常场景分析,可以分为下述三种情况:
- 场景1:命令入队时就报错
- 基于此场景,事务本身就会被放弃执行,所以可以保证数据库的一致性
- 场景2:命令入队时没报错
- 基于此场景,有错误的命令不会被执行,正确的命令可以正常执行,也不会改变数据库的一致性
- 此处区分原子性的概念,原子性针对的是事务中的所有操作要么都成功、要么都不成功;而一致性针对的是数据库中的数据要确保前后一致
- 场景3:EXEC 命令执行时实例发生故障
- 基于此场景,实例故障后会进行重启,一致性的保证和数据恢复的方式有关,要根据实例是否开启了 RDB 或 AOF 来分情况讨论下
- 如果没有开启 RDB 或 AOF,当实例故障重启后,数据都没有了,数据库是一致的
- RDB快照可确保数据的一致性:如果使用了 RDB 快照,因为 RDB 快照不会在事务执行时执行,所以事务命令操作的结果不会被保存到 RDB 快照中,使用 RDB 快照进行恢复时,数据库里的数据也是一致的
- AOF 日志可借助辅助工具以确保数据的一致性:如果使用了 AOF 日志,而事务操作还没有被记录到 AOF 日志时,实例就发生了故障,那么,使用 AOF 日志恢复的数据库数据是一致的。如果只有部分操作被记录到了 AOF 日志,可以使用 redis-check-aof 清除事务中已经完成的操作,数据库恢复后也是一致的
- 基于此场景,实例故障后会进行重启,一致性的保证和数据恢复的方式有关,要根据实例是否开启了 RDB 或 AOF 来分情况讨论下
综上所述,可以看到在命令执行错误或 Redis 发生故障的情况下,Redis 事务机制对一致性属性是有保证的
隔离性(有保证)
事务的隔离性保证,会受到和事务一起执行的并发操作的影响。而事务执行又可以分成命令入队(EXEC 命令执行前)和命令实际执行(EXEC 命令执行后)两个阶段,所以,就针对这两个阶段,分成两种情况来分析:
- 场景1:并发操作在 EXEC 命令前执行,此时,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证;
- 场景2:并发操作在 EXEC 命令后执行,此时,隔离性可以保证;
场景1:并发操作在 EXEC 命令前执行
一个事务的 EXEC 命令还没有执行时,事务的命令操作是暂存在命令队列中的。此时,如果有其它的并发操作,就需要看事务是否使用了 WATCH 机制。WATCH 机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用 EXEC 命令执行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。
WATCH 机制的具体实现是由 WATCH 命令实现的,结合图示进一步理解下 WATCH 命令的使用。
- 在 t1 时,客户端 X 向实例发送了 WATCH 命令。实例收到 WATCH 命令后,开始监测 a:stock 的值的变化情况
- 紧接着,在 t2 时,客户端 X 把 MULTI 命令和 DECR 命令发送给实例,实例把 DECR 命令暂存入命令队列
- 在 t3 时,客户端 Y 也给实例发送了一个 DECR 命令,要修改 a:stock 的值,实例收到命令后就直接执行了
- 等到 t4 时,实例收到客户端 X 发送的 EXEC 命令,此时实例的 WATCH 机制发现 a:stock 已被修改,就会放弃事务执行,以此保证事务的隔离性
在EXEC指令执行前如果没有使用 WATCH 机制,在 EXEC 命令前执行的并发操作是会对数据进行读写的。而且,在执行 EXEC 命令的时候,事务要操作的数据已经改变了,在这种情况下,Redis 并没有做到让事务对其它操作隔离,隔离性也就没有得到保障
结合图示分析:在 t2 时刻,客户端 X 发送的 EXEC 命令还没有执行,但是客户端 Y 的 DECR 命令就执行了,此时,a:stock 的值会被修改,这就无法保证 X 发起的事务的隔离性了
场景2:并发操作在 EXEC 命令后执行
基于并发操作在 EXEC 命令之后被服务器端接收并执行的场景,由于Redis 是用单线程执行命令,且EXEC 命令执行后,Redis 会保证先把命令队列中的所有命令执行完。所以,在这种情况下,并发操作不会破坏事务的隔离性
持久性(不保证)
Redis 是内存数据库,所以,数据是否持久化保存完全取决于 Redis 的持久化配置模式。
如果 Redis 没有使用 RDB 或 AOF,那么事务的持久化属性肯定得不到保证。
如果 Redis 使用了 RDB 模式,那么,在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,这种情况下,事务修改的数据也是不能保证持久化的。
如果 Redis 采用了 AOF 模式,因为 AOF 模式的三种配置选项 no、everysec 和 always 都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。
所以,不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。
3.Redis事务总结
Redis中通过MULTI、EXEC、DISCARD 和 WATCH 四个命令来支持事务机制
命令 | 说明 |
---|---|
MULTI | 开启一个事务 |
EXEC | 提交事务(从命令队列中取出提交的操作命令,进行实际执行操作) |
DISCARD | 放弃事务(放弃一个事务,并清空命令队列) |
WATCH | 检测一个或多个键值在事务执行期间是否发生变化,如果发生变化则当前事务放弃执行 |
事务的 ACID 属性是使用事务进行正确操作的基本要求。通过上述分析,了解Redis 的事务机制对ACID属性的场景支持。建议:**严格按照 Redis 的命令规范进行程序开发,并且通过 code review 确保命令的正确性。**让Redis 的事务机制就能被应用在实践中,保证多操作的正确执行。
Redis 对 ACID 属性的支持
原子性:区分三种场景,部分场景无法保证原子性
命令入队时就报错(例如操作指令错误等),会放弃事务执行,保证原子性;
命令入队时没报错(例如操作指令对应的操作类型不匹配,但命令可以正常入队列),实际执行时报错,不保证原子性;
EXEC 命令执行时实例发生故障,如果开启了 AOF 日志,可以保证原子性;
一致性:区分三种场景,可保证一致性
- 命令入队时就报错,会放弃事务执行,可保证一致性;
- 命令入队时没报错,实际执行时报错。有错误的命令不会被执行,正确的命令可以正常执行,可保证一致性
- EXEC 命令执行时实例发生故障,可保证一致性
- 如果没有开启 RDB 或 AOF,当实例故障重启后,数据都没有了,数据库是一致的
- RDB快照可确保数据的一致性:因为 RDB 快照不会在事务执行时执行,所以事务命令操作的结果不会被保存到 RDB 快照中,使用 RDB 快照进行恢复时,数据库里的数据也是一致的
- AOF 日志可借助辅助工具以确保数据的一致性:如果使用了 AOF 日志,而事务操作还没有被记录到 AOF 日志时,实例就发生了故障,那么,使用 AOF 日志恢复的数据库数据是一致的。如果只有部分操作被记录到了 AOF 日志,可以使用 redis-check-aof 清除事务中已经完成的操作,数据库恢复后也是一致的
隔离性:引入WATCH机制保证隔离性
- 场景1:并发操作在 EXEC 命令前执行,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证;
- 场景2:并发操作在 EXEC 命令后执行,隔离性可以保证;
持久性:无法保证持久性(Redis的持久化取决于持久化机制的配置模式)
- 如果 Redis 没有使用 RDB 或 AOF,那么事务的持久化属性肯定得不到保证
- 如果 Redis 使用了 RDB 模式,在一个事务执行后而下一次的 RDB 快照还未执行前,如果发生了实例宕机,事务修改的数据也是不能保证持久化的
- 如果 Redis 采用了 AOF 模式,因为 AOF 模式的三种配置选项 no、everysec 和 always 都会存在数据丢失的情况,基于这种情况事务的持久性属性也还是得不到保证
Redis 是否存在回滚机制?
Redis 不支持事务回滚,因为支持回滚会对 Redis 的简单性和性能产生重大影响。
"Redis" 是 "REmote DIctionary Server" 的缩写,翻译为“远程字典服务”,设计的初衷是用于缓存,追求快速高效。而了解过数据库的ACID事务就会理解事务回滚的复杂度,因此,Redis不支持事务回滚似乎也合情合理。
Redis的事务由 MULTI/EXEC 两个命令完成,WATCH/DISCARD 两个命令的加持,给 Redis事务提供了 CAS 乐观锁机制。Redis 事务不支持回滚,它和关系型数据库(比如 MySQL)的事务(ACID)是不一样的。
Lua 对Redis原子性的支持
1.Redis 中的原子性概念
Lua 是一种功能强大、高效、轻量级、可嵌入的脚本语言。它支持过程编程、面向对象编程、函数式编程、数据驱动编程和数据描述。 Lua 将简单的过程语法与基于关联数组和可扩展语义的强大数据描述结构相结合。Lua 是动态类型的,通过使用基于寄存器的虚拟机解释字节码来运行,并具有自动内存管理和增量垃圾回收功能,使其成为配置、脚本编写和快速原型设计的理想选择。
Lua 本身并没有提供对于原子性的直接支持,它只是一种脚本语言,通常是嵌入到其他宿主程序中运行,比如 Redis
在 Redis中执行 Lua的原子性是指:整个 Lua脚本在执行期间,会被当作一个整体,不会被其他客户端的命令打断。
Redis 中的原子性概念
传统ACID中原子性的概念:一个事务中的所有操作要么都执行要么都不执行,而Redis对原子性的保证则需要结合不同场景进行分析。
Lua对Redis原子性的支持:在Redis中执行Lua脚本,Lua脚本会作为一个整体执行且不被其他客户端打断。至于 Lua脚本里面的命令是否必须全部成功,或者全部失败,并不要求。
import redis.clients.jedis.Jedis;
/**
* Redis Lua 原子性Demo
*/
public class RedisLuaDemo {
/**
* Lua对Redis原子性的支持:
* 在Redis中支持Lua脚本,整个脚本在执行期间会被当做一个整体,不会被其他的客户端命令打断
*/
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
jedis.auth("123456");
jedis.select(1);
String luaScript =
"redis.pcall('SET',KEYS[1],ARGV[1])" +
"redis.pcall('SET',KEYS[2],ARGV[2])" +
"redis.pcall('SET',KEYS[3],ARGV[3])" +
"return 'OK'";
Object result = jedis.eval(luaScript,3,"key1","key2","key3","value1","value2","value3");
System.out.println("Result: " + result);
System.out.println("key1: " + jedis.keys("key1"));
System.out.println("key2: " + jedis.keys("key2"));
System.out.println("key3: " + jedis.keys("key3"));
// 关闭客户端连接
jedis.close();
}
}
2.Redis 中如何执行Lua?
一般情况下,Redis执行 Lua常用的方法有 2 种:
- 原生命令,比如 EVAL/EVALSHA命令等;(通过客户端直接执行原生命令)
- 编程工具,比如编程语言中提供的三方工具包或类库;(借助第三方工具进行调用)
在编写 Lua脚本时,需要注意区分 redis.call() 和 redis.pcall() 两个命令的使用,他们都是用于执行 Redis的命令。对于两个命令的场景选择,取决于根据实际业务来判断,标准是:当 Lua脚本中某条命令执行出错时,是否需要阻断后续的命令执行
- redis.call() :当命令执行出错时,会阻断整个脚本执行,并将错误信息返回给客户端
- redis.pcall():当命令执行出错时,不会阻断脚本的执行,而是内部捕获错误,并继续执行后续的命令
Lua 脚本执行
# 语法规则
EVAL script numkeys key [key ...] arg [arg ...]
script
是要执行的Lua脚本numkeys
是传递给脚本的键数量key [key ...]
是被Lua脚本使用的键arg [arg ...]
是传递给Lua脚本的参数
确保遵循了正确的格式,并且numkeys
的值与实际传递的键的数量相匹配。如果脚本不需要键或者参数,确保相应的部分为空。
# 案例1:如果脚本不需要键和参数
EVAL "return 'Hello, World!'" 0
# 案例2:如果脚本需要一个键和参数
set mykey myvalue # 设置一个字符串用于测试
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
# 案例3:基于上述JAVA编程中案例,其对应的脚本如下
-- 参数传递的写法
EVAL "redis.pcall('SET', KEYS[1],ARGV[1]); redis.pcall('SET', KEYS[2],ARGV[2]); redis.pcall('SET', KEYS[3],ARGV[3]); return 'OK'" 3 key1 key2 key3 value1 value2 value3
-- 不传递参数的写法
EVAL "redis.pcall('SET', 'key1', 'value1'); redis.pcall('SET', 'key2', 'value2'); redis.pcall('SET', 'key3', 'value3'); return 'OK'" 0
call()命令
redis.call() :当命令执行出错时,会阻断整个脚本执行,并将错误信息返回给客户端,已经执行成功的指令正常提交,当执行异常会阻断后面的内容
从结果可以看到,指令1正常执行,指令2执行异常阻断了脚本执行,导致指令3也没执行
# 脚本执行
EVAL "redis.call('SET', 'ckey1', 'value1'); redis.call('INCRBY', 'ckey2', 1/0); redis.call('SET', 'ckey3', 'value3'); return 'OK'" 0
pcall()命令
redis.pcall():当命令执行出错时,不会阻断脚本的执行,而是内部捕获错误,并继续执行后续的命令,捕获异常信息,正常执行后面的指令
从结果可以看到,指令1、3正常执行,指令2执行异常所以没成功
# 脚本执行
EVAL "redis.pcall('SET', 'pckey1', 'value1'); redis.pcall('INCRBY', 'pckey2', 1/0); redis.pcall('SET', 'pckey3', 'value3'); return 'OK'" 0
3.如何保证原子性?
Redis执行Lua脚本可以保证原子性,不过这和Redis Server的部署方式密不可分,此处结合多种不同的部署方式分析其对原子性的保证
单机部署
不管 Lua脚本中操作的 key是不是同一个,都能保证原子性
主从部署
Redis 主从复制是用于将主节点的数据同步到从节点,以保持数据的一致性。而Redis的所有写操作都在主节点上,所以,不管 Lua脚本中操作的 key是不是同一个,都能保证原子性;
需要注意:当主节点执行写命令时,从节点会异步地复制这些写操作。在这个复制的过程中,从节点的数据可能与主节点存在一定的延迟。因此,如果在 Lua 脚本中包含读操作,并且该脚本在主节点上执行,可能会读到最新的数据,但如果在从节点上执行,可能会读到稍有延迟的数据
集群部署
如果 Lua脚本操作的 key是同一个,能保证原子性;
如果操作的 Key不相同,可能被 hash 到不同的 slot,也可能 hash 到相同的 slot,所以不一定能保证原子性;
因此,在 Cluster集群部署的环境下使用 Lua脚本时一定要注意:Lua脚本中操作的是同一个 Key;
对原子性的保证
以 Redis单机部署为例:当客户端向服务器发送一个带有 Lua脚本的请求时,Redis会把该脚本当作一个整体,然后加载到一个脚本缓存中,因为 Redis读写命令是单线程操作。最终Lua脚本的读写在 Redis服务器上可以简单地抽象成下图,所有的 Lua脚本会按照进入顺序放入队列中,然后串行进行读写,这样就保证每个 Lua不会被其他的客户端打断,从而保证了原子性:
既然Redis事务在某些场景下可以保证原子性,为什么还需要Lua脚本?
- Lua 是一种嵌入式语言,是 Redis官方推荐的脚本语言;
- Lua 脚本一般比 MULTI/EXEC 更快、更简单;
- Lua 脚本更适合复杂的场景:Redis 事务中,事务队列中的所有命令都必须在 EXEC命令执行才会被执行,对于多个命令之间存在依赖关系,比如后面的命令需要依赖上一个命令结果的场景,Redis事务无法满足;
- Redis 事务能做的 Lua能做,Redis事务做不到的 Lua也能做;
4.Lua 注意事项
Redis执行 Lua脚本时,Lua的编写需要注意以下几个点:
- 不要在 Lua脚本中使用阻塞命令(如BLPOP、BRPOP等)。因此这些命令可能会导致 Redis服务器在执行脚本期间被阻塞,无法处理其他请求;
- 不要编写过长的 Lua脚本。因为 Redis读写命令是单线程,过长的脚本,加载,解析,运行会比较耗时,导致其他命令的延迟延迟增加;
- 不要在 Lua脚本中进行复杂耗时的逻辑;因为 Redis读写命令是单线程的,长时间运行脚本可能导致其他命令的延迟增加;
- Lua脚本中,需要注意区分 redis.call() 和 redis.pcall() 命令;
- Lua 索引表从索引 1 开始,而不是 0;