Redis-事务篇

2021年4月7日 24点热度 0条评论 来源: BP白朴

同系列一:Redis 缓存数据库入门教程
同系列二:Redis-通用指令篇
同系列三:Redis-RDB-AOF持久化篇

文章目录

Redis-事务篇

Redis简介

Redis是C语言开发的一个高性能键值对(key -value) 内存数据库,可以用作数据库,缓存和消息中间件等。

特点

  1. 作为内存数据库,它的性能非常优秀,数据存储在内存当中,读写速度非常快,支持并发10W QPS(每秒查询次数),单进程单线程,是线程安全的,采用IO多路复用机制。

  2. 丰富的数据类型,支持字符串,散列,列表,集合,有序集合等,支持数据持久化。可以将内存中数据保存在磁盘中,重启时加载。

  3. 主从复制,哨兵,高可用,可用作分布式锁。可以作为消息中间件使用,支持发布订阅。

什么是事务(Transaction)?

事务,一般是指要做的或者所做的事情,在计算机中是指访问并可能更新数据库中各种数据项的一个程序执行单元,它包含了一组数据操作指令,并且所有的指令都作为一个整体一起向系统提交或撤销操作(要么都执行,要么都不执行)事务是恢复和并发控制的基本单位。

begin transaction;
    update account set money = money-100 where name = '张三';
    update account set money = money+100 where name = '李四';
commit transaction;

特征

关系型数据库事务具有四大特性:原子性、一致性、隔离性、持久性

简称:ACID属性

  • 原子性:事务是一个完整的操作。事务中的各部操作是不可分割的(原子的),要么都执行,要么都不执行

  • 一致性:事务必须是使数据库从一个一致性状态变到另一个一致性状态,当事务完成时,数据必须是一致状态

  • 隔离性:对数据操作的所有并发事务都是相互隔离的,事务必须是独立,不依赖或影响其他事务

  • 永久性:事务一旦提交完成后,它对数据库的修改被永久保持,事务日志能够保持事务的永久性,其他操作不应该对其执行结果有任何影响

Redis中的事务?

Redis中的事务实际上是一组命令的集合。它的事务支持一次执行多个命令,一个事务中所有命令都会被序列化。将一系列预定义命令打包成一个整体(队列),当执行时,一次性按照添加顺序依次执行,中间不会被打断或者干扰。在事务指向期间,服务端不会中断事务而改去执行其他客户端的命令请求,将事务中的所有命令执行完毕才会去处理其他客户端请求。

Redis事务的作用就是在一个队列中,一次性,顺序性,排他性的执行一系列命令

事务的基本操作

在 Redis 中使用事务会经过 3 个过程

  • 开启事务:multi 执行该命令表示一个事务快的开始,在开启完事务的时候,每次操作的命令将会被插入到一个队列中并返回QUEUED,同时这个队列中的命令在事务没有被提交之前不会被实际执行 –>没有隔离级别的概念,但是总归是具有隔离性,毕竟不会受到别的命令打断
    例:
    事务队列结构:

  • 执行事务:exec 执行该命令后,redis会执行事务块里面的所有命令,该命令需要和 multi 命令成对使用,事务不保证原子性且没有回滚,任意命令执行失败还是会接着往下执行 –>不保证原子性
    例:

    执行事务的流程:

  • 取消事务:discard 执行命令后,放弃执行该事务的所有命令,取消该事务,该命令需要和 multi 命令成对使用

    总体执行流程:

事务的工作流程

如图:

当客户端在给服务器发送一个 X 指令后,服务器大概会进行这么一个操作

判断我们当前是否存在一个事务的状态,如果当前不是有事务状态,则去识别判断客户端发送过来的 X 是不是一个关于事务操作的指令,如果这个 X 是个普通的指令,那么服务端就直接执行了 X ,最后返回执行结果。

如果这个X是一个事务操作的则有三种情况:

  1. X 为 multi 指令,那么给它开启一个事务队列,返回一个OK给客户端
  2. X 为 discard 指令,由于我们还没有开启事务状态,所以会直接报错:(error) ERR DISCARD without MULTI
  3. X为 exec 指令,由于我们还没有开启事务状态,也会报错:(error) ERR EXEC without MULTI

如果当前有事务状态,则去识别判断客户端发送过来的X是不是一个关于事务操作的指令,如果这个X是个普通的指令,那么服务端就将 X 指令加到事务队列中去,最后返回 QUEUED问题:如果在中途输入了一个语法有误的命令,会有什么结果?

