高并发下的下单功能设计

功能需求:设计一个秒杀系统

初始方案

商品表设计:热销商品提供给用户秒杀,有初始库存。

@Entity
public class SecKillGoods implements Serializable{
    @Id
    private String id;

    /**
     * 剩余库存
     */
    private Integer remainNum;

    /**
     * 秒杀商品名称
     */
    private String goodsName;
}

秒杀订单表设计:记录秒杀成功的订单情况

@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自带的方法

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);

}

数据初始化以及提供保存订单的操作:

@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层的设计

@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 "购买失败,库存不足";
    }

}

上面是全部的基础准备,下面使用一个单元测试方法,模拟高并发下,很多人来购买同一个热门商品的情况。

@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方法做同步,每次只有一个人能下单。但是太影响性能了,下单变成了同步操作。

 @RequestMapping("/seckill.html")
 @ResponseBody
 public synchronized String SecKill

改进方案

根据多线程编程的规范,提倡对共享资源加锁,在最有可能出现并发争抢的情况下加同步块的思想。应该同一时刻只有一个线程去减少库存。但是这里给出一个最好的方案,就是利用Oracle,MySQL的行级锁–同一时间只有一个线程能够操作同一行记录,对SecKillGoodsDao进行改造:

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做出相应的判断。

@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 "购买失败,库存不足";
    }

在看看运行情况

订单表:

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



相关文章

发表评论

Comment form

(*) 表示必填项

12 条评论

  1. ssss 说道:

    订单生成时有很多需要计算的,如促销信息,运费信息,物流等,这些环节中有的是调用其它系统进行查询计算的,怎样合理设计这个计算流程来保证下单的流畅与准确性?

    Thumb up 0 Thumb down 0

  2. Legend_Wang 说道:

    用多线程来测试,和实际情况不符合。
    实际情况下每次请求有独立的session,各session在提交事务之前不能探测到其他未提交的sql,导致改进方案同样失效。

    Well-loved. Like or Dislike: Thumb up 6 Thumb down 1

  3. 黄灿达 说道:

    update SecKillGoods g set g.remainNum = g.remainNum – ?2 where g.id=?1 and g.remainNum>0″) 如果一次减少多个库存呢

    Thumb up 0 Thumb down 0

  4. dudu 说道:

    >0换成>?2
    商品只剩1件时,购买两件,会变-1的吧

    Thumb up 2 Thumb down 0

  5. 孙杰 说道:

    这个分享比较实在的,我们也是这样处理的

    Thumb up 0 Thumb down 0

  6. reesoft 说道:

    这样设计的秒杀系统估计并发数上千就死掉了

    Thumb up 0 Thumb down 0

  7. dogngua 说道:

    如果 减库存成功, 但是生成订单失败,就不合适吧?
    secKillGoodsDao.reduceStock(goodsId, num); // 这一步操作成功
    secKillService.generateOrder(consumer, goodsId, num); // 这一步操作失败

    Thumb up 0 Thumb down 0

  8. 萝卜 说道:

    求demo

    Thumb up 0 Thumb down 0

  9. mengfw 说道:

    确定这样就可以了?剩余量为1 秒杀2个难道不会有问题?

    Thumb up 1 Thumb down 0

  10. 李潇 说道:

    高并发秒杀使用mysql会有问题吧,链接过多,可能会有大量链接超时的异常。
    如果是分布式使用缓存的形式,该如何保证抢购的数据一致性?

    Thumb up 1 Thumb down 0

  11. zhangyujin 说道:

    update SecKillGoods g set g.remainNum = g.remainNum – ?2 where g.id=?1 and g.remainNum>=?2
    这样更保险吧,如果一次购买多个产品,也就可能出问题了

    Thumb up 1 Thumb down 0

  12. Yang 说道:

    好奇import new只是一个爬虫网站吧,没人审核文章质量

    Thumb up 1 Thumb down 0

跳到底部
返回顶部