1.前言
在进行电商平台开发的时候,我们必定需要考虑到商品的超卖现象或者是秒杀模块功能的实现,因此不得不考虑到高并发所带来的问题,综合网上的各类博客文章,我对商品超卖这样的问题处理进行了从浅到深的逐步分析。(秒杀和抢购功能类似)
2. 由浅到深逐步思考
2-1第一种类型:原始方法
我们先从原先的方法来谈论可能会出现什么问题?
原始方法就是直接查询请求是否符合库存数量,符合则进行下单处理,但是由于数据库的读操作是无锁查询,也就是说,在同一时间如果有多个用户进行查询那么他们得到的库存数据是一样的,都能够进行下单操作,这样必然就出现了超卖现象。
那么怎么处理?首先我们势必想到的给要修改的数据库加锁,这里我们就要引入锁的概念。
先说说有什么锁我们可以用到:悲观锁和乐观锁
2-2锁的概念
2-2-1第一级锁分类
悲观锁:每次拿数据的时候都认为别的线程会修改数据,所以在每次拿的时候都会给数据上锁。上锁之后,当别的线程想要拿数据时,就会阻塞,直到给数据上锁的线程将事务提交或者回滚。其中悲观锁,包含了行锁、表锁、共享锁、排他锁等。
乐观锁:默认别的线程不会修改数据,所以不会上锁。常用的判断实现方式是使用版本戳,例如在一张表中添加一个整型字段version,每更新version++,比如某个时刻version=1,线程A读取了此version=1,线程B也读取了此version=1,当线程A更新数据之前,判断version仍然为1,更新成功,version++变为2,但是当线程B再提交更新时,发现version变为2了,与之前读的version=1不一致,就知道有别的线程更新了数据,这个时候就会进行业务逻辑的处理。
通常情况下,写操作较少时,使用乐观锁,写操作较多时,使用悲观锁。
2-2-2第二级分类
共享锁:共享锁又称为读锁,一个线程给数据加上共享锁后,其他线程只能读数据,不能修改。 如何加共享锁?可以使用select ... lock in share mode语句。
排他锁:排他锁又称为写锁,和共享锁的区别在于,其他线程既不能读也不能修改。 如何加排他锁?可以使用select ...for update 语句,所以行锁和表锁就是排他锁。
2-2-3第三级分类
行锁:在事务中线程A通过select name student where id=1 from for update语句给id为1的数据行上了锁。那么其他线程此时可以使用select语句读取数据,但是如果也使用select for update语句加锁,或者使用update,add,delete都会阻塞,直到线程A将事务提交(或者回滚),其他线程中的某个线程排在线程A后的线程才能获取到锁。
表锁:当没有查询条件where的时候,如这条SQL所示select * from student for update;那么就会形成表锁。
2-3第二种类型:使用事务加排他锁解决
使用mysql的事务加排他锁解决,前提必须保证数据库表设计的存储引擎为innoDB
核心代码如下:
public function indexMysql() {
DB::beginTransaction();
//通过for update 加排它锁
$shop = DB::table('shop')->where('id', '=', 1)->lockForUpdate()->first();
if ($shop->number > 0) {
if (DB::update("update shop set number = number - 1 where id = 1")) {
DB::commit();
} else {
DB::rollBack();//回滚并重试
usleep(100000);
$this->indexMysql();
}
} else {
DB::commit();
}
}
结果:利用数据库的for update来加锁,在数量少的情况下并不会出现问题,但是当并发达到(ab -n 1000 -c 200),就会出现请求非2XX的响应增多,因此在高并发的情况下,会导致数据库连接数不够,部分php获取不到连接而报错,或者是超过等待时间而报错。
缺点:当减库存和高并发碰到一起的时候,由于操作的库存数目在同一行,就会出现争抢InnoDB行锁的问题,导致出现互相等待甚至死锁的情况,从而大大降低MySQL的处理性能,最终导致前端页面出现超时异常。
2-4第三种类型:使用Redis缓存解决
使用数据库事务和数据库的锁机制,当高并发的时候,势必导致数据库连接数增大,这样高频率读写数据库很容易导致数据库宕机,因此,并不建议在数据库层面进行加锁,反而建议通过服务端的内存锁(锁主键)来对该问题进行解决,那么缓存我们有什么方法能够实现防止商品超卖的情况呢?
下面介绍Redis的两种方法:分布式锁、队列串行化
2-4-1使用redis的setnx来实现分布式锁
分布式锁的实现原理:
同一个锁key,同一时间只能有一个客户端拿到锁,其他客户端会陷入无限的等待来尝试获取那个锁,只有获取到锁的客户端才能执行下面的业务逻辑。
案例解析:
从上图可以看到,只有一个订单系统实例可以成功加分布式锁,然后只有他一个实例可以查库存、判断库存是否充足、下单扣减库存,接着释放锁。释放锁之后,另外一个订单系统实例才能加锁,接着查库存,一下发现库存只有2台了,库存不足,无法购买,下单失败。不会将库存扣减为-8的。
缺点:并发大的情况下,锁的争夺会变多,导致响应越来越慢,假设加锁之后到释放锁之前,的一系列操作“查库存 -> 创建订单 -> 扣减库存”全过程消耗20毫秒,那同一个商品在多用户同时下单的情况下,会基于分布式锁串行化处理,导致没法同时处理同一个商品的大量下单的请求。
如何对分布式锁进行优化:使用分段加锁技术方案,把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。假设场景:假如你现在iphone有1000个库存,那么你完全可以给拆成20个库存段,在数据库的表里建20个库存字段,比如stock_01,stock_02,类似这样的,也可以在redis之类的地方放20个库存key。然后写一个简单的随机算法,每个请求都是随机在20个分段库存里,选择一个进行加锁。这样每次就能够处理20个进程请求,但有个坑需要解决:当某段锁的库存不足,一定要实现自动释放锁然后换下一个分段库存再次尝试加锁处理。
2-4-2使用redis的队列来实现
将要促销的商品数量以队列的方式存入redis中,每当用户抢到一件促销商品则从队列中删除一个数据,确保商品不会超卖。
代码思路:
//1.当增加商品或修改商品库存时,将库存数据存入缓存
//2.如果用户下单,则在缓存的该商品库存key进行删除操作(判断删除后值不小于0)
//3.通过缓存获取商品库存数据显示在前端
//3.逻辑判断,当库存等于0时,数据持久化操作,并对商品下架处理