如果这个 X 是一个事务操作的则又有三种情况:

  1. X 为 multi 指令,那么它本身已经处于事务状态了,还来一个 multi 那么就会报错了,Redis事务禁止套娃:(error) ERR MULTI calls can not be nestedMULTI命令的发送不会造成整个事务失败,也不会修改事务队列中已有的数据
  2. X 为 discard 指令,那么会将当前事务队列销毁,队列中所有命令都不执行,然后返回 OK
  3. X 为 exec 指令,接下来会进行执行队列中的命令,返回结果。这里会产生一个问题,当我们输入一条语法正确的命令,但是运行时实际会出现异常的命令,如:set cjcc value,接下来跟着用incr cjcc,会产生什么后果?

事务操作注意事项

定义事务过程中,命令格式输入错误

在Redis中,如果所定义的事务包含命令出现了语法错误(后面命令照常可以入队),那么这个事务中的所有命令都不会执行,包括正确的命令

127.0.0.1:6379> multi   开启了一个事务
OK
127.0.0.1:6379> set key 1  添加正确的命令到事务队列
QUEUED
127.0.0.1:6379> sett s     添加错误的命令到事务队列
(error) ERR unknown command 'sett'
127.0.0.1:6379> set k k    添加正确的命令到事务队列,发现还能加到队列
QUEUED
127.0.0.1:6379> exec       尝试执行事务,发现报错了,因为上面出现了一条编译错误的命令,而放弃了当前事务
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379>

例:
结果:

定义事务过程中,命令执行错误

在Redis中,如果所定义的事务队列命令语法正确,但是无法正确的执行(运行时异常,如Java的除0异常之类。。。。,在这Redis里就是相当于对一个字符串进行自增)那么运行错误的命令不会被执行,其他正确的命令都会被执行

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set cjcc value    添加正确的命令到事务队列
QUEUED
127.0.0.1:6379> incr cjcc         添加正确的命令到事务队列,自增字符串运行会出错
QUEUED
127.0.0.1:6379> set cjcc1 value1  添加正确的命令到事务队列
QUEUED
127.0.0.1:6379> exec
1) OK       执行成功
2) (error) ERR value is not an integer or out of range
3) OK       执行成功
127.0.0.1:6379>

注意:

单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的,所以也不具有一致性

redis的事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做(前面有图)。redis提供了一个简单的事务,这也是和传统关系型数据库的区别,但凡报错,整个事务必回滚

事务回滚

手动进行事务回滚

记录操作过程中被影响的数据之前的状态,把他存储起来,对于单个的对于string这种,存储前先获取值存起来,对于hash,list,set,zset 先拷贝一份副本,万一出问题了用将整个副本恢复回去。

对于一些重要的操作,我们必须通过程序去检测数据的正确性,以保证 Redis 事务的正确执行,避免出现数据不一致的情况。Redis 之所以保持这样简易的事务,完全是为了保证移动互联网的核心问题——性能。

Redis——锁

watch 命令

watch key1 [key2……] 

WATCH 是一个乐观锁(CAS(Compare And Swap)CAS会出现ABA问题)。命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被拒绝执行,并向客户端返回代表事务执行失败的空回复,其实就是大家都监控同一个或几个点,我想操作什么东西的时候,只要大家都没有动它,那么我就会进行操作,如果发现有人动了即会被监控发现触发了这个条件,那我就取消这次事务操作

每个redis数据库都保存着一个 watched_keys 字典,这个字典的键是某个被 watch 命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端,数据结构如下图:

c1…c2代表有c1和c2两个客户端在监控name这个key

所有对数据库进行修改命令,在执行之后都会对 watched_keys 字典进行检查,查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有,则会将监视被修改键的 reids_dirty_cas 标识打开,
表示该客户端的事务安全性已经被破坏.如果在事务提交时,检测到该标识被打开,则会拒绝执行它们提交的事务,以此来保证事务的安全性.

