6 redis事务

6.1 redis 事务定义

Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。Redis事务的主要作用就是串联多个命令防止别的命令插队。

6.2 redis 事务命令

multi、exec、discard
从输入multi命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入exec后,Redis会将之前的命令队列中的命令依次执行。组队的过程中可以通过discard来放弃组队。
123.jpg
组队成功 提交成功
image.png
组队成功 取消组队
image.png
组队阶段失败 提交失败
image.png
如果执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚
image.png

6.3 事务的特性

  • 悲观锁
  • 乐观锁

    监视一个或多个key 乐观锁实现

    watch [key . . .]
    如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
    image.png
    unwatch 取消所以key监视

    6.4 redis事务三大特性

  1. 单独的隔离操作
    1. 事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  2. 没有隔离级别的概念
    1. 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行
  3. 不保证原子性

    1. 事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

      7 秒杀案例

      7.1 存在并发问题的秒杀,没有加锁或没有使用 lua脚本。

      ```java @Service @Log4j2 public class TestService {

      @Autowired RedisTemplate redisTemplate;

      //库存key private static final String STOCK_KEY = “sk:”; //秒杀成功user key private static final String SK_SUSS = “successUser:”;

      /**

      • 秒杀
      • 并发下会产生超卖 *
      • @param userId 用户id
      • @param productId 商品id
      • @return */ public boolean seckill(String userId, Integer productId) { //1 uid和prodid非空判断 if (userId == null || productId == null) {

        1. log.error("参数错误");
        2. return false;

        }

        //2 获取库存,如果库存null,秒杀还没有开始 (多个线程进入获取同一个值,此时未执行decrement) Integer stock = (Integer) redisTemplate.opsForValue().get(STOCK_KEY + productId); if (stock == null) {

        1. log.error("秒杀还没有开始");
        2. return false;

        }

        //3 判断用户是否重复秒杀操作 if (BooleanUtil.isTrue(redisTemplate.opsForSet().isMember(SK_SUSS + productId, userId))) {

        1. log.error("已经秒杀过");
        2. return false;

        }

        //4 判断如果商品数量,库存数量小于1,秒杀结束 if (stock < 1) {

        1. log.error("秒杀已经结束了");
        2. return false;

        }

        //5 秒杀过程 redisTemplate.opsForValue().decrement(STOCK_KEY + productId); redisTemplate.opsForSet().add(SK_SUSS + productId, userId); log.warn(“秒杀成功:” + userId); return true;

      }

}

  1. <a name="Kqp3y"></a>
  2. ## 7.2 基于redis事务解决并发问题
  3. > [redisTemplate 操作事务](https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#tx)
  4. ```java
  5. /**
  6. * 秒杀
  7. * multi 乐观锁操作
  8. * 使用redisTemplate.multi 会启用一个新的连接 因为他会先执行discard 所以导致开始事务和批量(multi)不一致
  9. * 只能引入jedis操作
  10. *
  11. * @param uid
  12. * @param productId
  13. * @return
  14. */
  15. public boolean doSecKill(String uid, String productId) {
  16. //1 uid和prodid非空判断
  17. if (uid == null || productId == null) {
  18. return false;
  19. }
  20. //2 连接redis
  21. //通过连接池得到jedis对象
  22. JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
  23. Jedis jedis = jedisPoolInstance.getResource();
  24. //3 监视库存
  25. jedis.watch(STOCK_KEY + productId);
  26. //4 获取库存,如果库存null,秒杀还没有开始
  27. String kc = jedis.get(STOCK_KEY + productId);
  28. if (kc == null) {
  29. System.out.println("秒杀还没有开始,请等待");
  30. jedis.close();
  31. return false;
  32. }
  33. // 5 判断用户是否重复秒杀操作
  34. if (jedis.sismember(SK_SUSS + productId, uid)) {
  35. System.out.println("已经秒杀成功了,不能重复秒杀");
  36. jedis.close();
  37. return false;
  38. }
  39. //6 判断如果商品数量,库存数量小于1,秒杀结束
  40. if (Integer.parseInt(kc) <= 0) {
  41. System.out.println("秒杀已经结束了");
  42. jedis.close();
  43. return false;
  44. }
  45. //7 秒杀过程
  46. //使用事务
  47. Transaction multi = jedis.multi();
  48. //组队操作
  49. multi.decr(STOCK_KEY + productId);
  50. multi.sadd(SK_SUSS + productId, uid);
  51. //执行
  52. List<Object> results = multi.exec();
  53. if (results == null || results.size() == 0) {
  54. System.out.println("秒杀失败了....");
  55. jedis.close();
  56. return false;
  57. }
  58. System.out.println("秒杀成功了..");
  59. jedis.close();
  60. return true;
  61. }

ab工具 模拟并发 也可以使用jmeter 并发测试

  1. ab -n 2000 -c 200 -T 'text/plain;charset=UTF-8' https://cn-cd-dx-2.natfrp.cloud:64074/redis/seckill/1001

