缓存常见问题
2025-09-16 13:08:41

为什么需要缓存?缓存是什么?

在高并发的场景下,直接用数据库去抗是远远不够的,不管你的数据库是mysql还是oracle,不管用SSD还是磁盘阵列,不管采用怎样的分表分库都是很容易达到极限的,单机上千的QPS已经是一个比较的现实的天花板,而且成本太高了。

而且有些比较复杂的场景单纯的去采用数据库是不能实现要求的。

例如:

1。读多写少的场景,这种其实很尴尬的,数据库是力不从心 鞭长莫及。

比如文章流量:每日上亿的pv,每篇文章都会被大量的阅读,一次的insert的文章在短时间内会带来几百万甚至上千万的select。如果每次的阅读这种不善变的都直接干到数据库其实很傻的,数据库是为了记录数据不是为了这种简单查询场景设计的,这种场景下数据很容易撂挑子的。

2。需要复杂的计算得出的数据,数据库真的压力山大了。

比如排行榜:最新来访的排行,用数据库是可以实现的,大不了每次登陆或者刷新都来update一下数据么,可是当用户量过大的时候呢?看似update一个简单的数据因为量太大可能会导致整体数据库系统的瘫痪。

又比如附近的人:每个人都可能在移动这个update太频繁,mysql是可以通过插件实现这个功能,但是效率呢?

缓存就是为了应对这些场景应用而生的,诞生于高并发的互联网场景,为高速而生。也许它不能保证数据的100%的准确,也许它不能完全脱离数据库,但那些不是缓存要考虑的,它为了抗住高并发,有限的有损抗住流量洪峰,准确性可以异步慢慢操作。

缓存绝大多数存放在内存中(cpu的M1,M2,M3 cache其实也是缓存),内存的速度远不是硬盘能比的,硬盘就是SSD阵列也就是每秒百兆最快也就是G的级别。而内存已经达到数十G,双通道之后可以达到了上百G的级别。

缓存一次生产多次使用,避免每次读取都直接干到数据库。

如果说数据库主要是为了写,那么缓存主要是为了读,任务不同 场景不同。

缓存能够带来性能的大幅提升,以memcache为例,单台memcache服务器简单的key-value查询能够达到TPS 5千以上,而redis服务器的话可以再翻倍达到10万以上没有问题的。

基本架构:

img

每次的写入操作都是直接操作数据库,如果采用一些同步组件的话可以直接异步写入缓存。

而每次读取数据的操作一般是先读取缓存层,如果有就直接返回,没有话程序去读取数据库再写入到缓存然后再返回数据。其实这样会有些问题,后面再讲办法。

缓存使用中有哪些问题?

1. 数据不一致

这个数据不一致主要是缓存和数据库不一致,前面讲基础架构里面有使用的方法,这种方法有可能会产生脏数据:

当年在趣头条前期的时候(那时候是爱魔豆),为了快速开发而设计的比较简单,导致后期整天遇到数据不一致的情况,白天处理校对数据,晚上开发需求,一个人搞的焦头烂额。

数据一致性有以下三个要求:

  • 缓存不能读到脏数据。

  • 缓存可能会读到过期数据,但要在可容忍时间内实现最终一致。

  • 这个可容忍时间尽可能的小。

例如现有三个请求:写入数据A和C和读取数据的B。都对同一个数据进行读写操作。

  • A先写入缓存再写数据库会怎样?

答:有可能缓存写完程序出错退出,数据库写入失败,此时B读取的缓存就是脏数据。

  • A先写入数据库再写入缓存呢?

答:A先写,然后C继续写,接着C写入缓存,然后某种原因A写入缓存的步骤来晚了再去写入缓存,B去读缓存的时候就是A后面写入的脏数据了。

  • A只管写数据库,然后删除缓存,等B来读的时候发现没缓存再去读取数据库同时写入缓存呢?

答:还是不行,因为这么操作流量大的时候可能都发现没缓存击都去读取数据库导致直接打垮数据库。或者B读取完毕的时候,C开始写数据库,然后C先同步缓存成功,然后B再去写入缓存,结果可想而知还是脏数据。

那怎么办?

  • 最好的方式采用订阅监听binglog方式异步串行处理

比如采用阿里开源的中间件canal可以完成订阅binlog日志的功能。

mysql主从时会在主机修改后写入binglog日志,然后同步到从服务器。这些组件伪装成从服务器,从收到binglog日志里面解析出原始的修改,然后再去从容的修改缓存。异步串行处理让复杂的事情变得简单了。img

局限性:

a. 为了保证高效,这个最好不要做复杂的逻辑,比如聚合 连表 统计等等,不然的话影响性能。一般都是原表的复制或者是某些字段的复制。需要提前配置好,太不灵活,对于业务开发人员不够友好。

b. 有一定的延迟性。这个其实还好的,一般都是毫秒级的同步。但是需要注意监控,出现问题最好及时人工处理。

  • 缓存引入版本号。

设计表的时候增加版本号字段,每次update其他字段的时候增加set version=version+1,每次写入修改后,再去读取一把获取数据以及最新的version字段,再去修改缓存,如果发现缓存的中的版本比较高就不去修改缓存,如果现有缓存比较低就修改缓存。

局限性:

只有部分缓存支持,比如我腾讯的CKV等。而memcache和redis都不支持,如果采取读取缓存再去判断再去操作缓存,就变成非原子性的操作,一样的有不同步的风险。

  • 写入缓存前加锁

先加锁(一定要带失效时间),再去查询数据库,再去写入缓存,然后删除锁。其他需要写入缓存的操作一定要先判断是否有锁,有锁就休眠一会再去重复刚才的动作。

局限性:

a。锁的失效时间一定要设置,还要设置的比较恰当,不然的话会出问题,推荐5-10s吧。

b。 只要是锁都是有风险的,都会影响点性能的。

综上所述 :

最好的还是监听binglog异步处理,其次如果采用缓存支持版本号可以使用版本号,再其次采用加锁。当然了如果要求不算太高可以先删缓存->写数据库->读取数据库->写缓存。Facebook选择了这个方案。

2. 缓存穿透(查根本不存在的数据)

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据,如果id是id是hash的更容易缓存不命中。这时的用户很可能是攻击者,攻击会导致数据库压力过大,很可能搞死数据库。

由于缓存中没有这个数据,请求会直接打向数据库。而数据库中也查不到这个数据,所以数据库也不会将这个结果写入缓存。结果就是,每一次对这个不存在的数据的请求,都会穿透缓存,直接访问数据库。如果攻击者利用大量这种请求进行并发攻击,就会给数据库带来巨大的压力,甚至导致数据库宕机。

怎么去防范?

  • 在接入层做好数据的校验,比如id要是正整数,否则返回错误。

  • 每次读取数据库后不管是否读取到数据,均设置到缓存中,比如把null设置到缓存里同时要设置缓存失效时间的。这样在失效前下次再来请求不去查询数据库的。

  • 很多教程只是说了前面两种,其实这是不够的,因为恶搞的人不傻,id最大才999999 但可能传过来的id是9999991,9999992,9999993。。。既合法又不重复其实没有的数据id,那要怎么办?你可能会想到一直维护一个maxId,每次update数据库都要维护一下,那就成本太高了。这就要涉及到频控(最好外加服务降级),比如ip的维度等等,不管是不是恶意都要控制到可靠的范围内,这样才能比较可靠。

    • 频控:这是指限制请求的频率。我们不关心你请求的数据是否存在,而是关注你的行为模式。
      • “ip的维度”:一个非常常见的策略是限制同一个IP地址在单位时间内的请求次数。例如,规定一个IP每秒最多只能请求20次。无论是正常用户手速过快,还是恶意程序攻击,只要超过这个阈值,就直接拒绝服务或返回一个错误码,根本不让请求到达缓存或数据库。这能从源头上掐断大部分攻击流量。
    • 服务降级:这是一种系统保护机制。当检测到系统负载过高或正在遭受攻击时,为了保证核心功能的可用性,可以暂时关闭或简化一些非核心服务。在缓存穿透的场景下,如果频控系统检测到来自某个IP的异常流量,可以触发服务降级,例如暂时封禁该IP,或者对来自该IP的请求直接返回一个预设的默认值,而不再查询后端。

