超卖方案(线程锁)
超卖方案
Java锁
jvm提供了很多锁,它们都可以解决线程安全问题,大致可以分为两类:悲观锁和乐观锁
悲观锁
悲观锁认为当前线程在操作共享资源期间,总会有其它线程修改数据
为了保证线程安全,就要在操作前总是先加锁,操作完成后释放锁
像Java中的synchronized、ReentrantLock都属于悲观锁
乐观锁
乐观锁认为当前线程在操作共享资源期间,不会有其它线程去并发修改数据,所以谁都可以去执行代码
但是为了保证数据的安全性,它会在更新之前先进行一次新老值的比较,即CAS(Compare And Swap)
示例:库存数据对应一个版本,库存每次变化则版本号跟着变化,如下:
| 优惠券活动名称 | 库存 | 版本号 |
|---|---|---|
| 元旦促销活动 | 1 | 1 |
修改库存前拿到库存及对应的版本号:1和1,然后判断库存如果大于0,则将库存减1,然后准备更新库存,更新前先校验版本号
- 如果版本号依旧是1,说明自己在执行的过程没有其它线程去修改过库存,此时将库存更新为0,版本号更新为2
- 如果版本号不再是1,说明自己在执行的过程中,其它线程修改了库存版本,此次更新会被放弃或者重试
分布式锁
上边介绍的锁只控制了单个JVM本身的线程争抢同一个锁,无法控制多个JVM之间争抢同一个锁
如下图,每个JVM进程都有一个Lock锁,但是两个JVM中的线程仍然会同时去修改库存,依旧会出现线程安全问题

这种情况下,就需要使用分布式锁来保证线程安全,它的本质是将锁提取到JVM的外部
以redis为例,看一下分布式锁的使用思路图
Redis原子操作
上面的方案每次操作,都需要远程与Redis交互来获取锁、释放锁,有没有方法避免申请锁与释放锁的交互呢?
这就可以使用Redis的自带的命令来完成扣减库存的操作,比如使用decr命令,它可以完成数字的自减
而且Redis的命令都具有原子性,这就保证了当多个线程去执行decr命令扣减库存时是顺序执行的
但是这里又产生了一个问题,抢券的操作可不是只有一个简单的扣减库存,而是一些列的操作
- 首先查询库存
- 判断库存大小,如果大于0则扣减库存,否则直接返回
- 记录抢券成功的记录,用于后面判断用户不能重复抢券的依据
- 记录抢券同步的记录,用于后面将抢券结果同步到数据库
这样一来就需要执行多条Redis命令,那么整体还是原子性吗?
如果上述四步整体不具有原子性仍然没有办法控制超卖问题,如何保证多个Redis命令具有原子性呢?
使用MULTI实现:这是Redis提供的用来模拟事务的一套命令,它能保证多个命令成为一个原子操作
MULTI HSET key1 field1 value2 field2 value2 INCR key2 EXEC1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- Redis+Lua实现:Lua是一种可嵌入在Redis中执行的脚本语言,可以使用它编写一批Redis命令
- 然后使用eval命令执行Lua脚本,eval是redis的命令本身具有原子性,整个脚本的执行具有原子性

```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 |
|
测试
注入上边定义的DefaultRedisScript,注意注入时指定名称“Lua_test01”
1 | package com.jzo2o.market.service; |
集群注意事项
在redis分片集群下执行lua脚本,它要求脚本中所涉及到的所有key必须要在同一个Redis节点上
否则调用Lua脚本会报错:ERR eval/evalsha command keys must be in same slot
如何保证多个key落地到一个redis节点呢?
解决方法:一次执行Lua脚本的所有key中使用大括号‘{}’且保证大括号中的内容相同即可
1 |
|
执行测试成功,观察redis多了两个key:”test_key01{111}”和”test_key02{111}”
方案总结
抢券的交互流程如下:
1、由预热程序将优惠券活动的库存同步到redis(活动开始将不允许更改库存)
2、活动开始后,抢券程序请求Redis扣减库存,扣减库存成功向抢券成功队列和抢券同步队列写入记录
- 抢券成功队列:记录活动-用户,证明某个活动已经被某个用户抢了,后面用于处理用户限领券的逻辑
- 抢券同步队列:记录用户-活动,证明哪个用户抢了那个活动的券,后面用于处理向数据库保存抢券记录
3、通过定时任务程序根据Redis中同步队列记录的用户抢券结果信息将数据同步到MySQL,具体操作如下:
- 向优惠券表插入用户抢券记录
- 更新优惠券活动表的库存
- 写入数据库完成后删除Redis中同步队列的相应记录

为了让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 |