当秒杀并发大,库存够多的情况下,乐观锁导致库存锁住而秒杀失败,但是库存依旧存在。所以单纯使用redis事务在实际秒杀场景中不能使用。

7.3 使用lua脚本

Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,Lua并没有提供强大的库,一个完整的Lua解释器不过200k,所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言
LUA脚本在Redis中的优势
将复杂的或者多步的redis操作,写为一个脚本,一次提交给redis执行,减少反复连接redis的次数。提升性能。
LUA脚本是类似redis事务,有一定的原子性,不会被其他命令插队,可以完成一些redis事务性的操作。
但是注意redis的lua脚本功能,只有在Redis 2.6以上的版本才可以使用。

  1. public boolean doSecKillWithScript(String userId, String productId) {
  2. JedisPool jedispool = JedisPoolUtil.getJedisPoolInstance();
  3. Jedis jedis = jedispool.getResource();
  4. String sha1 = jedis.scriptLoad(secKillScript);
  5. Object result = jedis.evalsha(sha1, 2, userId, productId);
  6. String reString = String.valueOf(result);
  7. jedis.close();
  8. if ("0".equals(reString)) {
  9. System.err.println("已抢空!!");
  10. } else if ("1".equals(reString)) {
  11. System.out.println("抢购成功!!!!");
  12. return true;
  13. } else if ("2".equals(reString)) {
  14. System.err.println("该用户已抢过!!");
  15. } else {
  16. System.err.println("抢购异常!!");
  17. }
  18. return false;
  19. }
  20. static String secKillScript = "local userid=KEYS[1];\r\n" +
  21. "local prodid=KEYS[2];\r\n" +
  22. "local qtkey='sk:'..prodid..\":qt\";\r\n" +
  23. "local usersKey='sk:'..prodid..\":usr\";\r\n" +
  24. "local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" +
  25. "if tonumber(userExists)==1 then \r\n" +
  26. " return 2;\r\n" +
  27. "end\r\n" +
  28. "local num= redis.call(\"get\" ,qtkey);\r\n" +
  29. "if tonumber(num)<=0 then \r\n" +
  30. " return 0;\r\n" +
  31. "else \r\n" +
  32. " redis.call(\"decr\",qtkey);\r\n" +
  33. " redis.call(\"sadd\",usersKey,userid);\r\n" +
  34. "end\r\n" +
  35. "return 1";

8 redis持久化RDB与AOF

Redis 提供了2个不同形式的持久化方式。

  • RDB(Redis DataBase)
  • AOF(Append Of File)

    8.1 RDB

    可参考 https://blog.csdn.net/zhizhengguan/article/details/120619574
    在指定的时间间隔内将内存中的数据集快照写入磁盘,也就是Snapshot快照,它恢复时是将快照文件直接读到内存里

    8.1.1 RDB备份是如何执行的

    Redis会单独创建(fork)一个子进程来进行持久化,会先将数据写入到一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那RDB方式要比AOF方式更加的高效。RDB的缺点是最后一次持久化后的数据可能丢失

    8.1.2 Fork

  • Fork的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程

  • 在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,Linux中引入了“写时复制技术
  • 一般情况父进程和子进程会共用同一段物理内存,只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。

image.png

8.1.3 RDB保存的名称

image.png

8.1.3 RDB保存的位置

image.png

8.1.4 手动触发RDB快照

手动触发有两种方式: save和bgsave

  • save :save时只管保存,其它不管,全部阻塞。手动保存。不建议。
  • bgsave:Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。 (默认配置)

可以放开 自己手动修改触发策略
image.png

8.1.4 自动触发RDB快照

除去手动触发之外,还有自动触发RDB持久化的方式,例如以下的场景:

  • 在配置文件中使用了save的相关配置,比如save m n。表示m秒内数据集存在n次修改,自动触发bgsave
  • 如果节点执行全量复制操作,主节点自动执行gbsave生成RDB文件并发送给从节点
  • 执行debug reload命令重新加载redis时,也会自动触发save操作
  • 默认情况下执行shutdown命令时,如果没有开启AOF持久化功能则自动执行bgsave

    8.1.5 RDB优缺点

    优点

  • RDB文件小,非常适合定时备份,用于灾难恢复

  • 因为RDB文件中直接存储的是内存数据,而AOF文件中存储的是一条条命令,需要应用命令。Redis加载RDB文件的速度比AFO快很多。