3. 缓存击穿(高频次查刚失效的缓存)

缓存击穿,就是说某个key非常热点,访问非常频繁(一个爆款商品的详情页、一则头条新闻),处于集中式高并发访问的情况,当这个key在失效的瞬间(缓存数据都有一个过期时间,问题就发生在这个Key的TTL刚好到期,缓存把它删除的那一刻),大量的请求就击穿了缓存,会直接请求数据库,就像是在一道屏障上凿开了一个洞。

解决方法:

  • 可以将高频次的热点数据设置为永远不过期,比如全局配置(对于某些极端重要的系统级配置,可以把缓存作为其唯一数据源,如果连缓存里都没有,就认为系统出了严重故障,直接报错,而不是去查数据库),挡在最前面即便没了缓存也是不应该查询数据库的,直接抛出错误返回。
    • 带来的新问题:数据一致性。如果数据永远不过期,那么当数据库中的原始数据发生变化时,缓存里的数据就成了“脏数据”。因此,这个方案必须配套一个数据更新机制。常见的做法是:
      • 主动更新:在后台系统修改了数据库中的数据后,主动发出一个命令来删除或更新缓存中对应的Key。
      • 消息队列:通过消息队列(如Canal订阅数据库binlog),当数据发生变更时,自动通知缓存进行更新。
  • 基于Redis或者Zookeeper实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该key访问数据。
    1. 缓存未命中:一个请求过来,发现缓存中没有数据(Key已过期)。
    2. 尝试加锁:它不去直接查数据库,而是先尝试获取一个与该Key关联的分布式互斥锁(例如,使用Redis的SETNX命令)。
    3. 获取锁成功:如果成功获取到锁,那么这个请求就成了“天选之子”。它负责去数据库查询数据,然后将查到的数据写回缓存,最后释放锁。
    4. 获取锁失败:如果没获取到锁,说明此时已经有另一个请求(“天选之子”)在负责重建缓存了。这个请求就不会去打扰数据库,而是进入等待状态。它可以短暂休眠一会,然后重新尝试从缓存中获取数据(而不是再次尝试加锁)。因为等它醒来时,第一个请求很可能已经把缓存重建好了。

4. 缓存雪崩(缓存故障大批量甚至整体查询都去数据库)

由于缓存服务器意外故障,也可能是网络问题也可能宕机,导致全部请求直接落到数据库。由于数据库的并发能力并不能抗住大量并发请求,因此数据库直接瘫了。即便是重启灰度数据库也会立刻再次被打死,这被称为缓存雪崩。

解决方法:

  1. 集群!集群!集群!重要的事情说三遍,只是采用主从模式是不够的的。
    • 通过部署缓存集群来避免单点故障。例如,使用 Redis Sentinel (哨兵模式) 或 Redis Cluster (集群模式)。这些方案能提供自动的故障检测和故障转移。当集群中的某个主节点宕机时,系统会自动将一个从节点提升为新的主节点,继续提供服务。整个过程对应用层是透明的,从而避免了缓存服务的整体中断。
    • 为什么主从不够:简单的主从复制主要用于数据备份和读写分离。如果主节点宕机,需要手动介入才能将从节点提升为主节点,这个过程会产生服务中断,无法有效防止雪崩。而哨兵或集群模式则实现了自动化的故障转移。
  2. 即便是集群也不是不靠谱的,最好采用多级缓存比如ehcache缓存,或者服务在本机缓存等等(后面有举例)。
    • 在应用服务器内部,使用一个内存缓存库(例如Java中的Caffeine/Ehcache,或者本项目中的QCache)。请求的流程变成:
      • 先查找本地缓存。
      • 本地缓存未命中,再查找远程的Redis集群。
      • Redis也未命中,才去查询数据库。
  3. 老生常谈:限流+服务降级。死一个服务总比全死强,丢掉一部分总比全丢强。

在 redis 中,实现 高可用 的技术主要包括 持久化、复制、哨兵 和 集群,下面简单说明它们的作用,以及解决了什么样的问题:

