尝试用一个例子来描述高并发系统下的缓存设计,一边举例子一边描述和解决以下问题。
- 为什么要用缓存?
- 缓存一致性问题?
- 缓存溢出问题?
- 缓存雪崩问题?
- 缓存穿透问题?
- 缓存击穿问题?
问题:
假设我们有5000万条商品信息存储在数据库中,现在这些信息要展示给用户看,我们需要做什么?
答案1:直连数据库
- 代码中直接访问数据库,读取数据,展示给用户看,这个方法可以吗?
- 答案是:访问量少的时候可以,系统访问量大了就崩了。
- 事实上大多数的内部系统和ToB业务,访问量不大,直接用数据库就解决问题了
- 如果业务访问量上来了,这时候频繁访问数据库,就会造成很明显的瓶颈。
- 这也是大多数“古典“网站和系统,用户访问一多就崩溃的原因
- 在设计系统的时候没有考虑:高访问量,高并发
- 一般认为预计访问量有超过2000次/秒,直连数据库的方案就不太建议了
- 为了避免数据库被打崩,我们就需要考虑在数据库和代码层之间加上一个缓存
- 有很多种缓存,下面以用得最多的Redis来举例子
答案2:加缓存(例如Redis)
- 现在我们用了Redis在数据库和业务之间做缓冲
- 需要访问一个商品的时候
- 1.业务传过来一个商品id
- 2.在redis中查找是否有这个id的信息,有就直接返回
- 3.如果redis中没有找到,去数据库里读取,读取到了信息存入redis,并返回给用户
- 因为多了一层redis,程序性能得到了极大的优化
- 访问变快了(纯内存的redis比MySQL要快很多)
- 不会因为大量的访问被堵死了(单节点的Redis可负担的简单QPS大约是10万,MySQL大约是0.4万)
- 现在系统的瓶颈解决了,那么接着往下想
- 如果此时数据库的信息被更新了,Redis中的缓存信息怎么办?
- 可能有同学认为,数据库更新了,也把Redis信息同步更新/或删除了不就行了
- 事实上你细想一下,就没那么简单了
- 这就是引出了一个问题:缓存一致性问题
缓存一致性问题
当修改一条商品信息,MySQL和Redis缓存都需要修改,两者之间会有先后顺序,可能导致数据不一致。
- 当我们需要修改商品时,需要考虑3个问题:
- 1.先更新缓存还是先更新数据库?
- 2.更新缓存的时候,是更新(update)缓存,还是删除(delete)缓存?
- 3.怎么更新缓存保证一致性?
1、先更新缓存还是先更新数据库?
- 如果先更新缓存,写数据库失败,则缓存为最新数据,数据库为旧数据,缓存为脏数据。
- 之后其他查询马上进来就会拿到这个数据,但是这个数据在数据库中是不存在的。
- 数据库中不存在的数据缓存并返回给客户端是没有意义的。
- 所以不能先更新缓存。只能是:DB First
2、更新缓存的时候,是更新(update)缓存,还是删除(delete)缓存?
- 这里推荐是修改商品的时候,直接删除(delete)缓存
- 原因是update缓存通常比delete缓存需要更多的资源
- 为了得到一条商品的完整信息,可能会join几张表得到一个json,组装起来set到redis中的代价,会比直接del一个rediskey要大得多
- 而在一个高并发系统中,我们要尽可能的保证整个修改是尽可能快的完成(代价是一次缓存失效)
3.怎么更新缓存保证一致性?
- 这里有几种方案:
数据库事务方案:
- 实现
- begin一个事务
- update数据库
- 更新缓存
- commit事务
- 如果更新缓层失败,则回滚数据库
- 评价:
- 优点:逻辑简单
- 缺点:用了数据库事务(并发问题),缓存失败后,导致整个更新都失败回滚了(业务影响大)
缓存延迟双删方案
- 实现
- 先删除缓存
- update数据库
- 休眠N毫秒,然后删除缓存
- 如果第二次的删除缓存失败了,需要重试
- 评价:
- 优点:没有用数据库事务,并发性提高
- 缺点:第二次删除缓存逻辑上要保证成功,所以需要增加重试机制,但是重试也不能保证100%成功
缓存延迟双删方案消息队列改进版
- 实现
- 先删除缓存
- update数据库
- 休眠N毫秒,然后删除缓存
- 将更新的商品id写入消息队列
- 读取消息队列,并再次删除缓存(如果还删除报错,就要触发给DBA报警了,Redis服务出问题了)
- 评价:
- 优点:没有用数据库事务,并发性提高,异步进程,保证缓存一致性
- 缺点:系统复杂度提升
缓存延迟双删方案binlog改进版
- 实现
- 先删除缓存
- update数据库
- 休眠N毫秒,然后删除缓存
- 解析binlog(例如Cannal),读取到更新的binlog
- 读取消息队列,并再次删除缓存(如果还删除报错,就要触发给DBA报警了,Redis服务出问题了)
- 评价:
- 优点:加了binlog解析是为了防止意外更新(某些系统可能存在有未被关注到的更新入口,藏在角落里很少有人用到的更新接口,甚至新来的程序员加了一个更新入口,大意的程序员直接提了个SQL上线),为了系统的健壮性,所以在设计的时候尽可能的考虑到各种潜在的威胁,减少线上Bug出现的可能性。
- 缺点:系统复杂度更次提升
看到这里,我们知道为了提高系统在高访问量的并发性,解决数据库瓶颈,引入了缓存(Redis)。又为了解决缓存的一致性,引入了各种复杂双删策略,引入了消息队列(MQ),binlog服务(cannal), 为了能让程序更加快速,满足高并发,我们搞了这么多事。
别急,这还没完。继续往下想,还有很多事情。
缓存溢出
还是刚才的问题继续想一下,这5000万条数据都放到缓存里面吗?
- 算一下:假定一条商品信息是16k的字符串:50001000016/1024/1024=762G
- 为了缓存这762G数据,你至少需要找DBA申请900G的Redis
- 这么大的一个Redis集群,DBA该怎么运维(不是说DBA搞不动哈,就说这玩意有多膈应人)
- 要是某天又突然来了一个供应商,又多个几千万商品怎么办?再来个900G?
- 如果这个商品是可以按时间维度切分的(例如,酒店的房型,不同的时间可以理解成不同的商品)突然多了一个维度
- 缓存的资源是有限的。不止是成本问题,还有大集群带来的运维风险更大(bgsave,keys… )。
- DBA给的Redis规范中,Redis实例的内存是(1G/2G/8G/16G*3节点/5节点])不可能无限增长的
- 有限的缓存空间和无限的缓存需求,就有了缓存溢出的风险
缓存溢出的解决方法
- 解决起来也很简单,增加Redis过期时间
- Redis使用规范中有要求【强制:所有的key设置过期时间(最长可设置过期时间10天,如有特殊要求,联系dba说明原因 )】
- 通常key过期时间,建议是1-3天,具体视业务而定
缓存雪崩
继续刚才的问题,现在已经给商品的key设置了过期时间(假定是24小时),假如有一个供应商在昨天的17:00 批量修改了100万条商品信息,那么这100万条商品信息在redis中都将会在今天的17:00失效。此时就会引起缓存雪崩。
- 大量的数据在同一个时间点都失效了,让请求直接打到数据库了,数据库就崩了。(你可以理解在高并发系统中数据库就是一个脆弱的小天使,一打一倒)
- 还有一种情况是缓存故障了,Redis集群坏了(这个是妥妥的DBA部门故障,但是研发设计的时候也需要多考虑一步)此时也会形成缓存雪崩
缓存雪崩的解决方法
针对缓存集中过期:
- 1.缓存过期时间的随机性,每个key的过期时间不再要求是准确的24小时,而是一个相对随机的范围(20-30小时),这样批量更新的key不会同时过期
- 2.缓存过期时间读延后,在读取一个key的时候,同时延后这个key的过期时间。这样可以保证热点数据一直不过期,当然带来的缺点是热点key在缓存中持续高频更新,所以在并发高的系统中,建议有个随机数(每1万次读取,可能会触发此key的过期时间后延一次)
针对Redis集群故障:
- 程序中捕捉到Redis访问故障时,需要做降级处理,一般我们叫它:缓存降级
- 限流&降级,避免MySQL崩掉,用加锁/队列/方式 控制对MySQL的访问量,避免更大的事故发生
- 对非核心请求,缓存降级能扔就扔。保证核心业务流程不中断
- 对核心请求,优先何证数据完整性。有损降级等待DBA处理恢复缓存,可以设置业务开关,选择降级程度。
缓存穿透
现在有个id为9527的前热门商品下架了,数据库里删除了,现在有大量的访问一直要访问9527这个商品,此时缓存中没有,会透到数据库中
- 除掉商品下架,还有一些奇怪的原因会导致缓存穿透
- 比如:9527这个商品id本身从来就不存在,是业务人员写错编号了
- 又比如:读取9527这个商品有个特殊属性,读到缓存时失败了,代码bug了写缓存失败了..
- 这些情况下,会造成:数据库中也没有,所以也不会写入缓存,访问量大的时候,数据库又崩了.
缓存穿透的解决方法
- 读取不到商品的时候,也要将id的key写入缓存中,value可以是约定的特殊值(不建议空值或null值)
- 读取到缓存失败的商品,也要做特殊处理,视情况决定将此id的key写入缓存中(考虑是偶发性的错误)
缓存穿透Plus(黑客/爬虫)
假定有黑客/或爬虫程序一直在遍历不存在的id(这个量可能会大的吓人)
- 按照上面处理缓存穿透的方法,我们把这些不存在id的key也写入缓存,会导致缓存了很多无用的数据
- 进一步可能会引起缓存雪崩,影响又大了
缓存穿透Plus的解决方法
- 方法1:设置一个阈值N,当一个id不存在的key被访问到的时候,它有1/N的机率触发到加载到缓存的动作
- 方法2:设置一个布隆过滤器,将所有可能存在的id哈希到一个足够大的bitmap中,一定不存在的数据请求会被屏掉
- 与其同时,对这些不存在的key的请求,应该设置一个较短的过期时间,假如普通id的过期时间是24小时,那么这些key的过期时间可以设置成30分钟。
- 和安全部门联动。他们可以在前置拦掉这些访问
- 有时候访问一些冷门文章(假如朋友转发过来的大众点评的一个冷门文章时)会弹出一个真人验证的环节。也可以起到一些不存在或很冷门的id被爬取或扫描时带来的问题
缓存击穿
现在不是被人攻击了,就是一个很热门的商品(每秒种几百万次点击)被修改了,此时这个商品的缓存失效了。高并发场景同一时刻有大量的访问,发现没有缓存
- 这种热点数据的失效,造成的单点穿透。我们就叫它:缓存击穿
- 重点理解它的:击 这个字,单点突破了。也能把数据库打死。
- 究期根源是高并发。有100个人同时去访问,都没等到第一个人读取到数据更新好缓存。这100个都去库里读了。
缓存击穿的解决方法
- 方法1:对部分热门商品做特殊处理
- 这些商品的key在缓存中永不过期。
- 这些商品的更新走特殊流程,更新db的同时会更新缓存,用db事务保证它的一致性。(参见上面的一致性章节)
- 也就是说,永远保证这些热点key存在。该用事务用事务。该锁资源锁资源,非热点key靠边站。缓存就是优先为这些爆款服务的不要舍不得,也不要怕麻烦。(vip中p的待遇)
- 如果有个商品,平时没人看,突然有天老罗在直播间给你带货,商品突然成了爆款呢,但是没有加载到缓存(理论上是有可能的),此时需要考虑到:缓存预热。
- 怎么维护热门商品列表呢?出门右拐找大数据或运营同事
- 方法2:改造加载缓存的代码
- 如果发现缓存没有命中。则随机等待N毫秒再重试一次,如果还是没有则去db里读
- 本地维护一个直接读取db的id字典,如果已经有存在的则再随机等待一下(这里只需要本地字典就行,分布式部署的节点数带来的的多次缓存更新可忽略)
小结
>> Home说了这么多,都是在说高并发情况下需要考虑到的问题及解决方法。为了应对高并发场景,我们肉眼可见的提高了代码的复杂度和设计的复杂度。只有充分的了解和考虑到这些风险点并提前加以处理和规避,才能让一个系统在高并发的时候保持:稳定/健壮/不出错。如果你的系统没有高并发,可以不用这么折腾,因为过度设计也是一种浪费,该直连数据库就直连数据库吧。或者简单的套个redis做缓存也足够了。