文章目录



  • 1 Redis中的事务&简单使用
  • 1.1 Redis中事务的定义
  • 1.2 Multi、Exec、discard
  • 1.3 事务的错误处理
  • 2 事务冲突 乐观锁&悲观锁
  • 2.1 事务冲突问题
  • 2.2 悲观锁&乐观锁
  • 2.2.1 悲观锁
  • 2.2.2 乐观锁
  • 2.2.3 乐观锁在Redis中的使用
  • 2.2.4 Redis中的事务特性
  • 3 秒杀案例
  • 3.1 单机模拟
  • 3.2 考虑并发
  • 3.2.1 连接超时问题
  • 3.2.2 超卖问题
  • 3.2.3 库存遗留问题


1 Redis中的事务&简单使用

1.1 Redis中事务的定义

Redis中的事务是一个单独的隔离操作
事务中的所有命令都会序列化,按顺序地执行
事务在执行的过程中,不会被其他客户端发送来的命令请求所打断

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

1.2 Multi、Exec、discard

在输入multi命令后,输入的所有命令都会依次进入命令队列中,但不会执行

直到输入exec后,Redis会将之前命令队列中的命令依次执行,组队的过程中可使用discard来放弃组队

输入exec后事务中的命令会自动执行

redis 死锁的原因_redis 死锁的原因


redis 死锁的原因_乐观锁_02

1.3 事务的错误处理

当使用multi组队事务时,也可能出现失败的情况

1,组队过程中输入了错误的命令导致组队失败,整个命令队列被取消,一个都不执行

redis 死锁的原因_缓存_03


2,组队结束后使用exec其中有一条或多条命令执行失败,则报错的命令不执行,其他的正常执行

redis 死锁的原因_redis_04

2 事务冲突 乐观锁&悲观锁

2.1 事务冲突问题

考虑如下场景,有很多人知道你的网购账户和密码,同时参加某平台的抢购活动,账户内余额10000元

A想购买8000元商品,B想购买5000元商品,C想购买1000元商品

如果三人在同一时间购买商品,可能会出现如下情况:

redis 死锁的原因_redis 死锁的原因_05


为此对其解决方案是加锁,采用乐观锁/悲观锁来解决

2.2 悲观锁&乐观锁

2.2.1 悲观锁

悲观锁:每次拿数据时先加锁,拿不到的阻塞等待

redis 死锁的原因_redis_06

A采用悲观锁先给10000元加锁,此时B和C不能修改余额,只能等待A操作结束释放锁后才能操作
接着A对余额扣除8000并释放锁,B得到锁发现余额不足购买失败释放锁,C得到锁发现余额充足购买成功释放锁
最终余额为1000元,B购买商品失败

悲观锁认为每次去拿数据时别人都会修改,所以每次拿数据时都会上锁
这样	其他人尝试拿数据时就会阻塞,传统的关系数据库中大量使用悲观锁,如行锁,表锁,读锁,写锁等 都是操作前先上锁
但其效率较低,多人操作时会出现多等一阻塞的情况

2.2.2 乐观锁

乐观锁:不加锁,通过版本号等机制check数据是否被更新过

对余额10000加versionv1.0,ABC三人读取余额10000元且版本是1.0
当A操作发现余额是10000,此时余额充足购买后对余额-8000,且将版本更新为1.1
轮到B时B检查自己读到的数据和DB中的数据版本是否相等,发现不一致更新自己拿到的余额1.1版本为2000
B发现余额不足购买失败,C操作时检查版本不一致,更新版本为1.1余额200购买成功,余额-1000且更新版本为1.2

redis 死锁的原因_redis 死锁的原因_07

乐观锁:不上锁但每次更新数据前会判断有没有人更新了该数据,通过版本号等机制
乐观锁适用于多读的场景,可以提高吞吐,Redis就是利用这种版本号check-and-set机制来实现事务机制的
抢票秒杀等场景都适用乐观锁

2.2.3 乐观锁在Redis中的使用

在执行multi之前,先执行watch key1 [key2]… 可以监视一或多个key
如果事务在执行之前这些key被其他命令更新,则本次事务失败

1,打开客户端A set k1 100 并watch k1
2,打开客户端B watch k1
3,客户端A开启事务multi 对k1的值加10
4,客户端B开启事务multi 对k1的值加20
5,客户端A的事务队列exec 执行成功
6,客户端B的事务队列exec 执行失败

redis 死锁的原因_缓存_08


watch key命令用来监视key,也可以使用unwatch key取消对key的监视
在执行了watch后exec或者discard被执行后,就不需要再执行unwatch

2.2.4 Redis中的事务特性

Redis不支持ACID,但有其自己的事务特性

1,单独的隔离机制
事务中的所有命令都会序列化,按顺序地执行
事务执行的过程中不会被其他客户端发来的命令请求打断

2,没有隔离级别的概念
multi队列中的命令没有exec前不会实际执行,事务提交前任何指令都不会被实际执行

3,不保证原子性
事务中如果有一条命令执行失败,其他命令仍然会执行,且没有回滚

3 秒杀案例

3.1 单机模拟

在商品秒杀的场景中,我们需要两个映射来反映秒杀的状况

mapper1: 商品id->库存个数

mapper2: 商品id->抢到者id的List

秒杀开始后,每当有一个人抢到商品,mapper1中商品的库存数量-1

mapper2中商品对应的抢到者List增添该用户id

redis 死锁的原因_乐观锁_09


接下来用redis来模拟这个过程,判断用户秒杀是否成功

