[学习记录] Redis 5. 事务和锁机制,秒杀案例 Demo

Redis 5. 事务和锁机制,秒杀案例 Demo

参考课程:https://www.bilibili.com/video/BV1Rv41177Af

参考书:https://blog.csdn.net/liu8490631/article/details/124290851

1. Redis 事务定义

Redis 事务是一个单独的隔离操作,事务中的所有命令都会序列化,按顺序地执行。

事务在执行过程中,不会被其他地客户端发送来地命令请求所打断。

Redis 事务地主要作用就是串联多个命令防止别地命令插队。

2. Multi,Exec,Discard

输入 Multi 后,输入的命令会依次进入命令队列,但不会执行,直到输入 Exec 后,Redis 会将之前的命令队列中的命令依次执行,discard 会抛弃命令队列中的命令。

类似于 MySQL 中 start transaction/begin 开启事务,commit 提交事务,rollback 回滚事务。

3. 事务错误处理

当 Redis 事务中有命令报错 ERROR,则该事务中的所有命令都不执行:
在这里插入图片描述

事务中没有错误,事务执行过程中出现错误,则报错的语句不执行,其他命令正常执行:
在这里插入图片描述

4. 事务冲突的问题

一般情况下,三个事务同时修改余额,可能会导致余额不够买一个商品但是买下来了的情况。这就是事务冲突。解决冲突的方法:

4.1 悲观锁(Pessimistic Lock)

每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。

  • 传统的关系型数据库里面就用到了很多这种锁机制,比如行锁表锁读锁写锁等,都是在做操作之前先上锁。

  • 缺点:效率低

4.2 乐观锁(Optimistic Lock)

每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。

  • 乐观锁适用于多读的应用类型,这样可以提高吞吐量

  • Redis 采用的就是这种 check-and-set 机制实现事务的

  • 典型场景:抢票

4.3 WATCH key [key …]

在执行 MULTI 之前,先执行 watch key1 [key2 ...],可以监视一个(或者多个)key,如果在事务执行之前这个或这些 key 被其他命令所改动,那么事务将被打断

事务 1 和事务 2 同时 watch 一个 key,然后都 multi 开启事务:

  1. 事务 1 对 key 进行修改,exec,修改成功
  2. 事务 2 对 key 进行修改,exec,可以看到返回(nil),修改失败
  • unwatch 可以取消 key 的监视,若执行 watch 命令后又执行了 exec 或者 discard 的话,就不用执行 unwatch 了。

5. Redis 事务三特性

5.1 单独隔离性

  • 事务中的所有命令都会序列化,按照顺序执行。
  • 事务在执行过程中,不会被其他的客户端发送来的命令请求所打断

5.2 没有隔离级别的概念

  • 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行。

5.3 不保证原子性

  • 事务执行过程中如果有一条命令执行失败,其后面的命令仍然会被执行,没有回滚
  • 若在队列中发生语法错误等则该事务中的所有元素都会被 discard

6. 秒杀案例 Demo

需求:

  • 商品库存:个数减少
  • 秒杀成功清单:加人

6.0 前情提要

SpringBoot 默认序列化方式可读性太差了:
在这里插入图片描述
换成 Jackson

