缓存更新方式
很多研发同学是这么用缓存的:在查询数据的时候,先去缓存中查询,如果命中缓存那就直接返回数据。如果没有命中,那就去数据库中查询,得到查询结果之后把数据写入缓存,然后返回。在更新数据的时候,先去更新数据库中的表,如果更新成功,再去更新缓存中的数据。流程如下图
这样使用缓存的方式有没有问题?绝大多数情况下都没问题。但是,在并发的情况下,有一定的概率会出现“脏数据”问题,缓存中的数据可能会被错误地更新成了旧数据。比如1,对同一条记录,同时产生了一个读请求和一个写请求,这两个请求被分配到两个不同的线程并行执行,读线程尝试读缓存没命中,去数据库读到了数据,这时候可能另外一个写线程抢先更新了缓存,在处理写请求的线程中,先后更新了数据和缓存,然后,拿着旧数据的第一个读线程又把缓存更新成了旧数据(概率低)。比如2两个线程对同一个条订单数据并发写,也有可能造成缓存中的“脏数据”(概率高)
1、故我们经常使用Cache Aside 模式,它们处理读请求的逻辑是完全一样的,唯一的一个小差别就是,Cache Aside 模式在更新数据的时候,并不去尝试更新缓存,而是去删除缓存。流程如下:
这种方式可以解决如上例子2中的脏数据的问题。在写策略中,能否先删除缓存,后更新数据库呢?答案是不行的,因为这样会大大提高如上事例1出现的概率。另外我们一般会配合添加一个比较短的过期时间,即使示例1的情况出现了,也只有比较短时间的脏数据。
但也要学会依情况而变。比如说新注册用户,按照这个更新策略,要写数据库,然后清理缓存。可当注册完用户后,当使用读写分离时,会出现因为主从延迟所以读不到用户信息的情况(一致性要求比较高的话,写后读在一定时间阈值里面一般去master读,此时就不会有这个问题,我会在一致性浅谈的文章里介绍)。而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了,因为是新注册的用户,所以不会出现并发更新情况。
2、另一种经常使用的策略是模拟MySQL的从机,通过订阅binlog的方式更新缓存,此时MySQL必须设置为row格式。一般流程图如下:
缓存穿透
如果我们的缓存命中率比较低,就会出现大量“缓存穿透”的情况。缓存穿透指的是,在读数据的时候,没有命中缓存,请求“穿透”了缓存,直接访问后端数据库的情况。少量的缓存穿透是正常的,我们需要预防的是,短时间内大量的请求无法命中缓存,请求穿透到数据库,导致数据库繁忙,请求超时。大量的请求超时还会引发更多的重试请求,更多的重试请求让数据库更加繁忙,这样恶性循环最终导致系统雪崩。
1、当系统初始化的时候,比如说系统升级重启或者是缓存刚上线,这个时候缓存是空的,如果大量的请求直接打过来,很容易引发大量缓存穿透导致雪崩。为了避免这种情况,可以采用灰度发布的方式,先接入少量请求,再逐步增加系统的请求数量,直到全部请求都切换完成。如果系统不能采用灰度发布的方式,那就需要在系统启动的时候对缓存进行预热:在系统初始化阶段,接收外部请求之前,先把最经常访问的数据填充到缓存里面,这样大量请求打过来的时候,就不会出现大量的缓存穿透了。
2、当有大量的请求访问不存在的数据时,比如在券商系统的用户表中,我们需要通过用户 ID 查询用户的信息。如果要读取一个用户表中未注册的用户,按照这个策略,我们会先读缓存再穿透读数据库。由于用户并不存在,所以缓存和数据库中都没有查询到数据,因此也就不会向缓存中回种数据,这样当再次请求这个用户数据的时候还是会再次穿透到数据库。在这种场景下缓存并不能有效地阻挡请求穿透到数据库上,它的作用就微乎其微了。一般来说我们会有两种解决方案:回种空值以及使用布隆过滤器
第一种解决方案回种空值。当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。所以这个方案,在使用的时候应该评估一下缓存容量是否能够支撑。
第二种解决方案布隆过滤器。布隆过滤器有一个特点是:布隆过滤器如果返回不存在的那么一定是不存在的,但是如果返回存在,未必存在。如果布隆过滤器的多次hash函数选择的比较合理,空间预估的比较合理,那边布隆过滤器返回存在,但是不存在的概率是很小的。故我们可以使用这一特性。如新注册的用户除了需要写入到数据库中之外,同时更新用户ID到布隆过滤器。那么当我们需要查询某一个用户的信息时,先查询这个 ID 在布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这样就可以极大地减少异常查询带来的缓存穿透。
3、热点KEY问题,按照上文介绍的缓存更新方式(缓存+过期时间),当前KEY是一个热点KEY,有大量的并发请求并且重建缓存不能再很短时间内完成。那么在缓存失效的瞬间,有大量请求来重建缓存,造成后端负载加大,甚至雪崩。这个问题的根本原因是有大量的请求访问了后端存储,故我们可以从减少访问后端请求的角度解决问题:
第一种方法是互斥锁方案:此方法只允许同一时刻只有一个线程更新缓存,具体的是在更新的时候申请互斥锁,获取到锁的线程更新缓存,其他线程等待更新完成。这种方法思路比较简单,但是可能存在死锁的风险,并且线程池可能会堵塞。
第二种方法是永远不过期:从缓存层面,不设置过期时间,从而不会出现热点KEY过期后产生的问题;从功能层面,为每个value设置逻辑过期时间,当发现超过逻辑过期时间后使用单独的线程重建缓存。逻辑过期时间增加了代码复杂度和内存成本。
4、大量KEY同时访问的问题,按照上文介绍的缓存更新方式(缓存+过期时间),当有大量的KEY同时访问,那么他们的过期时间也是一样的,这个会导致很多缓存项同时过期,从而可能导致缓存的机器资源占用高(缓存在同一时间淘汰大量的缓存项),另外下次大量并发请求过来的时,就需要重建大量的缓存,从而导致缓存穿透甚至雪崩。解决办法也很简单,就是更新缓存的时候添加一个随机的过期时间(缓存+过期时间+随机时间)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。