背景
在项目中经常会遇到并发场景,比如最典型的秒杀场景,假设后端有一张库存表,当大批请求过来时,都会经历读库存+减库存两个步骤,比如库存为100,两个线程先分别读到了库存100,然后各自执行减1并写回数据库,结果数据库数据为99而不是预期的98,这种情况如果不加以技术手段进行处理,很容易导致库存超卖。
另一个场景的例子是本人之前做过的请假系统,后端有一张表存储着请员工的可支配假期天数,当有多条请假申请被同时审批时,它们都要去执行读假期+减假期的操作,如果两个线程都先读到了假期数据,然后分别执行减假期的操作,就很容易导致假期少扣的情况。
我将对这一开发中的典型场景总结一些我自己的思考。
思路一:SQL优化
为了具体模拟秒杀这一场景,假设库存表叫做stock,商品数量叫做num,有业务代码计算出的新数目为new_num=num-1,原来执行的SQL为:
update stock set num=new_num where id = id=#{id};
我们可以改进SQL,让数据库根据自己当前的值更新数据,比如写成下面这样:
update stock set num=num-1 where id = #{id};
复制代码
这样虽然看似解决了问题,但是假设此时库存的数量为1,两个线程各自减1后,会发现库存被扣成了-1,同样也无法完全解决并发问题。
思路二:代码加锁
既然两个线程同时拿数据还是有问题,那么有没有办法让它们依次执行,于是很容易想到在查库存之前先加上锁,比如Java的synchronized关键字。
这能很好的保证串行,保证每次只有一个线程去执行查库存和扣库存的代码段,但同样缺点也非常多:
- 无法做到细粒度的控制,如果有的人秒杀商品A、有的秒杀商品B,都要走秒杀方法,相互之间不冲突但是也只能串行执行,访问会变得很慢。
- 因为spring里的@service是单例的,所以可以这么写,如果用python语言的django框架,则没法在代码层做类似的控制,不通用。
- 只支持单点(单机、服务器环境),无法做到水平扩展,现在基本都是集群部署的,仅能一台机器生效。
也许,我们需要一种第三方的机制,去实现这样的一把锁。
思路三:基于MySQL的悲观锁
因为所有的线程都在访问同一张表,可以在数据库上加锁,典型的做法有悲观锁和乐观锁两种。
悲观锁利用了select…for update 语法,例如:
select * from stock where id=#{id} for update;
注意:使用悲观锁要把业务代码放在事务里。
悲观锁获取数据时对数据行了锁定,其他事务要想获取锁,必须等原事务结束。
使用selec…for update会把数据给锁住,MySQL InnoDB默认Row-Level Lock,所以只有「明确」地指定主键,MySQL 才会执行Row lock (只锁住被选取的数据) ,否则MySQL 将会执行Table Lock (将整个数据表单给锁住)。
使用悲观锁的缺点是,因为要锁表,所以并发性不高,如果并发过高,数据库压力过大,会宕机。
思路四:基于MySQL的乐观锁
乐观锁相对悲观锁