博客地址: 手摸手带你写项目----秒杀系统(二)
所有文章会第一时间在博客更新!
上一节我们提到了如果不对代码进行加锁,那么线程并发量上来之后,一定会出现超卖问题。那么这一节我们主要介绍一下如何通过加锁来解决超卖问题。
1. 用悲观锁解决商品超卖
说到加锁,那么大部分同学第一时间想到的肯定是synchronized,在JDK1.8中对synchronized也进行了很大程度的优化,我在之前的文章中也总结了有关synchronized的知识点,感兴趣的同学可以了解一下。
那么在这里,我们应该在哪里加锁呢?有两种选择,第一种选择是在Service层加锁,第二种是在Controller层加锁。
1.1 在Service层使用synchronized
如果我们在service层加锁,那么肯定也是在添加订单信息时加锁,更改后的代码如下:
package cn.codinglemon.demo.service.impl;
import cn.codinglemon.demo.dao.StockDao;
import cn.codinglemon.demo.dao.StockOrderDao;
import cn.codinglemon.demo.entity.Stock;
import cn.codinglemon.demo.entity.StockOrder;
import cn.codinglemon.demo.service.StockOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* @author zry
* @date 2021-9-29 18:49
*/
@Service
public class StockOrderServiceImpl implements StockOrderService {
@Autowired
private StockOrderDao stockOrderDao;
@Autowired
private StockDao stockDao;
//注意这里对createOrder方法进行了加锁
@Override
@Transactional(propagation = Propagation.REQUIRED)
public synchronized StockOrder createOrder(StockOrder stockOrder) {
Stock stock = stockDao.selectById(stockOrder.getSid());
//查看能否找到商品
if( stock != null){
boolean changeStock = stockDao.sale(stockOrder.getSid(),stockOrder.getCount()+stock.getSale()) > 0;
//判断更新库存成功
if(changeStock){
boolean result = stockOrderDao.createOrder(stockOrder) >0;
//判断订单是否成功存入数据库
if(result){
return stockOrder;
}
}
}
return null;
}
}
那我们来使用JMeter测试一下是否加锁成功(注意先把订单表清空,将stock表中sale值改为0,JMeter中依旧为1000个线程):
等待JMeter运行结束后我们先看一下数据库的库存表,
库存表中已售为250没问题,但是我们再看订单表时,问题就出现了:
我们发现,居然有258条记录,这就奇怪了,我们明明加锁了,为什么还是发生了超卖???
其实问题出在这里:
我们知道Service层中对于insert、delete、update等操作是必须添加事务的,那么事务也会存在线程同步的问题,并且事务包含的范围是大于我们在方法上添加的synchronized的锁的范围的,那么就有可能存在这样的一个情况:
- 线程1获取synchronized锁,执行操作,但此时事务未提交
- 线程1执行完synchronized锁包含的内容,释放synchronized,事务还未提交
- 事务将要提交时,线程2获取synchronized锁,执行synchronized锁包含的内容,且执行完毕后,刚好发生事务提交,此时就会将线程1和线程2所执行的内容一起提交了。也就是发生了多提交。
所以在Service层进行加锁是无法达到效果的,我们应该在Controller层添加锁
1.2 在Controller层添加synchronized锁
我们在Controller层加锁也应该注意,尽可能的减少synchronized锁所包含的范围,因为我们知道synchronized在线程竞争激烈的时候会升级成为重量级锁,程序运行时间会大大增加,因此在不必要加锁的地方尽可能的不加锁。
加锁后的代码如下:
package cn.codinglemon.demo.controller;
import cn.codinglemon.demo.Response.StockResponseEnum;
import cn.codinglemon.demo.entity.Stock;
import cn.codinglemon.demo.entity.StockOrder;
import cn.codinglemon.demo.service.StockOrderService;
import cn.codinglemon.demo.service.StockService;
import cn.codinglemon.demo.Response.ResponseBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* @author zry
* @date 2021-9-29 16:42
*/
@RestController
@CrossOrigin
@RequestMapping("/stock")
public class StockController {
@Autowired
private StockService stockService;
@Autowired
private StockOrderService stockOrderService;
@GetMapping("/kill")
public ResponseBean kill(@RequestParam("id")Integer id,@RequestParam("count")Integer count){
ResponseBean responseBean = new ResponseBean();
//检查库存是否足够
if(stockService.checkStock(id,count)){
//创建订单
Stock stock = stockService.selectById(id);
StockOrder stockOrder = new StockOrder();
stockOrder.setName(stock.getName());
stockOrder.setSid(stock.getId());
stockOrder.setTotalPrice(count*stock.getPrice());
stockOrder.setCount(count);
//在要创建订单信息的地方加锁
synchronized (this){
stockOrder = stockOrderService.createOrder(stockOrder);
if(stockOrder !=null){
//返回订单信息
responseBean.setCode(StockResponseEnum.StOCK_SUCCESS.getCode());
responseBean.setMsg(StockResponseEnum.StOCK_SUCCESS.getMessage());
responseBean.setData(stockOrder);
} else {
responseBean.setCode(StockResponseEnum.STOCK_NOT_ENOUGH.getCode());
responseBean.setMsg(StockResponseEnum.STOCK_NOT_ENOUGH.getMessage());
}
}
}else {
responseBean.setCode(StockResponseEnum.STOCK_NOT_ENOUGH.getCode());
responseBean.setMsg(StockResponseEnum.STOCK_NOT_ENOUGH.getMessage());
}
return responseBean;
}
}
此时我们再用JMeter测试一下,发现没有超卖的问题了。
两个表的数据都是我们预期的结果。
但是我们之前也提到过,synchronized在线程竞争激烈的时候会升级为重量级锁,并且这个升级过程是不可逆的,这样我们程序的运行效率会大大降低,用户体验差,那么有没有更好的办法呢?我们接着往下看。
2. 使用乐观锁(CAS)解决超卖
有关CAS的具体介绍可以看我之前写的一个有关CAS的总结:
那么在这个项目中,简单来说就是我们利用之前建表后有一个version字段,也就是版本号。利用数据库的操作的原子性,每次更新库存时先比较一下当前的版本号跟之前查询库存时的版本号是否一致,如果相同则更新库存,版本号加一,下订单;如果不一致说明在本次事务中,有其他线程先修改了库存,更新了版本号,则此次秒杀行为失败。
那么我们需要在StockMapper.xml中修改如下SQL:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.codinglemon.demo.dao.StockDao">
<select id="selectById" resultType="cn.codinglemon.demo.entity.Stock">
select id,name,count,sale,price,version from stock where id = #{id}
</select>
<!--新增版本号的比较-->
<update id="sale" >
update stock set sale = #{sale}, version = #{version} + 1 where id = #{id} and count >= #{sale} and version = #{version}
</update>
</mapper>
注意在StockDao中的sale方法需要添加一个version字段:
package cn.codinglemon.demo.dao;
import cn.codinglemon.demo.entity.Stock;
import org.apache.ibatis.annotations.Param;
/**
* @author zry
* @date 2021-9-29 16:30
*/
public interface StockDao {
Stock selectById(@Param("id")Integer id);
//添加version字段
int sale(@Param("id")Integer id,@Param("sale")Integer sale,@Param("version")Integer version);
}
同时在StockOrderServiceImpl中调用sale方法时传入version字段,这里针对不同的情形,返回了不同的结果;并且因为如果版本号不一致,则要进行事务回滚,因此将检查库存的操作放在了createOrder方法中,修改如下:
package cn.codinglemon.demo.service.impl;
import cn.codinglemon.demo.Response.ResponseBean;
import cn.codinglemon.demo.Response.StockResponseEnum;
import cn.codinglemon.demo.dao.StockDao;
import cn.codinglemon.demo.dao.StockOrderDao;
import cn.codinglemon.demo.entity.Stock;
import cn.codinglemon.demo.entity.StockOrder;
import cn.codinglemon.demo.service.StockOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* @author zry
* @date 2021-9-29 18:49
*/
@Service
public class StockOrderServiceImpl implements StockOrderService {
@Autowired
private StockOrderDao stockOrderDao;
@Autowired
private StockDao stockDao;
@Override
@Transactional(propagation = Propagation.REQUIRED)
public ResponseBean createOrder(StockOrder stockOrder) {
ResponseBean responseBean = new ResponseBean();
Stock stock = stockDao.selectById(stockOrder.getSid());
//查看能否找到商品
if( stock != null){
//检查库存是否足够
if(stock.getCount() >= stock.getSale() + stockOrder.getCount()){
boolean changeStock = stockDao.sale(stockOrder.getSid(),stockOrder.getCount()+stock.getSale(),stock.getVersion()) > 0;
//判断更新库存成功
if(changeStock){
boolean result = stockOrderDao.createOrder(stockOrder) >0;
//判断订单是否成功存入数据库
if(result){
responseBean.setCode(StockResponseEnum.STOCK_SUCCESS.getCode());
responseBean.setMsg(StockResponseEnum.STOCK_SUCCESS.getMessage());
responseBean.setData(stockOrder);
}
}else {
//秒杀失败
responseBean.setCode(StockResponseEnum.KILL_FAIL.getCode());
responseBean.setMsg(StockResponseEnum.KILL_FAIL.getMessage());
}
}else {
//库存不足
responseBean.setCode(StockResponseEnum.STOCK_NOT_ENOUGH.getCode());
responseBean.setMsg(StockResponseEnum.STOCK_NOT_ENOUGH.getMessage());
}
}else {
//没有找到该商品库存
responseBean.setCode(StockResponseEnum.NO_THIS_STOCK.getCode());
responseBean.setMsg(StockResponseEnum.NO_THIS_STOCK.getMessage());
}
return responseBean;
}
}
在controller层中可以简化代码:
package cn.codinglemon.demo.controller;
import cn.codinglemon.demo.Response.StockResponseEnum;
import cn.codinglemon.demo.entity.Stock;
import cn.codinglemon.demo.entity.StockOrder;
import cn.codinglemon.demo.service.StockOrderService;
import cn.codinglemon.demo.service.StockService;
import cn.codinglemon.demo.Response.ResponseBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* @author zry
* @date 2021-9-29 16:42
*/
@RestController
@CrossOrigin
@RequestMapping("/stock")
public class StockController {
@Autowired
private StockService stockService;
@Autowired
private StockOrderService stockOrderService;
@GetMapping("/kill")
public ResponseBean kill(@RequestParam("id") Integer id, @RequestParam("count") Integer count) {
ResponseBean responseBean;
Stock stock = stockService.selectById(id);
StockOrder stockOrder = new StockOrder();
stockOrder.setName(stock.getName());
stockOrder.setSid(stock.getId());
stockOrder.setTotalPrice(count * stock.getPrice());
stockOrder.setCount(count);
responseBean = stockOrderService.createOrder(stockOrder);
return responseBean;
}
}
这里在StockResponseEnum中添加了两种错误类型:
package cn.codinglemon.demo.Response;
/**
* @author zry
* @date 2021-9-29 20:16
*/
public enum StockResponseEnum {
STOCK_SUCCESS(20001,"下单商品成功"),
STOCK_NOT_ENOUGH(20002,"商品库存不足"),
KILL_FAIL(20003,"抢购失败"),
NO_THIS_STOCK(20004,"没有该商品库存")
;
StockResponseEnum(Integer code,String message) {
this.code =code;
this.message =message;
}
private int code;
private String message;
public int getCode() {
return this.code;
}
public String getMessage() {
return this.message;
}
public StockResponseEnum setMessage(String message) {
this.message = message;
return this;
}
}
好,现在我们从代码逻辑层面对更新库存操作进行了CAS式的加锁,我们利用JMeter测试一下是否如我们预期(测试之前记得删除一下数据库的数据):
这里sale为250,并且version也为250。
这里的stockOrder表中总共有250条记录,与我们预期的一致:
因为这里只是从代码逻辑层面进行了加锁,而不是从物理层面利用synchronized进行线程的加锁,利用CAS可以让多个线程同时执行,使得执行效率大大提高。并且这里监测的是对版本号的修改,版本号只会增加,不会减少,也不会存在ABA的问题。因此是一个比较好的选择。
有的同学会说:这里如果是250个人在同一时间抢购250个商品,那按照这里的代码逻辑,不是会存在商品没卖完但是秒杀失败的情况吗?是的,但是按照用户习惯,如果他秒杀失败,他肯定会再次尝试,也就是我们所说的多点几次,那么第一次不成功,后面的几次请求肯定会成功(只要还有库存),也就不存在商品卖不完的情况了。
3.总结
这一小节,我们分别利用悲观锁和乐观锁解决了商品超卖的问题,相比较而言,利用乐观锁来解决商品超卖的问题效率更高,代码也更优雅,是一个比较推荐的方式。
下一小节我们会介绍如何利用令牌桶和漏桶算法来过滤前端传来的超大量请求,防止太多的请求打崩服务器。
4.源代码地址
源代码如下,会根据项目进度不定期更新: