超卖问题解决方案

1,首先,解决一下当网络不好时,用户多次点击提交造成的多订单问题,可以在秒杀表中对用户id和商品id和本次活动的code进行一个唯一索引约束,可以避免多插入。



(不是很靠谱,根据阿里规约上面那个唯一约束肯定要加的,根据墨菲定律。。。)还有一种解决方案是,通过布隆过滤器来实现重复提交限制



2,使用mysql的事务隔离级别,select stock from table where id =1 for update; update set stock=stock-1 where id=1 这样对这个商品的操作变成了串行的了,其它的操纵都必须等待上一个事务完全提交后才能操作,效率太低,对于一般的小秒杀场景可以使用,实现较简单。



3,使用乐观锁,查询数据库时,会同时把商品信息和该条记录的版本号这个字段version都查询出来,这个version会在有更新时进行+1操作,然后当要进行扣库存操作时加上一个条件update table set stock=stock-1 where version=#{version} 如果这个version相等说明没人修改过这个值,那么就更新完成,否则就失败,那么就继续秒杀,重试



4,使用redis的分布式锁来实现,每秒10万并发肯定够的估计,但是处理库存等数据库操作估计20ms左右,当然机器性能好或优化好,表设计的好应该比这个低,算下来20*50=1000 ,大概也就是每秒可以处理50个请求,大的秒杀场景估计不够用,可以考虑库存分在多行进行存储,分段概念,这样分布式锁也是分段的,实现起来感觉比较复杂



5,将库存等映射到redis缓存中,秒杀可以快速响应扣库存等操作,然后将处理好的数据放入消息队列,在后台慢慢处理。



背景介绍:
对于一个互联网平台来说,高并发是经常会遇到的场景。最有代表性的比如秒杀和抢购。高并发会出现三个特点:
  1、高并发读取
  2、高并发写入(一致性)
  3、出现超卖问题
如何有效的解决这三个问题是应对高并发的关键。
一般系统都分为前端和后端。
前端如何应对?
1、缓存静态数据,例如图片,html页面,js等
2、搭建负载均衡集群,目前采用较多的为nginx
3、进行ip限制,限制同一个ip单位时间内发起的请求数量。或者建立ip黑名单,避免恶意攻击
4、考虑系统降级。比如当达到系统负载的时候返回一个静态处理页面
后端如何应对?
1、采用mysql读写分离,但是当高并发的时候mysql性能会降低。 一般来说,MySQL的处理性能会随着并发thread上升而上升,但是到了一定的并发度之后会出现明显的拐点,之后一路下降,最终甚至会比单thread的性能还要差。比如加减库存的操作,通常并发量不高的做法为:update xxx set count=count-xx where curcount>xx;这样可以充分利用mysql的事务锁来避免出现超卖的情况。但是并发量上了后,会因为排他锁等待而大大降低性能。
2、采用redis数据库,前置到mysql。思路如下:
2.1系统启动后,初始化sku信息到redis数据库,记录其可用量和锁定量
2.2使用乐观锁,采用redis的watch机制。逻辑为:
1.定义门票号变量,设置初始值为0。watchkey
2.watch该变量,watch(watchkey);
3.使用redis事务加减库存。首先获取可用量和抢购量比较,如果curcount>buycount,那么正常执行减库存和加锁定量操作:
multi;
redis incr watchkey;
redis decrby curcount buycount;
redis incrby lockcount buycount;
exec;
由于上述操作都在事务内进行,一旦watchkey被其他的事务修改过,那么exec将返回nil,如此就放弃本次请求。一般都是在循环中重复尝试直到成功或没有可用量。
最后通过订单信息流,保证mysql数据库的最终一致性。



方案一:redis事务处理(multi)
        我们可以使用redis中的监听(watch)方法,去监听库存数量,一旦库存数量在其他客户端发生改变,后续操作则会失败。



        watch key1 key2        监听key1 key2有没有变化,如果有变, 则事务取消



方案二:redis分布式锁    
        分布式锁确保只有一个线程会操作库存,明哥在redis文集中有过专门的整理



        1加锁(占个位置,后续的进不来):



        setnx命令: 只在键key不存在的情况下,将键key的值设置为value 。若键key已经存在, 则不做任何动作。



        2解锁(用完了,就把位置让出来):



        del(key)



        3锁超时(万一中间出现点意外,没有解锁,过几秒会自动释放)       



        expire(key,30) 



方案三:redis队列(rpoplpush的安全队列)
        把每一件商品都lpush到redis队列中,利用lpop从队列中去取