缺点

  • RDB持久化方式不能做到实时/秒级持久化。
    • 实时持久化要全量刷内存到磁盘,成本太高。
    • 每秒fork子进程也会阻塞主进程,影响性能。
  • 使用 RDB 方式实现持久化,一旦 Redis 异常退出,就会丢失最后一次快照以后更改的所有数据。
    • 这个时候我们就需要根据具体的应用场景,通过组合设置自动快照条件的方式来将可能发生的数据损失控制在能够接受范围。
    • 如果数据相对来说比较重要,希望将损失降到最小,则可以使用 AOF 方式进行持久化。
  • RDB文件是二进制文件,随着Redis不断迭代有多个rdb文件的版本,不支持跨版本兼容。老的Redis无法识别新的RDB文件格式。

    8.2 AOF

    可参考 https://blog.csdn.net/zhizhengguan/article/details/120617438
    AOF(append-only file)持久化也叫做在增量持久化,它仅对数据的变化进行存储,类似于日志文件。只许追加文件但不可以改写文件,redis启动之初会读取该文件重新构建数据。

    8.2.1 AOF持久化过程

    流程如下:

  • 所有写入命令都会追加到aof_buf(缓冲区)中。

  • AOF缓冲区根据对应的策略向硬盘做同步操作
  • 随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩的目的
  • 当redis服务器重启时,可以加载AOF文件进行数据恢复

image.png

8.2.2 开启AOF

默认是不开启aof的 默认文件名为appendonly.aof ,文件位置和rdb存放位置一样
image.png

8.2.3 AOF异常修复

修改默认的appendonly no,改为yes
如遇到AOF文件损坏,通过/usr/local/bin/redis-check-aof—fix appendonly.aof进行恢复
备份被写坏的AOF文件
恢复:重启redis,然后重新加载

8.2.4 AOF同步频率设置

image.png
appendfsync always:始终同步,每次Redis的写入都会立刻记入日志;性能较差但数据完整性比较好
appendfsync everysec:每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能丢失。
appendfsync no:redis不主动进行同步,把同步时机交给操作系统。

8.2.5 Rewrite压缩

8.2.5.1 是什么

AOF采用文件追加方式,文件会越来越大为避免出现此种情况,新增了重写机制, 当AOF文件的大小超过所设定的阈值时,Redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集.可以使用命令bgrewriteaof

8.2.5.2 重写原理,如何实现重写

AOF文件持续增长而过大时,会fork出一条新进程来将文件重写(也是先写临时文件最后再rename),redis4.0版本后的重写,是指把rdb 的快照,以二级制的形式附在新的aof头部,作为已有的历史数据,替换掉原来的流水账操作。

  • no-appendfsync-on-rewrite=yes ,不写入aof文件只写入缓存,用户请求不会阻塞,但是在这段时间如果宕机会丢失这段时间的缓存数据。(降低数据安全性,提高性能)
  • no-appendfsync-on-rewrite=no, 还是会把数据往磁盘里刷,但是遇到重写操作,可能会发生阻塞。(数据安全,但是性能降低)

触发机制,何时重写: Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发
重写虽然可以节约大量磁盘空间,减少恢复时间。但是每次重写还是有一定的负担的,因此设定Redis要满足一定条件才会进行重写。
auto-aof-rewrite-percentage:设置重写的基准值,文件达到100%时开始重写(文件是原来重写后文件的2倍时触发)
auto-aof-rewrite-min-size:设置重写的基准值,最小文件64MB。达到这个值开始重写。
例如:文件达到70MB开始重写,降到50MB,下次什么时候开始重写?100MB
系统载入时或者上次重写完毕时,Redis会记录此时AOF大小,设为base_size,
如果Redis的AOF当前大小>= base_size +base_size*100% (默认)且当前大小>=64mb(默认)的情况下,Redis会对AOF进行重写。

8.2.5.3 重写流程

  1. bgrewriteaof触发重写,判断是否当前有bgsave或bgrewriteaof在运行,如果有,则等待该命令结束后再继续执行。
  2. 主进程fork出子进程执行重写操作,保证主进程不会阻塞。
  3. 子进程遍历redis内存中数据到临时文件,客户端的写请求同时写入aof_buf缓冲区和aof_rewrite_buf重写缓冲区保证原AOF文件完整以及新AOF文件生成期间的新的数据修改动作不会丢失。

    1. 子进程写完新的AOF文件后,向主进程发信号,父进程更新统计信息
    2. 主进程把aof_rewrite_buf中的数据写入到新的AOF文件
  4. 使用新的AOF文件覆盖旧的AOF文件,完成AOF重写。

image.png

8.2.6 AOF优缺点

优点

  • 备份机制更稳健,丢失数据概率更低。
  • 可读的日志文本,通过操作AOF稳健,可以处理误操作。

缺点

  • 比起RDB占用更多的磁盘空间。
  • 恢复备份速度要慢。
  • 每次读写都同步的话,有一定的性能压力。
  • 存在个别Bug,造成恢复不能。

    8.3 具体选择哪种持久化方式

    混合持久化: https://zhuanlan.zhihu.com/p/462906147
    (1)一般来说,如果对数据的安全性要求非常高的话,应该同时使用两种持久化功能。
    如果可以承受数分钟以内的数据丢失,那么可以只使用 RDB 持久化。
    (2)有很多用户都只使用 AOF 持久化, 但并不推荐这种方式: 因为定时生成 RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快
    两种持久化策略可以同时使用,也可以使用其中一种。如果同时使用的话, 那么Redis 重启时,会优先使用AOF文件来还原数据