一个月前——
前端
整个优惠券中心分为前端和后端,小灰所负责的是后端RPC接口的开发。接口中包含“查券”和“领券”两个方法,项目大致结构以下图:程序员
两周后——数据库
小灰:看,这是优惠券查询功能的效果! 后端
小灰:看,这是优惠券领取功能的效果! 缓存
三天后——安全
小灰本来的优惠券查询接口是这样实现的:
服务器
优惠券列表在Redis中以List的形式存储,查询时的逻辑很简单:并发
1.查询缓存,若是缓存存在,返回结果分布式
2.缓存不存在,查询数据库线程
3.把查询数据库的结果循环放入缓存
然而,当某个时间点缓存不存在,请求量又很大的时候,会出现缓存并发的问题。也就是多个线程会重复去查询DB,又重复去更新缓存。(注意,这并非缓存击穿,不少人在这两个概念上混淆。)
这其中重复查询DB是次要问题,而重复更新缓存则是主要问题。假若有两个线程同时进入上述的第三个阶段,各自进行rpush操做,那么最终会在优惠券列表的缓存中插入两组一样的数据。
怎么解决呢?用Java的锁机制?显然不行,由于线上环境一般都是多个服务器组成的集群。因而小灰想到了利用分布式锁。
所谓分布式锁有不少种,能够利用ZooKeeper、MemCache、Redis来实现。其中Redis的方式比较简单,无非是利用一个服务器之间共享的Key,以及Setnx指令。
当第一个线程执行Setnx,会存储对应的键值,至关于成功得到锁。当后续再有线程对同于的Key执行Setnx指令,则会返回空,至关于抢锁失败。同时,为了防止一个线程因意外状况而长久把持着锁,程序对Key设置了1秒的过时时间。
概括一下修改后的逻辑:
1.查询缓存,若是缓存存在,返回结果
2.缓存不存在,查询数据库
3.争夺分布式锁
4.成功得到锁,把查询数据库的结果循环放入缓存
5.释放分布式锁
三天后——
诡异的bug又重现了,由于小灰上次的改动仍然存在一个致命的漏洞。在这里咱们假定缓存不存在,恰好有两个线程A和B一后一先进入到代码块。
第一阶段,线程A刚开始查询优惠券缓存,线程B正尝试获取分布式锁:
第二阶段,因为缓存不存在,线程A开始查询数据库,线程B成功得到锁,开始更新缓存:
第三阶段,线程A尝试得到分布式锁,而线程B已经释放分布式锁:
第四阶段,线程A得到了锁,又一次更新缓存,而线程B已经成功返回:
就这样,缓存被重复更新了两次,因此再次出现数据重复的bug。
这种局面如何破解呢?其实不难,只需在线程成功获得锁之后,再次判断优惠券缓存的存在:
概括一下修改后的逻辑:
1.查询缓存,若是缓存存在,返回结果
2.缓存不存在,查询数据库
3.争夺分布式锁
4.成功得到锁,再次判断缓存的存在
5.若是缓存仍旧不存在,把查询数据库的结果循环放入缓存
6.释放分布式锁
这种二次判断存在性的机制有一个专门的名字,叫作双重检测。该方法在线程安全的单例模式中也经常被用到。
小灰的回忆告一段落——
几点补充:
1.文中所使用的分布式锁,其实并非“正宗”的分布式锁,当线程争夺锁失败的时候,会直接返回查询DB的结果,而不会依靠自旋机制来等锁。
2.为何优惠券列表的信息要使用List类型来存入缓存,而不是把整个列表存为一个很长的Json字符串?这是因为业务须要,使用List在某些状况下更方便对单个优惠券信息进行修改(LSET指令)。
3.为何优惠券列表的信息不使用Redis的Set或者Hash数据类型来存储,实现自动去重呢?对于Set类型,去重前须要对比整个字符串是否彻底相同,而每一张优惠券是一个较长的Json字符串,对比的效率会比较低。使用Hash却是能够实现高效的去重,但并未在根本上解决重复更新的问题。
—————END—————
喜欢本文的朋友们,欢迎长按下图关注订阅号程序员小灰,收看更多精彩内容