2013年,我第一次接触Redis。
那时候Redis还不算太火,Memcached还是缓存的主流。但是我用了Redis之后,就被它征服了——数据结构丰富(string、hash、list、set、zset)、性能极高(单线程10万+ QPS)、支持持久化、支持主从复制。
从那以后,我就成了Redis的忠实粉丝。项目中能用缓存的地方,我都用Redis缓存——用户信息、商品信息、文章列表、排行榜、计数器、分布式锁、消息队列……我甚至一度觉得,"有了Redis,性能问题都能解决"。
但是,用了三年Redis,踩了各种坑之后,我才明白:缓存不是万能的。
缓存能解决很多性能问题,但是缓存也带来了很多新的问题——缓存穿透、缓存击穿、缓存雪崩、数据不一致、缓存污染、内存淘汰……如果用不好,缓存反而会成为系统的负担,甚至导致系统崩溃。
今天就来聊聊,Redis用了三年,我踩过的那些坑,以及我才明白的那些道理。
一、Redis的基本用法和好处
在聊坑之前,先简单说说Redis的基本用法和好处。
Redis的常用数据结构:
- String:字符串,最基本的数据结构,可以存字符串、数字、二进制数据。常用于缓存用户信息、商品信息、计数器。
- Hash:哈希,类似PHP的关联数组,适合存对象。常用于缓存用户信息、商品信息(一个key存一个对象的多个字段)。
- List:列表,有序可重复。常用于消息队列、最新列表、时间线。
- Set:集合,无序不可重复。常用于标签、共同好友、去重。
- ZSet:有序集合,每个元素有一个score,按score排序。常用于排行榜、积分系统、延迟队列。
Redis的好处:
- 性能极高:Redis是内存数据库,读写速度极快,单线程能达到10万+ QPS
- 数据结构丰富:不像Memcached只有string,Redis有5种基本数据结构,能满足各种场景
- 支持持久化:RDB和AOF两种持久化方式,重启后数据不丢失
- 支持主从复制:读写分离,高可用
- 单线程原子操作:所有操作都是原子的,不需要考虑并发问题
- 丰富的功能:除了缓存,还能做计数器、分布式锁、消息队列、排行榜、限流等
正因为Redis有这么多好处,我才成了它的忠实粉丝,项目中到处都用Redis。
二、踩过的坑
坑1:缓存穿透
缓存穿透是指查询一个数据库中不存在的数据,由于缓存中也没有,每次请求都会打到数据库,导致数据库压力过大。
比如,用户查询一个不存在的文章ID(如id=999999),缓存中没有,数据库中也没有。每次查询都会先查缓存(没命中),再查数据库(没数据),然后缓存也不存(因为没数据)。如果有人恶意大量查询不存在的ID,数据库就会被打垮。
我遇到的情况:有一次,我们的API被人恶意扫描,大量请求查询不存在的用户ID,导致数据库连接数飙升,差点宕机。
解决方案:
- 缓存空值:查询数据库不存在的数据,也缓存一个空值(如null或特殊标记),设置较短的过期时间(如5分钟)。这样下次查询同样的key,会直接命中缓存的空值,不会打到数据库。
- 布隆过滤器(Bloom Filter):在缓存之前加一层布隆过滤器,把所有可能存在的key都存在布隆过滤器中。查询时先查布隆过滤器,如果不存在,直接返回,不查缓存和数据库。
- 参数校验:对请求参数做校验,过滤掉明显不合理的请求(如id为负数、id过大)。
我们最后用了"缓存空值+参数校验"的方案,简单有效,解决了缓存穿透的问题。
坑2:缓存击穿
缓存击穿是指一个热点key,在缓存过期的瞬间,大量并发请求同时打到数据库,导致数据库压力过大。
比如,一篇热门文章,缓存了1小时。在缓存过期的那一刻,正好有1000个用户同时访问这篇文章。这1000个请求都会发现缓存没命中,然后同时去查数据库,同时写缓存。数据库瞬间承受1000个相同的查询,可能会被打垮。
我遇到的情况:有一次,我们的首页热点数据缓存过期,正好赶上流量高峰,大量请求同时打到数据库,数据库CPU飙升到100%,页面卡顿了好几分钟。
解决方案:
- 互斥锁(Mutex Lock):缓存没命中时,先加一个分布式锁,只有拿到锁的请求去查数据库并写缓存,其他请求等待然后重试读缓存。这样可以避免大量请求同时打到数据库。
- 永不过期:热点key不设置过期时间,而是后台异步更新缓存。这样缓存永远不会过期,就不会有击穿的问题。
- 提前续期:在缓存快过期的时候,后台异步提前刷新缓存。这样用户访问的时候,缓存总是存在的。
我们最后用了"互斥锁"的方案,用Redis的SETNX实现分布式锁,简单有效。
坑3:缓存雪崩
缓存雪崩是指大量缓存同时过期,或者Redis宕机,导致大量请求同时打到数据库,导致数据库压力过大甚至崩溃。
缓存雪崩有两种情况:
- 大量key同时过期:比如,批量设置了相同过期时间的key,在同一时间全部过期,大量请求打到数据库。
- Redis宕机:Redis服务挂了,所有缓存都不可用,所有请求都打到数据库。
我遇到的情况:有一次,我们做活动,批量缓存了大量商品数据,过期时间都设成了1小时。活动开始1小时后,这些缓存同时过期,大量请求打到数据库,数据库直接宕机了,活动页面打不开,损失惨重。
解决方案:
- 过期时间加随机值:设置缓存过期时间时,在基础时间上加一个随机值(如1小时+随机0-300秒),避免大量key同时过期。
- 多级缓存:本地缓存(如Caffeine、APCu)+ Redis缓存,Redis挂了还有本地缓存兜底。
- Redis高可用:Redis主从+哨兵,或者Redis Cluster,避免单点故障。
- 限流降级:数据库压力过大时,限流或者降级(返回默认值、缓存值、友好提示),保护数据库不被打垮。
我们最后用了"过期时间加随机值+Redis高可用+限流降级"的方案,再也没有发生过缓存雪崩。
坑4:数据不一致
缓存和数据库的数据不一致,是缓存最常见的问题之一。
比如,用户信息更新了,数据库更新了,但是缓存没更新,用户看到的还是旧信息。或者,缓存更新了,但是数据库更新失败,缓存和数据库不一致。
数据不一致的原因有很多:
- 更新数据库后,忘记更新缓存
- 更新缓存失败
- 并发情况下,读写顺序错乱
- 缓存过期后,读到旧数据
我遇到的情况:有一次,用户修改了昵称,数据库更新成功了,但是缓存更新失败了(网络抖动)。用户看到的还是旧昵称,投诉了好几次,我们查了半天才发现是缓存没更新。
解决方案:
- Cache Aside Pattern:最常用的缓存模式——读的时候,先读缓存,没命中再读数据库,然后写缓存;写的时候,先更新数据库,再删除缓存(不是更新缓存,是删除缓存)。
- 延迟双删:更新数据库后,先删除缓存,然后延迟一段时间(如500ms),再删除一次缓存。这样可以避免并发情况下的脏数据。
- 消息队列异步更新:更新数据库后,发消息到消息队列,消费者异步更新缓存。
- 设置合理的过期时间:即使数据不一致,缓存过期后也会重新从数据库加载最新数据。过期时间不能太长(不一致时间太长),也不能太短(缓存命中率低)。
我们最后用了"Cache Aside Pattern(先更数据库再删缓存)+ 合理过期时间"的方案,数据不一致的问题大大减少。
坑5:缓存污染
缓存污染是指大量不常用的数据被缓存,占用了Redis内存,导致真正的热点数据被淘汰,缓存命中率下降。
比如,一个爬虫批量爬取了网站的所有文章,每篇文章都被缓存了,但是这些文章大部分再也不会被访问。这些"僵尸缓存"占用了大量内存,导致真正的热点文章被淘汰,用户访问的时候缓存没命中,还要查数据库。
我遇到的情况:有一次,我们的网站被爬虫爬了,Redis内存使用率飙升到95%,触发了maxmemory策略,大量热点key被淘汰,缓存命中率从90%降到了50%,数据库压力大增。
解决方案:
- 设置合理的过期时间:所有缓存都设置过期时间,避免永久缓存。不常用的数据,过期时间设短一点。
- LRU/LFU淘汰策略:Redis的maxmemory-policy设置为allkeys-lru(最近最少使用淘汰)或allkeys-lfu(最不经常使用淘汰),让不常用的数据优先被淘汰。
- 限制缓存大小:给不同类型的缓存设置不同的内存上限,避免某一类缓存占用过多内存。
- 监控缓存命中率:监控缓存命中率,如果命中率下降,分析原因,及时调整。
我们最后用了"合理过期时间+allkeys-lru淘汰策略+监控命中率"的方案,缓存污染的问题得到了控制。
坑6:大key和热key
大key:指value很大的key,比如一个string存了几MB的数据,或者一个hash/list/set/zset有几百万个元素。大key会导致:
- 网络传输慢
- Redis阻塞(单线程,处理大key会阻塞其他请求)
- 内存占用大
- 淘汰大key时卡顿
热key:指访问量特别大的key,比如热门商品、热门文章。热key会导致:
- 单台Redis服务器压力过大
- 网络带宽瓶颈
- 缓存击穿(热key过期时)
我遇到的情况:有一次,我们把一个用户的所有文章(几千篇)存在一个hash里,value有几十MB。每次读取这个hash,Redis都会卡顿几秒钟,影响其他请求。
解决方案:
- 大key拆分:把大key拆分成多个小key,比如把一个大hash拆成多个小hash,按ID分片。
- 压缩:大value可以压缩后再存(如gzip、snappy),减少内存和网络传输。
- 热key多副本:热key复制多份,分散到不同的Redis服务器,或者本地缓存热key,减轻Redis压力。
- 监控:监控大key和热key,及时发现和处理。
坑7:Redis持久化和主从的坑
Redis虽然是内存数据库,但是支持持久化(RDB和AOF)和主从复制。这些功能也有坑。
RDB持久化的坑:
- RDB是定时快照,fork子进程生成快照。fork的时候,如果内存很大,会耗时较长,可能导致Redis短暂不可用。
- RDB快照间隔期间,如果Redis宕机,会丢失最后一次快照之后的数据。
AOF持久化的坑:
- AOF是追加写日志,文件会越来越大,需要定期rewrite(重写)。rewrite的时候也会fork子进程,可能导致短暂不可用。
- AOF的appendfsync策略,如果设为always,每条命令都刷盘,性能差;如果设为no,由操作系统刷盘,可能丢数据;everysec是折中方案,每秒刷一次,最多丢1秒数据。
主从复制的坑:
- 主从复制是异步的,主库宕机可能丢失数据。
- 从库同步延迟,读写分离时可能读到旧数据。
- 主从切换(故障转移)时,可能出现脑裂、数据不一致。
这些坑,我在使用Redis的过程中都遇到过。解决方案主要是:合理配置持久化策略、监控主从延迟、做好高可用(哨兵/集群)、重要数据不要只存在Redis。
三、缓存的正确使用姿势
踩了这么多坑,我总结了一些缓存的正确使用姿势:
1. 不是所有数据都要缓存
缓存不是万能的,不是所有数据都要缓存。以下数据不适合缓存:
- 频繁更新的数据(如实时库存、实时价格)
- 一致性要求极高的数据(如账户余额)
- 访问频率低的数据(缓存了也没用,反而占内存)
- 数据量太大且访问分散的数据(缓存命中率低,不如直接查数据库)
适合缓存的数据:
- 读多写少的数据(如文章、商品信息、用户信息)
- 热点数据(访问频率高)
- 一致性要求不高的数据(允许短暂不一致)
- 数据库查询成本高的数据(复杂查询、多表关联)
2. 设置合理的过期时间
所有缓存都应该设置过期时间,不要永久缓存。过期时间的选择:
- 热点数据:过期时间长一些(如1小时-1天),提高命中率
- 非热点数据:过期时间短一些(如5分钟-30分钟),避免缓存污染
- 一致性要求高的数据:过期时间短一些(如1分钟-5分钟),减少不一致时间
- 过期时间加随机值,避免大量key同时过期
3. 选择合适的缓存模式
常用的缓存模式:
- Cache Aside(旁路缓存):最常用,读先查缓存没命中查库再写缓存,写先更库再删缓存。适合大多数场景。
- Read Through(读穿透):缓存层负责读数据库,应用只和缓存交互。适合缓存层封装好的场景。
- Write Through(写穿透):写的时候同时写缓存和数据库,缓存层负责。适合写少读多的场景。
- Write Behind(异步写):写的时候只写缓存,异步批量写数据库。性能好,但是可能丢数据,适合对一致性要求不高的场景。
大多数场景用Cache Aside就够了。
4. 做好缓存异常的兜底
缓存不是100%可靠的,Redis可能宕机、可能超时、可能连接失败。要做好缓存异常的兜底:
- 缓存操作失败时,降级到直接查数据库
- 数据库压力过大时,限流或者返回默认值
- 多级缓存(本地缓存+Redis),Redis挂了还有本地缓存
- 熔断机制,缓存层故障时快速失败,不要拖垮整个系统
5. 监控和告警
缓存不是"加上就完事了",要做好监控和告警:
- 监控Redis的内存使用率、QPS、命中率、连接数、延迟
- 监控大key、热key、慢查询
- 监控缓存命中率,命中率下降时及时分析原因
- 设置告警阈值,异常时及时通知
四、写在最后
Redis用了三年,我才明白缓存不是万能的。
缓存能解决很多性能问题,但是缓存也带来了很多新的问题——缓存穿透、缓存击穿、缓存雪崩、数据不一致、缓存污染、大key热key、持久化和主从的坑……如果用不好,缓存反而会成为系统的负担。
但是,这不是说缓存不好。缓存是好东西,是高性能系统不可或缺的组件。关键是,要正确地使用缓存,了解缓存的坑,做好预案和兜底。
三年前,我觉得"有了Redis,性能问题都能解决";三年后,我明白"缓存是银弹,不是万能药"。缓存能解决很多问题,但是不能解决所有问题,而且缓存本身也需要精心维护。
最后,用一句话总结:缓存不是万能的,但是没有缓存是万万不能的。关键是,用对场景、用对方法、做好兜底。
愿每一个用Redis的开发者,都能避开这些坑,用好缓存,让系统又快又稳。愿Redis这只可爱的鲸鱼,载着你的缓存,畅游在高性能的海洋里。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录