持久化:持久化是 最简单的 高可用方法。它的主要作用是 数据备份,即将数据存储在 硬盘,保证数据不会因进程退出而丢失。

复制:复制是高可用 redis 的基础,哨兵 和 集群 都是在 复制基础 上实现高可用的。复制主要实现了数据的多机备份以及对于读操作的负载均衡和简单的故障恢复。缺陷是故障恢复无法自动化、写操作无法负载均衡、存储能力受到单机的限制。

哨兵:在复制的基础上,哨兵实现了 自动化 的 故障恢复。缺陷是 写操作 无法 负载均衡,存储能力 受到 单机 的限制。

集群:通过集群,redis 解决了 写操作 无法 负载均衡 以及 存储能力 受到 单机限制 的问题,实现了较为 完善 的 高可用方案。

5. 缓存爆满(由于预估不足,内存设置不足,内存使用占满)

redis的淘汰策略算法有6种:

  1. volatile-lru:从设置了过期时间的数据集中,选择最近最久未使用的数据释放。

  2. allkeys-lru:从数据集中(包括设置过期时间以及未设置过期时间的数据集中),选择最近最久未使用的数据释放。

  3. volatile-random:从设置了过期时间的数据集中,随机选择一个数据进行释放。

  4. allkeys-random:从数据集中(包括了设置过期时间以及未设置过期时间)随机选择一个数据进行入释放。

  5. volatile-ttl:从设置了过期时间的数据集中,选择马上就要过期的数据进行释放操作。

  6. noeviction:不删除任意数据(但redis还会根据引用计数器进行释放),这时如果内存不够时,会直接返回错误。

其实很多时候拿redis当mysql去用了,很多时候都是不失效的。这就导致了redis使用内存一直暴涨,如果内存设置的不够大那就有严重的问题了。

当年在趣头条的时候有天傍晚高峰期全线服务告警,没有一个服务正常的,顿时懵逼慌了神,不知道哪里的问题了。最后仔细分析才发现:redis满了!由于所有的服务基本都用了redis,导致全部写入不成功。

2019年初cisg企点webim也是出现类似的问题,webim的离线消息采用的redis的list结构作为存储。前期同事预估不足采购的redis太小(才2G),导致整体webim的离线消息不能正常存储,又一个惨痛运营事故!

解决预案(其实不能叫解决方法):

  1. 监控!监控!监控!其实即便不是这个问题也是需要对redis整体监控的,内存吃紧的时候尽早知晓!那么监控什么?

    • 内存使用率:这是最核心的指标。当内存使用率超过一个阈值(例如80%)时,就应该触发告警。

    • Key的数量:监控Key的总数,如果增长速度异常,可能意味着有新的业务上线或者存在逻辑错误(例如,产生了大量不应缓存的Key)。

    • 淘汰的Key数量 :如果这个指标持续很高,说明缓存空间严重不足,正在频繁地进行数据淘汰,这会降低缓存命中率。

    • 命中率 :命中率的突然下降,也可能是缓存空间不足的间接信号。

  2. 集群也是要分开的。不是说一个集群大家一股脑的混在一起去用,一个爆掉全部爆掉(如果所有业务、所有类型的缓存数据都共用一个大的Redis集群,那么任何一个业务的滥用都可能占满整个集群的内存,从而影响到所有其他业务)。分开存储也是未来容易拆分迁移,可以细分。有些redis的key确实需要持久的可以一起存储,有些业务缓存有失效期可以一起存储,或者可以根据微服务模块的方式隔离存储。

    1. 按业务模块隔离:为不同的微服务或业务线分配独立的Redis实例或集群。例如,用户服务的缓存和订单服务的缓存使用不同的Redis。

    2. 按数据类型/重要性隔离:

      • 核心数据/持久化数据:需要持久化(RDB/AOF)的关键数据,放在一个高可用的集群里。

      • 临时缓存数据:有明确过期时间的、丢了也没关系的业务缓存,放在另一个集群里。

  3. 随时准备好扩容,比如采用云redis集群。尽量不要自建集群,维护起来成本比较高。