@Configuration
public class RedisConfig {
    /**
     * 设置redis键值的序列化方式
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        /*
         * value值的序列化可采用如下几种方式
         * 1.默认采用JdkSerializationRedisSerializer 序列化后的长度最短,时间适中,但不是明文显示
         * 2.采用Jackson2JsonRedisSerializer 明文显示,序列化速度最快,长度适中,但会使存入redis中timestamp类型的数据以long类型存储
         * 3.采用GenericJackson2JsonRedisSerializer 明文显示,在redis中显示了@class字段保存有类型的包路径,反序列化更容易,但是序列化时间最长,长度最大,明文显示
         * 4.自定义FastJsonRedisSerializer实现RedisSerializer接口
         *
         * 这里使用Jackson2JsonRedisSerializer,并对日期类型做特别处理
         */
        Jackson2JsonRedisSerializer<Object> redisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
        ObjectMapper om = new ObjectMapper();
        // 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        /*
         *  指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常
         *  过期方法:om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
         */
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.WRAPPER_ARRAY);

        // 日期序列化处理
        om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        om.registerModule(new Jdk8Module())
                .registerModule(new JavaTimeModule())
                .registerModule(new ParameterNamesModule());
        redisSerializer.setObjectMapper(om);

        // 设置值(value)的序列化采用Jackson2JsonRedisSerializer
        template.setValueSerializer(redisSerializer);
        template.setHashValueSerializer(redisSerializer);

        // 设置键(key)的序列化采用StringRedisSerializer
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        template.afterPropertiesSet();

        return template;
    }
}

字符串的 value 保存后是 "\"world\"" 的形式,数字类型默认为 Integer,即使字符串进去出来也是 Integer,小数默认就是 Double。

在这里插入图片描述

6.1 简单案例

package com.xz.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.web.bind.annotation.*;

import java.util.Set;

@RestController
@RequestMapping("/redisTest")
public class RedisTestController {
    @Autowired
    private RedisTemplate redisTemplate;
    
    @PostMapping("/test")
    public boolean test2 (String uid, String prodid) {
        ValueOperations opsString = redisTemplate.opsForValue();
        SetOperations opsSet = redisTemplate.opsForSet();

        // 判空
        if (uid == null || prodid == null) {
            return false;
        }

        // 拼接 key, 库存 key , 秒杀成功用户 key
        String kcKey = "sk:" + prodid + ":qt";
        String userKey = "sk:" + prodid + ":user";

        // 获取库存, 若库存为 null, 秒杀未开始
        Object kc = opsString.get(kcKey);

        if (kc == null) {
            System.out.println("秒杀活动未开始!");
            return false;
        }

        // 判断是否重复秒杀操作
        if (Boolean.TRUE.equals(opsSet.isMember(userKey, uid))) {
            System.out.println("已经参与秒杀, 不能重复参与!");
            return false;
        }

        // 判断如果商品数量 < 1, 秒杀结束
        if ((Integer)kc < 1) {
            System.out.println("秒杀已经结束!");
            return false;
        }

        // 秒杀过程  库存 -1, 秒杀成功的用户添加进清单
        opsString.decrement(kcKey);
        opsSet.add(userKey, uid);
        System.out.println("用户 " + uid + " 参与 " + prodid + " 的秒杀活动参与成功!");
        return true;
    }

6.3 JMeter 测试

在这里插入图片描述

在这里插入图片描述

Redis 设置 0101 号商品库存为 10:

SET sk:0101:qt 10

500 个线程,每个线程抢两次。

在这里插入图片描述
10 件商品卖出去了 200 件。
严重超卖!

6.4 超卖问题

下面这代码并没有完全解决超卖,还不知道啥原因,记录一下,往后学一学,能解决了再来改!我设置一千个线程都超了!

找到原因了找到原因了,监视库存应该放在所有对 Redis 访问或者修改操作之前!

错误代码,监视库存只放在 Redis 写操作之前:

@PostMapping("/test")
    public boolean test2 (String uid, String prodid) {
        // 判空
        if (uid == null || prodid == null) {
            return false;
        }

        // 拼接 key, 库存 key , 秒杀成功用户 key
        String kcKey = "sk:" + prodid + ":qt";
        String userKey = "sk:" + prodid + ":user";


        // 获取库存, 若库存为 null, 秒杀未开始
        Object kc = redisTemplate.opsForValue().get(kcKey);

        if (kc == null) {
            System.out.println("秒杀活动未开始!");
            return false;
        }

        // 判断是否重复秒杀操作
        if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(userKey, uid))) {
            System.out.println("已经参与秒杀, 不能重复参与!");
            return false;
        }

        // 判断如果商品数量 < 1, 秒杀结束
        if ((Integer)kc < 1) {
            System.out.println("秒杀已经结束!");
            return false;
        }

        /*
        * 事务
        * */

        SessionCallback sessionCallback = new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                // 监视库存
                operations.watch(kcKey);
                operations.multi();
                // 秒杀过程  库存 -1, 秒杀成功的用户添加进清单
                redisTemplate.opsForValue().decrement(kcKey);
                redisTemplate.opsForSet().add(userKey, uid);
                return operations.exec();
            }
        };

        List result = (List) redisTemplate.execute(sessionCallback);

        if (result == null || result.size() == 0) {
            System.out.println("秒杀失败了......");
            return false;
        }

        System.out.println("用户 " + uid + " 参与 " + prodid + " 的秒杀活动参与成功!");
        return true;
    }