// 模拟秒杀过程
    public static Boolean doSecKill(String uid, String prodid) {
        if(uid == null || prodid == null) {
            return Boolean.FALSE;
        }

        // 1,连接redis
        Jedis jedis = new Jedis("127.0.0.1", 6379);

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

        // 3,检查商品库存 库存为null 秒杀还未开始
        String kc = jedis.get(kcKey);
        if(kc == null) {
            System.out.println("秒杀尚未开始 请等待");
            jedis.close();
            return Boolean.FALSE;
        }

        // 4,判断用户是否已秒杀成功 一人最多成功一次
        if(jedis.sismember(userKey, uid)) {
            System.out.println("一人最多秒杀成功一次");
            jedis.close();
            return Boolean.FALSE; 
        }
        
        // 5,判断库存数是否充足 库存为0时秒杀结束
        if(Integer.parseInt(kc) == 0) {
            System.out.println("秒杀已结束 商品售空");
            jedis.close():
            return Boolean.FALSE; 
        }
        
        // 6,开始秒杀 库存-1 将秒杀成用户id加入秒杀成功用户id list
        jedis.decr(kcKey);
        jedis.sadd(userKey, uid);
        
        System.out.println("秒杀成功");
        jedis.close();
        return Boolean.TRUE;
    }

上述的例子只是大致实现一下秒杀的思路,实际上所有的秒杀功能都必须考虑并发调用下的可用性和数据一致性

3.2 考虑并发

考虑三个人同一个账号购买商品的例子,不加锁没有事务,秒杀结束时会出现负数库存和超出限定个数的秒杀成功者的情况,而且还需要考虑连接超时等问题…

3.2.1 连接超时问题

用户每请求一次秒杀 创建一个Jedis对象将请求打到redis服务器上 后到的请求需要排队等待被处理
长时间未处理时,本次连接超时,用户的秒杀请求失败 且多次创建Jedis对象是一种浪费

对于连接超时问题,可以采用连接池来解决,其功能类似数据库连接池

public static JedisPool getJedisPool() {
        if(jedisPool == null) {
            synchronized (JedisPoolUtil.class) {
                if(jedisPool == null) {
                    JedisPoolConfig poolConfig = new JedisPoolConfig();
                    poolConfig.setMaxTotal(200);
                    poolConfig.setMaxIdle(32); 
                    poolConfig.setMaxWaitMillis(100*1000);
                    poolConfig.setBlockWhenExhausted(true);
                    poolConfig.setTestOnBorrow(true);

                    jedisPool = new JedisPool(poolConfig, "127.0.0.1", 6379, 60000);
                }
            }

        }

        return jedisPool;
    }

有了连接池,就可以在代码中使用以替代直接连接的方式

//  直接连接redis
Jedis jedis = new Jedis("127.0.0.1", 6379);

// 使用连接池连接redis
JedisPoolUtil jedisPool = JedisPool.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();

3.2.2 超卖问题

Redis中没有使用事务时,多请求操作同一个K对应的数据,极易导致数据混乱

redis 死锁的原因_缓存_10


采用乐观锁watch监控住库存的value,并将秒杀过程放入multi队列处理

public static Boolean doSecKill(String uid, String prodid) {
        if(uid == null || prodid == null) {
            return Boolean.FALSE;
        }

        // 1,连接redis
        JedisPoolUtil jedisPool = JedisPool.getJedisPoolInstance();
        Jedis jedis = jedisPoolInstance.getResource();

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

        // 采用乐观锁监视库存
        jedis.watch(kcKey);

        // 3,检查商品库存 库存为null 秒杀还未开始
        String kc = jedis.get(kcKey);
        if(kc == null) {
            System.out.println("秒杀尚未开始 请等待");
            jedis.close();
            return Boolean.FALSE;
        }

        // 4,判断用户是否已秒杀成功 一人最多成功一次
        if(jedis.sismember(userKey, uid)) {
            System.out.println("一人最多秒杀成功一次");
            jedis.close();
            return Boolean.FALSE;
        }

        // 5,判断库存数是否充足 库存为0时秒杀结束
        if(Integer.parseInt(kc) == 0) {
            System.out.println("秒杀已结束 商品售空");
            jedis.close():
            return Boolean.FALSE;
        }

        // 6,使用事务开始秒杀 库存-1 将秒杀成用户id加入秒杀成功用户id list
        Transaction multi = jedis.multi();
        multi.decr(kcKey);
        multi.sadd(userKey, uid);
        List<Object> res = multi.execute();

        // 对结果集执行结果进行判断
        if(res == null || res.size() == 0) {
            System.out.println("秒杀失败");
            jedis.close();
            return Boolean.FALSE;
        }

        System.out.println("秒杀成功");
        jedis.close();
        return Boolean.TRUE;
    }

3.2.3 库存遗留问题

秒杀还可能出现这样的问题,库存设置为500当整个秒杀快结束时,后到的用户发出请求时发现失败
但此时的库存却还未到0,这就是库存遗留问题,以为卖完了,其实没卖完,出现这样的状况是由于乐观锁导致的

开始时使用乐观锁watch了库存数值时,此时的库存数据版本是1.0
当秒杀快结束时,有10个人读取到了当前库存值10 版本5.0
假设第一个人的秒杀请求先处理,库存变为9,版本号变为5.1
其他9个人发秒杀请求想改库存数据时,却发现版本号对不上,无法修改库存数
此时秒杀时间结束,就出现了库存仍有遗留的问题
这样的问题很容易想到死锁解决,但redis中并不支持死锁

对此的解决方案可以采用Lua脚本实质上是Redis利用其单线程的特性,用任务队列的方式解决多任务并发问题

将库存-1和将id加入成功者队列的操作使用Lua脚本一次性提交给Redis执行
Lua脚本类似redis事务,有一定原子性,不会被其他命令插队