6. ⭐网卡打满(缓存故障大批量甚至整体查询都去数据库)

这个估计真的没多少人遇到过了,又是一件自己惨痛的教训。

系统中存在一些被极高频访问的核心数据,例如全局配置、权限列表等。在业务高峰期,海量的并发请求会集中地访问这一个或少数几个Key。每一次缓存访问,无论数据包大小,都会产生网络I/O,当对同一个Key的请求频率达到极限时,其请求和响应流量的累积值会超出服务器网卡的物理带宽上限。

当请求频率达到一个极高的水平时,这些为同一个Key产生的网络流量叠加起来,会超出服务器网卡的物理带宽上限。就像一条高速公路被过多的车辆堵死一样,网卡无法处理更多的网络包,导致新的请求被丢弃或严重延迟。

从应用的角度看,现象就是Redis响应极慢、大量请求超时、最终导致依赖Redis的所有服务全部瘫痪。这个问题非常难以排查,因为从Redis的CPU、内存监控来看可能一切正常,瓶颈出在了物理网络层。

又是在趣头条又是在高峰期,又是对外业务全部不正常了,一时半会不知道哪里的问题整个人汗都出来了。最后才发现网卡app的系统配置读取的太频繁,基本上每次请求都要来读取而且不是一次读取。

如何发现热key :

a. 凭借业务经验,进行预估哪些是热key

其实这个方法还是挺有可行性的。比如某商品在做秒杀,那这个商品的key就可以判断出是热key。缺点很明显,并非所有业务都能预估出哪些key是热key。

b. 在客户端进行收集

在应用代码中,每次调用Redis命令前,都加入一行统计代码,记录被访问的Key。那么这个数据统计的方式有很多种,也可以是给外部的通讯系统发送一个通知信息。缺点就是对客户端代码造成入侵。

c. 在Proxy层做收集

如果Redis架构中使用了代理层(如Twemproxy, Codis),可以在代理层做统一的流量统计。优点是无侵入性,但缺点是并非所有公司都使用代理架构。

d. 用redis自带命令

  • monitor命令,该命令可以实时抓取出redis服务器接收到的命令,然后写代码统计出热key是啥。当然,也有现成的分析工具可以给你使用,比如redis-faina。但是该命令在高并发的条件下,有内存增暴增的隐患,还会降低redis的性能。

  • hotkeys参数,redis 4.0.3提供了redis-cli的热点key发现功能,执行redis-cli时加上–hotkeys选项即可。但是该参数在执行的时候,如果key比较多,执行起来比较慢。

e. 自己抓包评估

redis客户端使用TCP协议与服务端进行交互,通信协议采用的是RESP。自己写程序监听端口,按照RESP协议规则解析数据,进行分析。缺点就是开发成本高,维护困难,有丢包可能性。

一旦定位到热点Key,就需要从架构层面解决流量过于集中的问题。解决方法:

a. 发现不对了赶紧做的是限流和服务降级,丢掉一部分请求先保证部分功能和请求的正常。

b. 将识别出的热点Key从原来的大集群中迁移出来,用一个独立的、专门的Redis实例或小集群来为它服务。这样,即使这个热点Key的流量再大,也只会打满它自己服务器的网卡,不会影响到其他业务的缓存。

c. 来个投机取巧的方式:每个业务服务器上部署一个redis做从机,做到缓存的分级。高频次访问的全局设置直接可以读本机的redis从机,因为是本机访问,没有网络开销,速度极快且绝不会打满网卡。只有当本机Redis出现问题时,才去访问远程的Redis集群。

有人说这个方法太挫,比如扩容不便等,但是对于扩容这种低概率的事件,用这个方法最重要的解决缓存的主从同步,redis的主从同步帮你解决了。如果自己做一套内存缓存,那么你这个主从同步怎么办?做不好就是大量脏数据或者缓存击穿了.

d. 其实最主要的是架构师的预估,对自身业务要心里有谱,就在设计的架构的时候就做好隔离,等到病了再想去吃药有点晚了。

总结 :

image-20250904110837527

上一页
2025-09-16 13:08:41