WATCH 只能在客户端进入事务状态之前执行,在事务状态下发送 WATCH 命令会引发一个错误,但它不会造成整个事务失败,也不会修改事务队列中已有的数据(和前面处理 MULTI 的情况一样

那么 watch 会不会出现ABA问题?

ABA问题:一个线程把数据A变为了B,然后又重新变成了A。此时另外一个线程读取的时候,发现A没有变化,就误以为是原来的那个A

代码位置:multi.c >> void touchWatchedKey(redisDb *db, robj *key)

/* "Touch" a key, so that if this key is being WATCHed by some client the * next EXEC will fail. */
void touchWatchedKey(redisDb *db, robj *key) { 
    list *clients;
    listIter li;
    listNode *ln;

    if (dictSize(db->watched_keys) == 0) return;
    clients = dictFetchValue(db->watched_keys, key);
    if (!clients) return;

    /* Mark all the clients watching this key as CLIENT_DIRTY_CAS */
    /* Check if we are already watching for this key */
    listRewind(clients,&li);
    while((ln = listNext(&li))) { 
        client *c = listNodeValue(ln);

        c->flags |= CLIENT_DIRTY_CAS;
    }
}

每当key的内容被修改时,则遍历所有watch了该 key 的客户端,设置相应的状态为CLIENT_DIRTY_CAS,所以不会出现 ABA 问题

看图:

unwatch 命令

unwatch  //取消对所有key的监控

如果在执行 WATCH 命令之后, EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行 UNWATCH 。因为 EXEC 命令会执行事务,因此 WATCH 命令的效果已经产生了;而 DISCARD 命令在取消事务的同时也会取消所有对 key 的监视,因此这两个命令执行之后,就没有必要执行 UNWATCH

分布式锁

setnx(set if not exists)实现

命令介绍

set if not exists 如果不存在,则 SET

  • 使用setnx 设置一个公共锁
    • 有值则返回设置失败: 0
    • 无值则返回设置成功: 1
setnx lock-key value


利用分布式锁控制商城超卖场景的问题: 电商618活动热卖商品 X ,非常多客户抢购,3S内将所有商品购买完毕,如何防止最后一件商品被多人同时购买。

这时候,使用上面的watch监控一个库存的key,还能不能解决问题?

不能,这个key会一直变化,如从100一点点的减到了0 ,这样的话,做了一次,剩下的事务全部被放弃了,一个人订购了一个个X商品,其他人订购的就全部被取消掉,这是不现实的。毕竟库存都还没一定到最后一件,因为watch 主要是监控某一个 key 值,有没有被其他客户端改变过,而不是控制其他客户端能不能修改这个值,所以这个时候用 watch 就不合适了,所以需要引入新的方法 setnx

watch setnx
key 有没被别人改过 key 能不能被别人改

用法:利用setnx 命令的返回值特征

  • 对于返回设置成功的,拥有控制权,进行下一步的具体业务操作,如:incr cjcc
  • 对于返回设置失败的,没有控制权,进行排队或者等待操作完毕
  • 操作完成后,通过del命令操作释放锁

改良1 setnx + expire

需要注意的是: 当我们用了上述方案设计了简单的分布式锁,已经可以实现控制客户端能不能具体控制对应的某个业务了,问题来了,当我们设置锁了之后,客户端挂了/停电了(),但是它偏偏已经获得锁了,没来得及打开,这样就容易产生了死锁的风险。所以我们要有一个保底机制,当用户控制加锁后,不能仅仅由用户进行解锁,我们在系统层面也需要做到能控制锁的释放,所以改进方案如下:使用 expire 命令为锁定的key添加一个时间限定,到时释放锁

命令

expire key second
pexpire key milliseconds

于是现在命令变成了:
setnx cjcc 1
expire cjcc 10 //设置cjcc值为1,cjcc过期时间为10秒

改良2 set扩展参数

但是,上面改良的方案还有一种可能,这种解决方案终归还是2条命令组成的,万一在设置完成值后,还没来得及设置expire过期时间,系统就已经挂掉了。锁未释放,也会引发死锁问题,新的解决方案:
set key value NX [EX seconds] [PX milliseconds]

set参考: SET 命令的行为可以通过一系列参数来修改by http://redisdoc.com/string/set.html

EX seconds : 将键的过期时间设置为 seconds 秒。 执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value 。

PX milliseconds : 将键的过期时间设置为 milliseconds 毫秒。 执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value 。

NX : 只在键不存在时, 才对键进行设置操作。 执行 SET key value NX 的效果等同于执行 SETNX key value 。

XX : 只在键已经存在时, 才对键进行设置操作。

因为 SET 命令可以通过参数来实现 SETNXSETEX 以及 PSETEX 命令的效果, 所以 Redis 将来的版本可能会移除并废弃 SETNXSETEXPSETEX 这三个命令。

锁过期时间设置

上面设置的锁的时间都是为了方便测试,截图,要是生产上真设置几秒一次,那就真的玩蛇了。
一般操作通常都是微秒或者毫秒级,所以锁定时间不合适设置太大了,至于具体时间,看自己业务测试来确定区间【持有锁的最短执行时间,最长执行时间】以及网络请求耗时之类的来定。

其他文章

同系列一:Redis 缓存数据库入门教程
同系列二:Redis-通用指令篇
同系列三:Redis-RDB-AOF持久化篇

    原文作者:BP白朴
    原文地址: https://blog.csdn.net/weixin_41367523/article/details/106715883
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。