在这里插入图片描述

正确代码,监视库存应该放在所有对 Redis 访问或者修改操作之前:

@PostMapping("/test")
    public boolean test2 (String uid, String prodid) {
        // 判空
        if (uid == null || prodid == null) {
            return false;
        }

        // 拼接 key, 库存 key , 秒杀成功用户 key
        String kcKey = "sk:" + prodid + ":qt";
        String userKey = "sk:" + prodid + ":user";


        /*
        * 事务
        * */
        SessionCallback sessionCallback = new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                // 监视库存
                List<String> list = new ArrayList<>();
                list.add(kcKey);
                operations.watch(list);
                Object kc = redisTemplate.opsForValue().get(kcKey);

                // 获取库存, 若库存为 null, 秒杀未开始
                if (kc == null) {
                    System.out.println("秒杀活动未开始!");
                    return null;
                }

                // 判断是否重复秒杀操作
                if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(userKey, uid))) {
                    System.out.println("已经参与秒杀, 不能重复参与!");
                    return null;
                }

                // 判断如果商品数量 < 1, 秒杀结束, (再次获取)
                if ((Integer) kc < 1) {
                    System.out.println("秒杀已经结束!");
                    return null;
                }

                operations.multi();
                // 秒杀过程  库存 -1, 秒杀成功的用户添加进清单
                redisTemplate.opsForValue().decrement(kcKey);
                redisTemplate.opsForSet().add(userKey, uid);
                return operations.exec();
            }
        };

        List result = (List) redisTemplate.execute(sessionCallback);

        if (result == null || result.size() == 0) {
            System.out.println("秒杀失败了......");
            return false;
        }

        System.out.println("用户 " + uid + " 参与 " + prodid + " 的秒杀活动参与成功!");
        return true;
    }

再次测试:
在这里插入图片描述

完美!

6.5 连接超时问题

Q:Redis 不是单线程吗?为什么还需要连接池?

A: https://blog.csdn.net/forBurnInG/article/details/103893680

Q:连接池最大连接数越大越好?

A:https://zhuanlan.zhihu.com/p/396034724

因为我用的 SpringDataRedis,RedisTemplate 默认用的就是连接池,所以不会遇到连接超时问题。

拿 Mysql 举个例子:

不用连接池:
在这里插入图片描述

使用连接池:

在这里插入图片描述

不适用连接池的步骤:

  1. 建立 TCP 连接
  2. 建立数据库连接
  3. 执行语句
  4. 断开数据库连接
  5. 断开 TCP 连接

连接池的作用:

  • 资源重用,节省每次连接服务带来的消耗
  • 更快的响应速度,直接利用了现有可用连接,避免了数据库连接初始化和释放过程的时间开销,从而缩减了系统整体响应时间。

6.6 (使用乐观锁)库存遗留问题

设置商品大一点,然后测试:

在这里插入图片描述

出现了库存遗留问题。

Lua 脚本解决该问题,我再学学,看看有没有其他方法,有的话我再来补充!
https://www.jianshu.com/p/a555facfd6c8

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

哇咔咔负负得正

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值