在一个系统中,有并发的购买请求,如果不加以控制,会出现数量超卖或者库存数量对不上购买数量,造成数据紊乱,接下来,介绍几种防止库存超卖的方式
关于并发操作的模拟工具可以使用jmeter实现,使用方式可以浏览文章
github代码链接
目录结构
数据库表的设计
简单两张表,一张商品表t_good,一张订单表 t_order
CREATE TABLE `t_good` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`good_name` varchar(20) CHARACTER SET utf8 DEFAULT NULL COMMENT '商品名称',
`stock` int(11) DEFAULT NULL COMMENT '库存',
`version` int(11) DEFAULT NULL COMMENT '版本',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=latin1 COMMENT='商品'
CREATE TABLE `t_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`order_number` varchar(64) DEFAULT NULL COMMENT '订单编号',
`user_id` bigint(20) DEFAULT NULL COMMENT '用户id',
`good_id` bigint(20) DEFAULT NULL COMMENT '商品id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1538 DEFAULT CHARSET=latin1 COMMENT='订单'
一般的实现中,service实现代码
public Result<String> buyGood0(Long userId, Long goodId) throws Exception {
Good good = goodDao.find(goodId);
if (good == null) {
return Result.jsonStringError("不存在该商品", ApiConstants.ERROR100);
}
List<Order> orderList = orderDao.findByParams(Paramap.create().put("userId", userId).put("goodId", goodId));
if (!CollectionUtils.isEmpty(orderList)) {
return Result.jsonStringError("你已经抢购成功,请勿重复购买", ApiConstants.ERROR200);
}
if (good.getStock() <= 0) {
return Result.jsonStringError("该商品库存不足", ApiConstants.ERROR300);
}
saveOrder(userId, goodId);
return Result.jsonStringOk();
}
private void saveOrder(Long userId, Long goodId) {
Good good = goodDao.find(goodId);
good.setStock(good.getStock() - 1);
Long flag = goodDao.update(good);
if(flag>0){
Order order = new Order();
order.setOrderNumber("");
order.setUserId(userId);
order.setGoodId(goodId);
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
orderDao.insert(order);
}
}
如果我们使用jmeter对该代码发起并发购买请求,1秒内发起100个请求
设定商品库存为100
结果:在插入100条订单记录(即卖出数量为100),库存数量为87
基于数据库级别的乐观锁和悲观锁
乐观锁的实现
乐观锁顾名思义就是在操作时很乐观,认为操作不会产生并发问题(不会有其他线程对数据进行修改),因此不会上锁 ,在查询、更新数据记录的时候带上一个标识字段version,如果查询出来version和带上的version值相同,则更新数据记录
把saveOrder方法改造成
Good good = goodDao.find(goodId);
Long flag = goodDao.updateStock(good);
if (flag > 0) {
Order order = new Order();
order.setOrderNumber("");
order.setUserId(userId);
order.setGoodId(goodId);
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
orderDao.insert(order);
} else {
throw new ApiException("抢购失败,请重新操作", ApiConstants.ERROR400);
}
其中updateStock方法的语句
UPDATE T_GOOD
<set>
<if test="stock != null ">
stock = stock-1,
</if>
<if test="version != null ">
version = version+1,
</if>
</set>
WHERE ID = #{id,jdbcType=BIGINT} and version=#{version,jdbcType=INTEGER} and stock>0
以500个线程并发执行接口,发现结果如下,并没有发现超卖问题,但是同一时间请求太多,导致许多请求失败
悲观锁的实现
每次获取数据的时候,都会担心数据被修改,所以每次获取数据的时候都会进行加锁,确保在自己使用的过程中数据不会被别人修改,使用完成后进行数据解锁。由于数据进行加锁,期间对该数据进行读写的其他线程都会进行等待。
在mysql中用语句实现如下
select * from table where 索引限制 for update
锁的释放
-
非事务中,语句执行完毕,立即释放锁
-
行锁在事务中,只有等当前事务进行了commit or rollback 操作才能释放锁
把saveOrder修改为
private void saveOrder2(Long userId, Long goodId) throws Exception {
Good good = goodDao.findGoodForUpdate(goodId);
good.setStock(good.getStock() - 1);
Long flag = goodDao.update(good);
if (flag > 0) {
Order order = new Order();
order.setOrderNumber("");
order.setUserId(userId);
order.setGoodId(goodId);
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
orderDao.insert(order);
} else {
throw new ApiException("抢购失败,请重新操作", ApiConstants.ERROR400);
}
}
其中findGoodForUpdate方法的sql语句如下
SELECT * FROM T_GOODWHERE ID=#{id,jdbcType=BIGINT} for update
用jmeter以500个线程并发执行接口,发现结果如下,并没有发现超卖问题,并且库存为0
基于redis的原子操作
redis的原子操作可以实现分布式锁的功能,因为其是单线程。可以使用命令setnx 和expire,将资源标识作为key,比如商品id,每次只有redis中不存在其值时才设置成功,如果设置成功后,再加上有效时间;但如果在设置成功后,获取锁的进程崩溃,那就成了死锁,可以在设置值成功时顺便设置过期时间,就不会产生该问题了
在2.6.12版本开始,redis为SET命令增加了一系列选项,支持在设置值成功时顺便设置过期时间
SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]
SET命令支持一组修改其行为的选项:
EX seconds-设置指定的到期时间,以秒为单位。
PX毫秒-设置指定的到期时间(以毫秒为单位)。
NX-仅在不存在的情况下设置密钥。
XX-仅设置密钥(如果已存在)。
KEEPTTL-保留与密钥关联的生存时间。
代码实现
public Result<String> buyGood3(Long userId, Long goodId) throws Exception {
String lockkey = "miaosha:goodId:" + goodId;
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockkey, "1", 1, TimeUnit.SECONDS);
if (success) {
Good good = goodDao.find(goodId);
if (good == null) {
return Result.jsonStringError("不存在该商品", ApiConstants.ERROR100);
}
List<Order> orderList = orderDao.findByParams(Paramap.create().put("userId", userId).put("goodId", goodId));
if (!CollectionUtils.isEmpty(orderList)) {
return Result.jsonStringError("你已经抢购成功,请勿重复购买", ApiConstants.ERROR200);
}
if (good.getStock() <= 0) {
return Result.jsonStringError("该商品库存不足", ApiConstants.ERROR300);
}
saveOrder(userId, goodId);
} else {
return Result.jsonStringError("抢购失败,请重新操作", ApiConstants.ERROR400);
}
return Result.jsonStringOk();
}
以500个线程并发执行接口,发现结果如下,并没有发现超卖问题,但同一时间并发量大 ,购买成功的并不多
基于开源框架Redisson的分布式锁
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格,Redisson提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务
以下为分布式锁的可重入实现,指在高并发产生多线程时,如果当前线程不能获得分布式锁,并不会立即被抛弃”抛弃“,而是等待设定的一段时间,重新尝试去获取分布式锁,如果可以获取成功,则执行后续业务代码;如果不能获取锁,而且重试的时间达到上限,则意味着该线程执行完毕
/**
* 尝试获取锁
*
* @param lockKey
* @param unit 时间单位
* @param waitTime 最多等待时间
* @param leaseTime 上锁后自动释放锁时间
* @return
*/
public static boolean tryLock(String lockKey, TimeUnit unit, int waitTime, int leaseTime) {
RLock lock = redissonClient.getLock(lockKey);
try {
return lock.tryLock(waitTime, leaseTime, unit);
} catch (InterruptedException e) {
return false;
}
}
/**
* 释放锁
*
* @param lockKey
*/
public static void unlock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
/**
* 在解锁之前先判断要解锁的key是否已被锁定并且是否被当前线程保持。 如果满足条件时才解锁
*/
if (lock.isLocked()) {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
业务代码的实现
@Transactional
@Override
public Result<String> buyGood4(Long userId, Long goodId) throws Exception {
boolean flag = false;
flag = RedissLockUtil.tryLock("miaosha:goodId:redisson:"+goodId, TimeUnit.SECONDS, 3, 10);
try {
if (flag) {
Good good = goodDao.find(goodId);
if (good == null) {
return Result.jsonStringError("不存在该商品", ApiConstants.ERROR100);
}
List<Order> orderList = orderDao.findByParams(Paramap.create().put("userId", userId).put("goodId", goodId));
if (!CollectionUtils.isEmpty(orderList)) {
return Result.jsonStringError("你已经抢购成功,请勿重复购买", ApiConstants.ERROR200);
}
if (good.getStock() <= 0) {
return Result.jsonStringError("该商品库存不足", ApiConstants.ERROR300);
}
saveOrder(userId, goodId);
} else {
return Result.jsonStringError("抢购失败,请重新操作", ApiConstants.ERROR400);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (flag) {
RedissLockUtil.unlock("miaosha:goodId:redisson:" + goodId);
}
}
return Result.jsonStringOk();
}
以500个线程并发执行接口,结果如下,在等待时间为3秒、获取锁后10秒后的可重入锁,没有发生超卖问题
基于Zookeeper的互斥排他锁
ZooKeeper 是一个典型的分布式数据一致性解决方案,分布式应用程序可以基于 ZooKeeper 实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。
在此使用Zookeeper实现分布式锁,从而控制多线程对共享资源的并发访问
@Transactional
@Override
public Result<String> buyGood5(Long userId, Long goodId) throws Exception {
InterProcessMutex mutex = new InterProcessMutex(client, pathPrefix + goodId + "-lock");
try {
/**
* 采用互斥锁组件尝试获取分布式锁-其中尝试的最大时间在这里设置为15s
*/
if (mutex.acquire(15L, TimeUnit.SECONDS)) {
Good good = goodDao.find(goodId);
if (good == null) {
return Result.jsonStringError("不存在该商品", ApiConstants.ERROR100);
}
List<Order> orderList = orderDao.findByParams(Paramap.create().put("userId", userId).put("goodId", goodId));
if (!CollectionUtils.isEmpty(orderList)) {
return Result.jsonStringError("你已经抢购成功,请勿重复购买", ApiConstants.ERROR200);
}
if (good.getStock() <= 0) {
return Result.jsonStringError("该商品库存不足", ApiConstants.ERROR300);
}
saveOrder(userId, goodId);
} else {
return Result.jsonStringError("抢购失败,请重新操作", ApiConstants.ERROR400);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
mutex.release();
}
return Result.jsonStringOk();
}
以500个线程并发执行接口,结果如下
总结
关于避免超卖的实现,介绍几种实现方式,有基于数据库级别的乐观锁和悲观锁、基于redis的原子性操作、基于开源框架Redisson的分布式锁和基于Zookeeper的互斥排他锁,根据不同的业务需求采用不同的方法。在高并发的情况下,大量的数据库读写对性能和DB都有很大的压力,一般会使用redis、zookeeper等进行协助。