缓存笔记(4)高并发场景下,保证数据准确性的方案

1,459 阅读5分钟

场景
  高并发场景下,如何保证数据的准确性如防止商品超卖。当多个用户同时下单时,数据准确性将不受保障。如果同时有两个线程a和b,同时查询到商品库存为1,他们都认为存库充足,于是开始下单减库存。如果线程a先完成减库存操作,库存为0,接着线程b也是减库存,于是库存就变成了-1,商品被超卖了。
方法1:悲观锁
  悲观锁是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作读某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
  简而言之,悲观锁主要用于保护数据的完整性。当多个事务并发执行时,某个事务对数据应用了锁,则其他事务只能等该事务执行完了,才能进行对该数据进行修改操作。

//0.开始事务即加锁
begin;/begin work;/start transaction; (三者选一就可以)
//1.查询出商品信息
select stock from t_goods where id=1 for update;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品stock减一
update t_goods set stock=stock-1 where id=1 and stock > 0;
//4.提交事务即释放锁
commit;

  假设现在商品只剩下一件了,此时数据库中 stock = 1;但有100个线程同时读取到了这个 stock = 1,所以100个线程都开始减库存了。但你会最终会发觉,其实只有一个线程减库存成功,其他99个线程全部失败。需要注意的是,FOR UPDATE生效需要同时满足两个条件时才生效:(注意,先insert再update效果优于先update再insert

数据库的引擎为 innoDB
操作位于事务块中(BEGIN/COMMIT

  悲观锁采用的是「先获取锁再访问」的策略,来保障数据的安全。但是加锁策略,依赖数据库实现,会增加数据库的负担,且会增加死锁的发生几率。此外,对于不会发生变化的只读数据,加锁只会增加额外不必要的负担。在实际的实践中,对于并发很高的场景并不会使用悲观锁,因为当一个事务锁住了数据,那么其他事务都会发生阻塞,会导致大量的事务发生积压拖垮整个系统。
[注]:在实际成产环境中,除了数据库锁会产生阻塞,影响并发性能外,Java端到MySQL的网络延迟也会影响性能(当然,通常而言,后端服务器和MySQL服务器是在同一机房)
方法2:乐观锁
乐观锁的方式一般采用版本号的方式,其实也就是CAS的原理。

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

  所谓乐观锁,是相对于悲观锁而言的,它假设数据一般情况下不会发生并发,因此不会对数据进行加锁,操作完成提交时才对数据是否冲突进行检测,如果发现冲突则返回错误。比较常见的实现方式是,在表中增加一个版本号version字段,操作前先查询version信息,在数据提交时检查version字段是否被修改,如果没有被修改则进行提交,否则认为是过期数据。那么其他99个在更新的时候,会发觉version并不等于上次select的version,就说明version被其他线程修改过了,那么放弃这次update。
  But, 但是这种乐观锁的方式,在高并发时,只有一个线程能执行成功,会造成大量的失败,这给用户的体验显然是很不好的。
方法3:Redis原子操作
  虽然通过以上方按可以防止库存超卖,但是高并发情况下对数据库进行频繁操作,会造成严重的性能问题。因此我们必须在前端对请求进行限制。我们可以在Redis中设置一个队列key为商品的id,队列的长度为商品库存量。每次请求到达时pop出一个元素,这样拿到元素的请求即认为秒杀成功,后续通过MQ发送消息异步完成数据库减库存操作。没有拿到元素的请求即认为秒杀失败。由于Redis是工作线程是单线程的,而list的pop操作是原子性的,因此并发的请求都被串行化了,库存就不会超卖了。
方法4:分布式锁
  除了在数据库层面加锁,我们还可以通过在内存中加锁,实现分布式锁。例如我们可以在Redis中设置一个锁,拿到锁的线程抢购成功,拿不到锁的抢购失败。Redis的setnx方法可以实现锁机制,key不存在时创建,并设置value,返回值为1;key存在时直接返回0。线程调用setnx方法成功返回1认为加锁成功,其他线程要等到当前线程业务操作完成释放锁后,才能再次调用setnx加锁成功。 为防止某个线程长时间持有锁,可以对锁设置过期时间,以及续命等等, 此外,可以利用LUA脚本保证锁不会被误删juejin.cn/post/693237…