什么是超卖?
商品超卖,简单理解就是仓库只有1000个商品,用户却成功下单1000个以上。这种超卖现象,不局限于电商的库存数,还包括其它场景,比如抢红包的预算,抽奖的奖品数等等。
用java来模拟并发下的库存超卖:
//库存数(AtomicInteger原子操作)
public static AtomicInteger stockNum = new AtomicInteger(1000);
//订单数
public static AtomicInteger orderNum = new AtomicInteger(0);
//获取库存
public static int getStockNum() {
try {
//模拟运行耗时
Thread.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
return stockNum.get();
}
//更新库存+i,i<0时表示减库存
public static int updateStockNum(int i) {
try {
//模拟运行耗时
Thread.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
//返回当前库存数
return stockNum.addAndGet(i);
}
//添加订单+1
public static int insertOrder() {
try {
//模拟运行耗时
Thread.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
return orderNum.addAndGet(1);
}
public static void main(String[] args) throws InterruptedException {
//简单模拟1100个人并发抢购
for (int i = 0; i < 1100; i++) {
new Thread(() -> {
//查询库存
int stockNum = getStockNum();
if (stockNum <= 0) {
System.out.println("售罄!!!");
} else {
//减库存
updateStockNum(-1);
//添加订单
insertOrder();
}
}).start();
}
Thread.sleep(5 * 1000);
System.out.println("最终下单数:" + orderNum.get()+" , 库存数:"+stockNum.get());
}
测试多次,最终订单数几乎都超过1000 , 库存出现负数,比如:
...
...
售罄!!!
售罄!!!
售罄!!!
最终下单数:1045 , 库存数:-45
问题出在哪?
情况a:多个线程几乎同时去获取库存,拿到的值一样,当库存充足时不会出现问题,但当线程数 > 库存数,比如5个线程拿到的库存数都为3,然后各自去减库存,最终就可能超卖2个。
情况b:线程获取库存的同时,其它线程正准备或正在修改库存。当库存充足时也不会出现问题,但其他线程将库存数减到了小于当前要去更新库存的线程数时,比如线程T1和T2获取到库存值为3,几乎同时2个其它线程将库存减到了1,然后T1和T2再去减库存的话,最终就可能超卖1个。
该怎么处理?
库存变量虽然使用了AtomicInteger,但它只保证“int值更新”的原子性(N个线程同时去+1,结果始终是+N),类似于redis的incr/decr操作,它们都不能直接保证外层“整个业务操作”的原子性。
加锁怎么样呢?比如java的本地synchronized或者其他分布式锁.
加锁当然是可以的, 但是在高并发场景下,“锁”或多或少是件很影响性能和体验的事,且锁粒度不好控制。
对于上面的代码,我们额外加了一些逻辑判断,即使不加锁, 似乎也可以解决问题,如下:
测试多次,都不会出现超卖:(多线程下请忽略打印顺序)
...
超卖,请回滚!!!
...
售罄!!!
最终下单数:1000 , 库存数:0
代码的核心控制逻辑就是,在更新库存之后,再去实时判断一下库存数,如果超卖了就及时回退。这个判断逻辑得益于updateStockNum() 可以实时返回库存数,而不用再去getStockNum() 查一下,只要去查,就会存在耗时,并发下就会存在不准确性。
实际开发中,库存变量这种业务数据不会像上面的demo那样一直存在本地内存中,我们会使用mysql或者结合redis等数据库。它们各自的更新操作,都可以返回一些实时的操作信息,比如mysql update 操作可以实时返回受影响的行数,借助这些特性和上面代码中的思路,我们可以很方便地控制超卖。
1 ) mysql
商品的库存信息,一般对应于表中的一行记录
DROP TABLE
IF EXISTS stock;
CREATE TABLE stock (
id INT PRIMARY KEY,
num INT NOT NULL
) ENGINE = INNODB;
INSERT INTO stock VALUES (1, 1000);
减库存语句:
UPDATE stock
SET num = num - 1
WHERE
num >= 1 -- 保证库存为0时不执行update
AND id = 1;
上面的sql语句,num>=1 这个条件至关重要,保证了只有库存为正值时才执行update操作,我们就可以利用实时返回的 “受影响的行数” 来判断减库存是否成功。
Java实现:
//减库存,返回mysql update受影响行数
public static int minusStockNum() throws SQLException {
PreparedStatement ps = null;//略
int reply=ps.executeUpdate(" UPDATE stock SET num = num - 1 WHERE num >= 1 AND id = 1 ");
return reply;
}
public static void main(String[] args) throws InterruptedException {
//简单模拟1100个人并发抢单
for(int i=0;i<1100;i++){
new Thread(()->{
int stockNum=getStockNum();
if(stockNum<=0){
System.out.println("售罄!!!");
}else{
//减库存,拿到mysql update 受影响行数
int reply=minusStockNum();
//判断是否为0
if(reply==0){
System.out.println("超卖 !!!");
}else{
//减库存成功,可以下单
insertOrder();
}
}
}).start();
}
}
如果实时返回的“受影响的行数” reply==0,则表示num>=1不成立,即商品已售罄。在并发下,可能reply==0 这个代码分支会进来多次,无外乎“浪费了”几次数据库请求,但不需要进行库存回退操作,因为当前更新没有最终执行。
2 ) redis
redis读写操作的原子性,效果类似于java.util.concurrent.atomic下面的原子类,前者是单线程模式,天生具有原子性,后者则利用了CAS算法。
基于redis的库存控制,下面列举2种方法:
2.1)使用redis的incrby
实现逻辑和最上面的AtomicInteger几乎一样:
//redis池
private static JedisPool jedisPool = null;
//订单数
public static AtomicInteger orderNum = new AtomicInteger(0);
//初始化库存池
public static void initStockNum(int num) {
Jedis jedis = jedisPool.getResource();
try {
jedis.set("stockNum", String.valueOf(num));
} catch (JedisException e) {
e.printStackTrace();
} finally {
jedis.close();
}
}
//获取库存
public static long getStockNum() {
Jedis jedis = jedisPool.getResource();
long currentNum = 0;
try {
currentNum = Long.valueOf(jedis.get("stockNum"));
} catch (JedisException e) {
e.printStackTrace();
} finally {
jedis.close();
}
return currentNum;
}
//更新库存+i,i<0时表示减库存
public static long updateStockNum(int i) {
Jedis jedis = jedisPool.getResource();
long currentNum = 0;
try {
currentNum = jedis.incrBy("stockNum", i);
} catch (JedisException e) {
e.printStackTrace();
} finally {
jedis.close();
}
return currentNum;
}
//添加订单
public static int insertOrder() {
try {
//模拟运行耗时
Thread.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
return orderNum.addAndGet(1);
}
public static void main(String[] args) throws InterruptedException {
//连接池
jedisPool = new JedisPool(new GenericObjectPoolConfig(), "192.168.1.2", 6379, 2000, "password");
//初始化库存
initStockNum(1000);
//简单模拟1100个人并发抢购
for (int i = 0; i < 1100; i++) {
new Thread(() -> {
//查询库存
long stockNum = getStockNum();
if (stockNum <= 0) {
System.out.println("售罄!!!");
} else {
/**
* 减库存操作,实时拿到现在的库存数,如果<0,则说明超卖了,需要回滚
*/
long currentNum = updateStockNum(-1);
if (currentNum < 0) {
System.out.println("超卖,请回滚!!!");
//回退库存
updateStockNum(1);
} else {
//添加订单
insertOrder();
}
}
}).start();
}
Thread.sleep(5 * 1000);
System.out.println("最终下单数:" + orderNum.get() + " , 库存数:" + getStockNum());
}
2.2)使用redis的列表
比如库存10,则lpush10个值,然后依次lpop,当lpop返回空nil,说明没有库存了:
lpush stockList 1 1 1 1 1 1 1 1 1 1
lpop stockList 1 //第1个人返回1
lpop stockList 1 //第2个人返回1
...
lpop stockList 1 //第11个人返回nil
lpop stockList 1 //第12个人返回nil
Java实现:
//redis池
private static JedisPool jedisPool = null;
//订单数
public static AtomicInteger orderNum = new AtomicInteger(0);
//初始化库存池
public static void initStockNum(int num) {
Jedis jedis = jedisPool.getResource();
try {
String[] flags = new String[num];
for (int i = 0; i < num; i++) {
flags[i] = String.valueOf(i);
}
jedis.del("stockList");
jedis.lpush("stockList", flags);
} catch (JedisException e) {
e.printStackTrace();
} finally {
jedis.close();
}
}
//获取库存
public static long getStockNum() {
Jedis jedis = jedisPool.getResource();
long currentNum = 0;
try {
currentNum = jedis.llen("stockList");
} catch (JedisException e) {
e.printStackTrace();
} finally {
jedis.close();
}
return currentNum;
}
//库存加减1,返回受影响的个数
public static int updateOneStockNum(int i) {
Jedis jedis = jedisPool.getResource();
int reply = 0;
try {
if (i == 1) {
System.out.println("库存+1");
jedis.lpush("stockList", "1");
reply=1;
} else if (i == -1) {
System.out.println("库存-1");
if(jedis.lpop("stockList")!=null){
reply=1;
}
}
} catch (JedisException e) {
e.printStackTrace();
} finally {
jedis.close();
}
return reply;
}
//添加订单
public static int insertOrder() {
try {
//模拟运行耗时
Thread.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
return orderNum.addAndGet(1);
}
public static void main(String[] args) throws InterruptedException {
//连接池
jedisPool = new JedisPool(new GenericObjectPoolConfig(), "192.168.1.2", 6379, 2000, "password");
//初始化库存
initStockNum(1000);
//简单模拟1100个人并发抢购
for (int i = 0; i < 1100; i++) {
new Thread(() -> {
//查询库存
long stockNum = getStockNum();
if (stockNum <= 0) {
System.out.println("售罄!!!");
} else {
/**
* 减库存操作,拿到redis lpop受影响的个数,如果==0,则说明售罄,这个和mysql类似不需要回退
*/
long reply= updateOneStockNum(-1);
if (reply == 0) {
System.out.println("售罄!!!");
} else {
//添加订单
insertOrder();
}
}
}).start();
}
Thread.sleep(5 * 1000);
System.out.println("最终下单数:" + orderNum.get() + " , 库存数:" + getStockNum());
}
比较2.1,好处是不存在回退库存的操作了,但如果库存数较大,比如在list放一万个1,会不会很臃肿?