超卖方案

Java锁

jvm提供了很多锁,它们都可以解决线程安全问题,大致可以分为两类:悲观锁和乐观锁

悲观锁

悲观锁认为当前线程在操作共享资源期间,总会有其它线程修改数据

为了保证线程安全,就要在操作前总是先加锁,操作完成后释放锁

像Java中的synchronized、ReentrantLock都属于悲观锁

乐观锁

乐观锁认为当前线程在操作共享资源期间,不会有其它线程去并发修改数据,所以谁都可以去执行代码

但是为了保证数据的安全性,它会在更新之前先进行一次新老值的比较,即CAS(Compare And Swap)

示例:库存数据对应一个版本,库存每次变化则版本号跟着变化,如下:

优惠券活动名称 库存 版本号
元旦促销活动 1 1

修改库存前拿到库存及对应的版本号:1和1,然后判断库存如果大于0,则将库存减1,然后准备更新库存,更新前先校验版本号

  • 如果版本号依旧是1,说明自己在执行的过程没有其它线程去修改过库存,此时将库存更新为0,版本号更新为2
  • 如果版本号不再是1,说明自己在执行的过程中,其它线程修改了库存版本,此次更新会被放弃或者重试

分布式锁

上边介绍的锁只控制了单个JVM本身的线程争抢同一个锁,无法控制多个JVM之间争抢同一个锁

如下图,每个JVM进程都有一个Lock锁,但是两个JVM中的线程仍然会同时去修改库存,依旧会出现线程安全问题

6252ef38-74c7-4def-979c-c65e0746a3ed

这种情况下,就需要使用分布式锁来保证线程安全,它的本质是将锁提取到JVM的外部

以redis为例,看一下分布式锁的使用思路图

whiteboard_exported_image (4)

Redis原子操作

上面的方案每次操作,都需要远程与Redis交互来获取锁、释放锁,有没有方法避免申请锁与释放锁的交互呢?

这就可以使用Redis的自带的命令来完成扣减库存的操作,比如使用decr命令,它可以完成数字的自减

而且Redis的命令都具有原子性,这就保证了当多个线程去执行decr命令扣减库存时是顺序执行的

whiteboard_exported_image (5)

但是这里又产生了一个问题,抢券的操作可不是只有一个简单的扣减库存,而是一些列的操作

  1. 首先查询库存
  2. 判断库存大小,如果大于0则扣减库存,否则直接返回
  3. 记录抢券成功的记录,用于后面判断用户不能重复抢券的依据
  4. 记录抢券同步的记录,用于后面将抢券结果同步到数据库

这样一来就需要执行多条Redis命令,那么整体还是原子性吗?

whiteboard_exported_image (6)

如果上述四步整体不具有原子性仍然没有办法控制超卖问题,如何保证多个Redis命令具有原子性呢?

  • 使用MULTI实现:这是Redis提供的用来模拟事务的一套命令,它能保证多个命令成为一个原子操作

    • MULTI
      HSET key1 field1 value2 field2 value2
      INCR key2
      EXEC
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15

      - Redis+Lua实现:Lua是一种可嵌入在Redis中执行的脚本语言,可以使用它编写一批Redis命令

      - 然后使用eval命令执行Lua脚本,eval是redis的命令本身具有原子性,整个脚本的执行具有原子性

      ![c71bfc5e-261e-493d-b7f3-c2e73343205d](/img/云岚到家assets/c71bfc5e-261e-493d-b7f3-c2e73343205d.png)

      ```Lua
      eval
      "
      local ret = redis.call('hset', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
      redis.call('incr', KEYS[2]);
      return ret..'';
      "
      2 test_key01 test_key02 field1 aa field2 bb

总结

解决超卖问题有哪些方案?

  • 单机环境下,可以使用JVM悲观锁和乐观锁解决
  • 分布式环境下,可以使用分布式锁解决,也可以使用Redis+lua实现原子操作来解决

Java调用Lua

语法

下边是使用RedisTemplate执行Lua脚本的方法:

1
<T> T execute(RedisScript<T> script, List<K> keys, Object... args)

第一个参数是要执行的Lua脚本,RedisScript的实现类是DefaultRedisScript,它需要脚本的字符串或位置进行构建

1
2
3
4
5
6
7
8
@Bean("Lua_test01")
public DefaultRedisScript<Integer> getLuaTest01() {
DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
//resource目录下的scripts文件下的Lua_test01.Lua文件
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/Lua_test01.Lua")));
redisScript.setResultType(Integer.class);
return redisScript;
}

测试

注入上边定义的DefaultRedisScript,注意注入时指定名称“Lua_test01”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.jzo2o.market.service;

@SpringBootTest
@Slf4j
public class RedisLuaTest {

@Resource(name = "redisTemplate")
RedisTemplate redisTemplate;

@Resource(name = "Lua_test01")
DefaultRedisScript script;

//测试Lua
@Test
public void test_Luafirst() {
//参数1:key ,key1:test_key01 key2:test_key02
List<String> keys = Arrays.asList("test_key01","test_key02");
//参数2:传入Lua脚本的参数,"field1","aa","field2", "bb"
Object result = redisTemplate.execute(script, keys, "field1","aa","field2", "bb");
log.info("执行结果:{}",result);
}
}

集群注意事项

在redis分片集群下执行lua脚本,它要求脚本中所涉及到的所有key必须要在同一个Redis节点上

否则调用Lua脚本会报错:ERR eval/evalsha command keys must be in same slot

如何保证多个key落地到一个redis节点呢?

解决方法:一次执行Lua脚本的所有key中使用大括号‘{}’且保证大括号中的内容相同即可

1
2
3
4
5
6
7
8
@Test
public void test_Luafirst2() {
//参数1:key ,key1:test_key01
List<String> keys = Arrays.asList("test_key01{111}","test_key02{111}");
//参数2:传入Lua脚本的参数,"field1","aa","field2", "bb"
Object result = redisTemplate.execute(script, keys, "field1","aa","field2", "bb");
log.info("执行结果:{}",result);
}

执行测试成功,观察redis多了两个key:”test_key01{111}”和”test_key02{111}”

方案总结

抢券的交互流程如下:

1、由预热程序将优惠券活动的库存同步到redis(活动开始将不允许更改库存)

2、活动开始后,抢券程序请求Redis扣减库存,扣减库存成功向抢券成功队列抢券同步队列写入记录

  • 抢券成功队列:记录活动-用户,证明某个活动已经被某个用户抢了,后面用于处理用户限领券的逻辑
  • 抢券同步队列:记录用户-活动,证明哪个用户抢了那个活动的券,后面用于处理向数据库保存抢券记录

3、通过定时任务程序根据Redis中同步队列记录的用户抢券结果信息将数据同步到MySQL,具体操作如下:

  • 向优惠券表插入用户抢券记录
  • 更新优惠券活动表的库存
  • 写入数据库完成后删除Redis中同步队列的相应记录

8512786b-d698-4f20-9077-56b548a8a865

whiteboard_exported_image (7)

为了让Redis中每个队里中的数据不至于非常多,我们在当前项目中对抢券时用到的三个队列做了优化

那就是将每个对列拆成10个,这样的话,每个队列中的数据就只有原来的十分之一了,具体设计如下:

缓存内容 结构类型 Key HashKey Value
优惠券活动库存 Hash COUPON:RESOURCE:STOCK:{活动id%10} 活动id 库存数
抢券成功队列 Hash COUPON:SEIZE:LIST:活动id_{活动id%10} 用户id 1
抢券同步队列 Hash QUEUE:COUPON:SEIZE:SYNC:{活动id%10} 用户id 活动id

bdbb79f2-7c5d-43b7-b842-f7787e315352