方案四:mysql层面优化update语句(凑数)
        收到抢购请求后,对商品执行库存 -1 操作。然后给用户生成一个订单。



        这系列操作中,并发问题在于:如果库存为1,这时候有两个用户同一时间请求,那么两个进程同时select 到库存数为1,并同时进行update -1操作,那么相当于1件商品就卖出去了两次。



        那么如何解决这个问题呢?很简单。我们只需要把select库存和update库存-1这两个操作放在1个事务里,并且在select商品库存的时候for update一下加上排他锁。那么就不存在两人同时读取到库存1的可能了。第一个人-1库存,+1订单,提交事务。第二人才能select到库存。这时候库存就是0了。自然而然就是抢购失败。



        上面是理论。实际操作中,有更简单的办法。那就是直接update 商品表 库存-1 where id=商品id AND 库存!=0;然后获取更新数量,如果不为1,就是抢购失败。为1,就是抢购成功。这里面保证并发安全的是数据库的单条sql就是一个事务的特性保证的。



        优化方案1:将 库存 字段 设为unsigned,当库存为0时,因为字段不能为负数,将会返回false



        优化方案2:使用mysql的事务,锁住操作的行



方案五:乐观锁



秒杀超卖现象:在高并发下,多个线程并发更新库存,导致库存为负的情况。



我搜集了一些资料,整理了一下,秒杀可选方案主要有以下三种:




  1. 悲观锁
    悲观锁方案最容易理解:在更新库存期间加锁,不允许其它线程修改。



1.1 select xxx for update
优点:确保了线程安全。



缺点:高并发场景下会导致多个请求一直等待,数据库性能下降,系统的链接数上升,负载飙升,影响系统的平均响应时间,甚至会瘫痪。



1.2 文件锁
优点与 1.1 类似,缺点是磁盘 IO 开销会变大。



1.3 缓存锁
当用户 A 要修改某个 id 的数据时,把要修改的 id 存入缓存,若其他用户触发修改此 id 的数据时,读到 memcache 有这个 id 的值时,就阻止其它用户修改。



优点与缺点与 1.2,但总体效果要好于以上两种方案。如果缓存是独立的集群,还可以解决跨进程乐观锁处理不了的问题。



1.4 分布式锁
与 1.3 类似。




  1. 乐观锁
    使用带版本号的更新。每个线程都可以并发修改,但在并发时,只有一个线程会修改成功,其它会返回失败。



Redis 的 watch



  1. FIFO 队列
    通过 FIFO 队列,使修改库存的操作串行化。



Redis 的队列
优点:不需要在单独加锁(无论是悲观锁还是乐观锁)。
缺点:队列的长度是有限的,必须控制好,不然请求会越积越多。




  1. 结论
    总的来说,不能把压力放在数据库上,所以使用”select xxx for update”的方式在高并发的场景下是不可行的。FIFO 同步队列的方式,可以结合库存限制队列长,但是在库存较多的场景下,又不太适用。所以相对来说,我会倾向于选择:乐观锁/缓存锁/分布式锁的方式。



由秒杀引发的一个问题
秒杀最大的一个问题就是解决超卖的问题。其中一种解决超卖如下方式:
1 update goods set num = num - 1 WHERE id = 1001 and num > 0



我们假设现在商品只剩下一件了,此时数据库中 num = 1;



但有100个线程同时读取到了这个 num = 1,所以100个线程都开始减库存了。



但你会最终会发觉,其实只有一个线程减库存成功,其他99个线程全部失败。



为何?



这就是MySQL中的排他锁起了作用。



排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。
就是类似于我在执行update操作的时候,这一行是一个事务(默认加了排他锁)。这一行不能被任何其他线程修改和读写



第二种解决超卖的方式如下



1 select version from goods WHERE id= 1001
2 update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);



这种方式采用了版本号的方式,其实也就是CAS的原理。



假设此时version = 100, num = 1; 100个线程进入到了这里,同时他们select出来版本号都是version = 100。



然后直接update的时候,只有其中一个先update了,同时更新了版本号。



那么其他99个在更新的时候,会发觉version并不等于上次select的version,就说明version被其他线程修改过了。那么我就放弃这次update



第三种解决超卖的方式如下
利用redis的单线程预减库存。比如商品有100件。那么我在redis存储一个k,v。例如 <gs1001, 100>



每一个用户线程进来,key值就减1,等减到0的时候,全部拒绝剩下的请求。



那么也就是只有100个线程会进入到后续操作。所以一定不会出现超卖的现象



总结



可见第二种CAS是失败重试,并无加锁。应该比第一种加锁效率要高很多。类似于Java中的Synchronize和CAS。



https://zhuanlan.zhihu.com/p/165638059



Category architect