功能需求:设计一个秒杀系统
初始方案
商品表设计:热销商品提供给用户秒杀,有初始库存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @Entity public class SecKillGoods implements Serializable{ @Id private String id; /** * 剩余库存 */ private Integer remainNum; /** * 秒杀商品名称 */ private String goodsName; } |
秒杀订单表设计:记录秒杀成功的订单情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | @Entity public class SecKillOrder implements Serializable { @Id @GenericGenerator (name = "PKUUID" , strategy = "uuid2" ) @GeneratedValue (generator = "PKUUID" ) @Column (length = 36 ) private String id; //用户名称 private String consumer; //秒杀产品编号 private String goodsId; //购买数量 private Integer num; } |
Dao设计:主要就是一个减少库存方法,其他CRUD使用JPA自带的方法
1 2 3 4 5 6 7 8 | public interface SecKillGoodsDao extends JpaRepository<SecKillGoods,String>{ @Query ( "update SecKillGoods g set g.remainNum = g.remainNum - ?2 where g.id=?1" ) @Modifying (clearAutomatically = true ) @Transactional int reduceStock(String id,Integer remainNum); } |
数据初始化以及提供保存订单的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | @Service public class SecKillService { @Autowired SecKillGoodsDao secKillGoodsDao; @Autowired SecKillOrderDao secKillOrderDao; /** * 程序启动时: * 初始化秒杀商品,清空订单数据 */ @PostConstruct public void initSecKillEntity(){ secKillGoodsDao.deleteAll(); secKillOrderDao.deleteAll(); SecKillGoods secKillGoods = new SecKillGoods(); secKillGoods.setId( "123456" ); secKillGoods.setGoodsName( "秒杀产品" ); secKillGoods.setRemainNum( 10 ); secKillGoodsDao.save(secKillGoods); } /** * 购买成功,保存订单 * @param consumer * @param goodsId * @param num */ public void generateOrder(String consumer, String goodsId, Integer num) { secKillOrderDao.save( new SecKillOrder(consumer,goodsId,num)); } } |
下面就是controller层的设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | @Controller public class SecKillController { @Autowired SecKillGoodsDao secKillGoodsDao; @Autowired SecKillService secKillService; /** * 普通写法 * @param consumer * @param goodsId * @return */ @RequestMapping ( "/seckill.html" ) @ResponseBody public String SecKill(String consumer,String goodsId,Integer num) throws InterruptedException { //查找出用户要买的商品 SecKillGoods goods = secKillGoodsDao.findOne(goodsId); //如果有这么多库存 if (goods.getRemainNum()>=num){ //模拟网络延时 Thread.sleep( 1000 ); //先减去库存 secKillGoodsDao.reduceStock(num); //保存订单 secKillService.generateOrder(consumer,goodsId,num); return "购买成功" ; } return "购买失败,库存不足" ; } } |
上面是全部的基础准备,下面使用一个单元测试方法,模拟高并发下,很多人来购买同一个热门商品的情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | @Controller public class SecKillSimulationOpController { final String takeOrderUrl = "http://127.0.0.1:8080/seckill.html" ; /** * 模拟并发下单 */ @RequestMapping ( "/simulationCocurrentTakeOrder" ) @ResponseBody public String simulationCocurrentTakeOrder() { //httpClient工厂 final SimpleClientHttpRequestFactory httpRequestFactory = new SimpleClientHttpRequestFactory(); //开50个线程模拟并发秒杀下单 for ( int i = 0 ; i < 50 ; i++) { //购买人姓名 final String consumerName = "consumer" + i; new Thread( new Runnable() { @Override public void run() { ClientHttpRequest request = null ; try { URI uri = new URI(takeOrderUrl + "?consumer=consumer" + consumerName + "&goodsId=123456&num=1" ); request = httpRequestFactory.createRequest(uri, HttpMethod.POST); InputStream body = request.execute().getBody(); BufferedReader br = new BufferedReader( new InputStreamReader(body)); String line = "" ; String result = "" ; while ((line = br.readLine()) != null ) { result += line; //获得页面内容或返回内容 } System.out.println(consumerName+ ":" +result); } catch (Exception e) { e.printStackTrace(); } } }).start(); } return "simulationCocurrentTakeOrder" ; } } |
访问localhost:8080/simulationCocurrentTakeOrder,就可以测试了
预期情况:因为我们只对秒杀商品(123456)初始化了10件,理想情况当然是库存减少到0,订单表也只有10条记录。
实际情况:订单表记录

商品表记录

下面分析一下为啥会出现超库存的情况:
因为多个请求访问,仅仅是使用dao查询了一次数据库有没有库存,但是比较恶劣的情况是很多人都查到了有库存,这个时候因为程序处理的延迟,没有及时的减少库存,那就出现了脏读。如何在设计上避免呢?最笨的方法是对SecKillController的seckill方法做同步,每次只有一个人能下单。但是太影响性能了,下单变成了同步操作。
1 2 3 | @RequestMapping ( "/seckill.html" ) @ResponseBody public synchronized String SecKill |
改进方案
根据多线程编程的规范,提倡对共享资源加锁,在最有可能出现并发争抢的情况下加同步块的思想。应该同一时刻只有一个线程去减少库存。但是这里给出一个最好的方案,就是利用Oracle,MySQL的行级锁–同一时间只有一个线程能够操作同一行记录,对SecKillGoodsDao进行改造:
1 2 3 4 5 6 7 8 | public interface SecKillGoodsDao extends JpaRepository<SecKillGoods,String>{ @Query ( "update SecKillGoods g set g.remainNum = g.remainNum - ?2 where g.id=?1 and g.remainNum>0" ) @Modifying (clearAutomatically = true ) @Transactional int reduceStock(String id,Integer remainNum); } |
仅仅是加了一个and,却造成了很大的改变,返回int值代表的是影响的行数,对应到controller做出相应的判断。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | @RequestMapping ( "/seckill.html" ) @ResponseBody public String SecKill(String consumer,String goodsId,Integer num) throws InterruptedException { //查找出用户要买的商品 SecKillGoods goods = secKillGoodsDao.findOne(goodsId); //如果有这么多库存 if (goods.getRemainNum()>=num){ //模拟网络延时 Thread.sleep( 1000 ); if (goods.getRemainNum()> 0 ) { //先减去库存 int i = secKillGoodsDao.reduceStock(goodsId, num); if (i!= 0 ) { //保存订单 secKillService.generateOrder(consumer, goodsId, num); return "购买成功" ; } else { return "购买失败,库存不足" ; } } else { return "购买失败,库存不足" ; } } return "购买失败,库存不足" ; } |
在看看运行情况

订单表:

在高并发问题下的秒杀情况,即使存在网络延时,也得到了保障。