要说这几年后端开发里最绕不开的技术组件,Redis绝对排前三。不管你是写Java、Go还是Python,面试要问缓存设计,项目要撑高并发流量,最后多半都会落到Redis头上。但说句实在话,很多人对Redis的理解只是停留在“缓存数据库”这五个字,真要动手去解决问题的时候,总是差那么口气。
这篇文章不打算从官网配置开始逐条罗列,而是从一个实际干活的角度,把Redis是什么、能干什么、怎么用好这几个事讲清楚。无论你是刚入行的新人,还是已经被Redis坑过几次的老开发,都应该能从里面找到点有用的东西。尤其是那些在热搜词里出现的场景,比如redis数据类型、redis分布式锁、docker安装redis主从、redis哨兵模式、redis缓存治理之类的内容,我都会结合真实项目经验展开聊。
1. Redis到底是什么——先把它从“缓存”的刻板印象里解放出来
1.1 一句话说清楚Redis的定位
Redis的全称叫Remote Dictionary Server,直译过来是“远程字典服务器”。这个名字其实已经把它最核心的定位说清楚了:一个基于内存的、以键值对形式存储数据的服务。但“键值对存储”这个说法在今天看来已经远远不够了,因为Redis支持的不仅仅是简单的String字符串,还有Hash、List、Set、ZSet(有序集合)等等,每一种数据结构都有自己的专属应用场景。
官方的形容是“数据结构服务器”。这比“缓存数据库”要准确得多。你把它当缓存用,它是一个高性能的缓存系统;你把它当队列用,它的List和Stream也能顶一阵子;你把它当数据库用,它能持久化到磁盘并在重启后恢复数据。所以不要把Redis想象成只能放临时数据的“内存仓库”,它更像是一把多功能的瑞士军刀,关键看你拿它来切什么。
我在实际项目里见过不少刚接触Redis的同事,第一反应都是“这不就是查一下再存一下的事吗”。其实这种看法低估了Redis的工程价值。一个真正用好的Redis,能在架构里扮演缓存、消息中间件、分布式锁服务、排行榜引擎、计数统计中心等多个角色,它的边界远比“缓存”这两个字要宽。
1.2 为什么Redis能这么快
很多人第一次接触Redis都会问:为什么它这么快?一个单线程的程序,怎么就能扛住每秒几十万的读取量?
先说最核心的一点:Redis的数据在内存里。内存本身的读写速度是纳秒级别的,这跟磁盘那种动不动就要做随机IO的存储完全不同。缓存的意义就是把热点数据放到离CPU更近、访问更快的地方,Redis正是干这件事的。
其次,Redis经典版本采用的是单线程事件循环模型。这里有个误区,有人一听“单线程”就觉得这一定是性能瓶颈,但实际上单线程在Redis的场景里有它独特的优势:没有多线程之间的锁竞争,没有线程上下文切换的开销,也不需要考虑共享数据一致性的问题。加上Redis内部用IO多路复用技术处理网络连接,一个线程就能同时管理成千上万个客户端连接,把等待IO的空闲时间都利用起来了。
说到底,Redis的性能瓶颈几乎只可能出现在网络带宽或者内存大小上,CPU通常不是你需要操心的问题。这也是它能做为“缓存层”站在数据库前面,替数据库挡掉大量重复读请求的原因。我测过一台普通的4核8G云服务器,单机Redis可以轻松扛住8万以上的QPS,这个性能在大多数业务场景下完全够用了。
1.3 Redis和Memcached,到底差在哪
聊Redis不对比一下Memcached似乎不太完整。尽管现在主流新项目基本都直接用Redis了,但如果你去翻老项目的代码,还是能看到Memcached的影子。
两者最大的区别在于数据结构的丰富程度。Memcached基本只能存简单的String类型,追加删除操作都很原始;而Redis从诞生起就带着丰富的数据结构清单,这让它能应对各种复杂业务逻辑而不只是“存一段文本”。其次是持久化能力,Memcached挂了就是数据也没了,重启之后只能冷启动重新缓存;Redis则支持RDB快照和AOF日志两种持久化方式,重启之后能把数据恢复回来。
还有一个很重要的点:高可用方案。Memcached在分布式场景下需要客户端自己做一致性哈希来分片,维护成本不低。Redis则原生提供了主从复制、哨兵模式、Cluster集群模式,这些机制能让Redis在节点故障时自动转移流量,保证服务可用性。单凭这一点,Redis在生产环境里的地位就没办法被Memcached替代。
2. 数据结构是Redis的第一生产力
2.1 String:最基础但也最容易被低估
String是Redis里最基础的数据结构,存一个字符串、数字、JSON序列化后的对象,都是用它。但很多人只把它当成一个简单的键值对在用,却不知道String类型里藏着很多高效的原子操作。
比如INCR、DECR、INCRBY这些命令能在内存里直接对数字做自增自减,不需要先GET回来改完再SET,整套操作是原子的,天然线程安全。这个特性在做计数器场景时非常有用,比如文章的浏览量、商品的库存扣减、用户积分增减,都能直接用几条命令搞定,连分布式锁都不需要加。
另外String类型有个容易被忽略的用法:SET key value EX seconds可以把设置值和过期时间放在一条命令里原子执行,这在不希望因为两步操作之间出间隙导致问题的场景下特别有用。后面讲分布式锁还会提到它。
我在项目里用String存过用户登录令牌、短信验证码、接口返回数据的临时副本、灰度开关配置,基本覆盖了大多数业务需求。如果连String都还没用熟练,不建议急着研究高级功能。
2.2 Hash:Java开发者的对象存储利器
Hash在Redis里是一个field-value对集合,通俗点说就是一个“里层还有键值对”的结构,非常适合存储一个对象的多个属性。
举个最常见的例子:用户信息。如果直接用String存,你得把整个用户对象序列化成JSON,要么整存整取,要么拆成多个key分别存。用Hash就方便多了,每个用户对应一个Redis key,用户ID下面再挂用户名、头像、积分、等级这些filed。想更新某个字段时,一条HSET命令就能完成,不用把整个对象读出来再反序列化改写再存回去。
这种结构的读取效率也很高,HGETALL能一次拿到整个对象,HGET只需取某一个字段。在缓存数据库表记录时,我一般优先考虑Hash,因为在字段多的场景下它的内存占用和维护成本都比多个String要划算。
有一个实际经验可以分享:用Hash缓存数据库行时,可以在每个字段前面加上查询时需要单独使用的索引字段,形成一个“字段级别的缓存单元”。这样部分更新时不会影响其他字段,并发修改同一个对象的不同字段也不会互相覆盖。
2.3 List:消息列表和轻量队列的底子
List是一个双向链表结构,支持在头部和尾部进行push/pop操作。正因为这种特性,它可以用两个命令的组合实现一个非常轻量的消息队列:生产者用LPUSH往列表左侧写入消息,消费者用BRPOP从右侧阻塞式读取。这里的B代表block,就是说如果没有数据消费者会一直等着,不会白白轮询浪费CPU。
List的另一类典型场景是“最新消息列表”,比如新闻资讯的最近动态、用户的操作日志,每次有新增内容就往列表头部推一条,再用LRANGE key 0 9取出前10条,效率非常高。比起去数据库里查ORDER BY create_time DESC LIMIT 10,这种方式的响应时间要低一个数量级。
但也要说清楚边界:List适合做轻量级队列,业务量特别大、需要消息确认、需要延迟消息、需要消息回溯的场景,还是老实交给专业的消息队列中间件比如RabbitMQ、Kafka去处理。拿Redis硬扛复杂队列场景,迟早会被各种边界问题折磨。
2.4 Set:去重、交集与随机抽奖
Set是一个无序的、元素不可重复的字符串集合,它最大的价值是“天然去重”和“集合运算”。
最常见的应用场景是标签系统。比如一个用户的兴趣标签可以存成一个Set,一篇博客的所属分类标签也能是Set。用户之间共同关注了哪些账号,直接SINTER一次就能算出交集,不用写一层层循环去数据库里比对。要做“你可能认识的人”这种功能时,这种集合运算能让代码简洁很多。
Set还支持SPOP和SRANDMEMBER命令,能随机返回集合中的元素。用这个特性就能轻松实现一个抽奖功能:把所有参与用户ID加入一个Set,然后用SPOP弹出一个用户作为中奖者,弹出后TA就自动从集合中移除了,天然保证一个人不会被重复抽中。
我记得有个项目里做“好友共同关注提醒”,原来用SQL查了两层才算出共同关注数,接口耗时稳定在300毫秒左右。后来改成把关注关系同步到Redis Set里,用SINTERCARD直接数交集数量,耗时降到10毫秒以内。这个对比让团队里不少人都对Redis的数据结构有了新的认识。
2.5 ZSet:排行榜场景的终极答案
ZSet又叫有序集合,和Set一样不允许重复元素,区别在于每个元素还关联了一个score(分数)。Redis按照score从小到大为集合中的元素排序,这就让排行榜类需求变得非常简单。
做“热榜”、销售排行、积分排行,核心逻辑就一条命令:ZADD key score member,往集合里加元素并带上分数;要查询前N名,ZREVRANGE key 0 N-1 WITHSCORES,倒序取出前N个元素和分数。更新分数时用ZINCRBY能在原分数基础上增加,不用先查出来再写回去,是原子的,天然能处理并发加分的问题。
ZSet还可以做延时队列。score存任务的执行时间戳,定时任务每隔几秒用ZRANGEBYSCORE key -inf now取所有到期的任务,取出来处理后删除。这个模式在实现订单超时关闭、定时提醒等场景时都很顺手,比用轮询数据库要高效得多。
实际项目中我做得最多的还是排行榜,从一套通用的排行榜服务起步,接入了社区积分排行、商城热销排行、视频热门排行多个业务,底层都是ZSet。写一套代码,所有排行榜类需求都能复用。
2.6 容易被忽略的三种高级结构
除了五大基础结构,Redis还提供了几种针对性很强的数据类型,平时讨论得不如String、Hash多,但在特定场景下能解决的问题会让你眼前一亮。
Bitmap(位图)本质上不是一个独立类型,它是String类型的一种位运算视角,但用SETBIT、GETBIT可以对一个字符串的每个二进制位进行操作。签到场景特别适合用Bitmap:一年365天记住365天,每天签到置为1,统计时用BITCOUNT数一下有多少个1,整年签到的存储空间只需要不到50个字节,比存365个key要省无数倍。在线状态也可以用同样的思路来做,用户量上亿时这种极致的内存节省显得很值钱。
HyperLogLog是专门用于基数统计的结构。什么叫基数?就是集合里不重复元素的个数。做UV统计时,访问量再大,每个用户ID只需要很少的内存就能完成去重统计,标准误差在0.81%左右,对于绝大多数数据分析场景这个误差完全在可接受范围内。当然,如果业务要求精确去重,就不适合用HyperLogLog了,得回到Set或者引入其他方案。
Geo是地理位置类型,用GEOADD把经纬度存进去,用GEOSEARCH能查出某个坐标附近的元素并按距离排序。“附近的人”“附近的店”这类功能用Geo做会非常简洁,底层也是ZSet实现的,所以能支持范围查询和排序。
这些数据类型不是让你全部背下来,而是要让自己的工具箱里有这些东西。等业务场景出现时,适合用什么结构一目了然,而不是什么都往String里塞。
3. 生产环境里Redis最常见的几种玩法
3.1 缓存加速:用好缓存不是把数据塞进去就行
Redis在项目里最核心的任务就是做缓存。但有太多项目“接入缓存”等于“把要读的数据全部塞进Redis”,没有思考哪些数据该缓存、缓存多久、数据变了怎么更新。结果往往是缓存命中率很低,缓存没起到挡流量的作用,反而因为多了一层网络访问让接口变慢了。
一个合格的缓存方案,至少要回答这几个问题:
- 哪些数据适合缓存?通常是读多写少、访问频繁、实时性要求不高的数据。
- 缓存多久?设置过期时间能避免数据永久占用内存,但过期时间的设置需要一个权衡,太短会频繁回源数据库,太长会导致数据不能及时更新。
- 数据更新时缓存怎么处理?是主动删除缓存让下次读取时回填,还是在数据库更新后同步更新缓存,或者干脆删了让后面再建。
最简单的做法是Cache Aside模式:读的时候先查缓存,缓存没有再去查数据库,拿到结果后回填Redis并设置过期时间;写的时候更新数据库后删除对应缓存。这个模式是很多团队的基础缓存策略,它的好处是逻辑清晰,不容易出现数据库和缓存长时间不一致的情况。
但是这里也有个小坑:删除缓存的时机要在数据库事务真正提交之后。如果先删缓存再更新数据库,中间来一个读请求,就很容易把旧数据回填进去,新数据反而被“压住”了。我现在通常是数据库更新成功后,直接把对应key删掉。如果担心删缓存失败造成脏数据,可以考虑延时双删,也就是删完之后下沉一个延迟任务再过几百毫秒删一次,双保险。
3.2 分布式锁:看似简单实则坑多
做微服务、多实例部署后,本地锁synchronized就不够用了,因为不同实例的JVM锁是互相不可见的。这时候需要用分布式锁来保证同一时刻只有一个实例能执行关键代码,比如库存扣减、订单支付回写、定时任务抢单执行。
用Redis做分布式锁是大家最熟悉的方式,但里面藏着不少坑。
很多年前大家习惯的做法是SETNX key value加锁,然后EXPIRE key seconds单独设置过期时间。这两个操作合在一起不是原子的,一旦在设置过期时间前程序挂了,这个锁就永远不释放,后面的请求全被堵死。现在的正确姿势是用一条SET key value NX EX seconds命令,让加锁和设置过期时间在原子操作中完成。
但设置过期时间本身又引入另一个问题:如果一个线程加锁后业务执行时间超过了锁的过期时间,锁被自动释放了,另一个线程乘虚而入拿到锁,这时前一个线程执行完后又去释放锁,结果把别人的锁给释放了。这个问题的标准解法是给value设置一个唯一标识,释放前先比较value是否是自己设置的那个,比较和删除的过程需要用Lua脚本来保证原子性。
后来有了Redisson这个客户端库,它实现了“看门狗”机制:加锁成功后,后台会有一个定时任务不断给锁续期,只要业务没执行完,锁就不会被过期,逻辑上规避了“业务时间超过锁过期时间”的困境。这也是我现在做分布式锁的默认选择,除非是一些特别简单的场景,不想引入额外依赖才会手写原生命令。
另外要提醒一下:分布式锁只能保证互斥,不能保证业务一定能成功。拿到锁之后执行的操作本身还是要做幂等设计,锁只是帮你挡住了并发,真正的数据一致性还是得靠业务逻辑自己兜底。
3.3 会话保持与登录态共享
传统单机项目里,用户登录状态通常存在服务器本地的Session里。做了负载均衡,请求被分发到A机器时登录状态在A机器,第二次请求被分发到B机器,B机器上找不到Session,用户就变成“未登录”了。
解决这类问题有三种常见思路:一是做“会话粘滞”,让同一IP的请求都固定发给同一台机器,但这在机器宕机时依然会出问题;二是用Session集群同步,但同步本身有性能损耗;第三种就是把登录态挪到Redis里,所有人共用同一个Session存储。
Spring Session框架最擅长的就是这个事,它能把原本存在Tomcat里的Session序列化到Redis,对业务代码近乎透明。实现“强制下线”之类功能时,只要从Redis里把对应用户的Session删掉就行,非常方便。
另外,Token也是一样的思路,把Token作为key,登录用户信息、有效期作为value存在Redis里,接口鉴权时只查Redis就能拿到完整用户上下文,不用每次去数据库查用户表。这种设计既能支撑大规模并发访问,又方便用户体系从单系统扩展到多应用间的SSO登录。
3.4 排行榜与限流计数
排行榜在2.5节已经详细讲过ZSet方案,这里想补充说明的是Redis在高并发计数场景下的应用远不止排行榜一个。
比如接口限流,最简单的固定窗口限流可以用INCR配合过期时间来实现:以当前分钟为key,每次请求执行INCR,第一次执行的时候顺手设置一下过期时间为60秒,如果在同一个key上的计数超过阈值就拒绝请求。更平滑的滑动窗口限流,可以结合ZSet,每次请求把当前时间戳插入ZSet,然后用ZREMRANGEBYSCORE清理旧数据,ZCARD查看窗口内的请求量。不过如果服务规模比较大,还是建议用专门的限流组件,Redis适合做业务级的轻量限流,不适合硬扛所有基础设施层的限流逻辑。
还有一个运用极其广泛的场景是“点赞/收藏统计”。传统的做法是每次点赞都写数据库,一旦出现热点内容,数据库的压力会非常大。用Redis做前置计数层:点赞数、收藏数、浏览数全部先在内存里自增,后台定时把数值回写到数据库。这样前台用户看到的数字是实时的,数据库也不至于被打穿。但这里要留意,Redis里的计数如果丢了,后台和前台会不一致,所以回写频率要做合适的权衡,同时即便Redis重启,也应该能从数据库恢复出比较接近真实值的数。
3.5 消息队列:Redis能行,但要认清边界
上一节讲过基于List可以实现一个简单的消息队列。这里继续把这个话题展开,因为实际项目中真的会遇到“不想为一个小功能专门引入Kafka”的纠结时刻。
List做队列最大的优势就是简单,生产端LPUSH,消费端BRPOP,不再需要额外部署任何中间件。对于并发量不高、消息总量不大、允许少量消息丢失的场景,这个方案完全够用。我在一些内部系统里用它传过异步通知任务、数据导出任务,跑了一年多也没有出过岔子。
但Redis 5.0版本以后,官方自己也觉得List做队列不够专业,所以推出了Stream类型。Stream支持消费者组、消息确认、消息回溯等这些专业消息队列才有的特性,某种程度上Redis已经具备了成为一个轻量消息队列管理系统的能力。如果你在选型时需要“Redis已有但不想引入新组件”这个理由,Stream是个不错的折中方案。
但我还是想泼一盆冷水:消息队列最怕的不只是吞吐量,更重要的是可靠性。Redis的关键数据毕竟常驻内存,即便开启AOF持久化,也可能存在刷盘延迟造成的少量消息丢失风险。一旦你的业务场景要求“每条消息都必须处理成功,不能丢,不能重复”,就老老实实用Kafka、RabbitMQ这类具备完善消费确认机制和高可用设计的专业消息中间件,Redis只适合处理“丢了也不会出大事”的消息。
4. 部署与运维:单机不是终点,主从哨兵集群要拎清楚
4.1 从单机到主从:数据要有备份
很多小项目把Redis部署在一台单机上,用着也挺好。但单机部署有个致命问题:这台机器挂了,Redis里的缓存数据全部无法访问,数据库直接被所有请求打穿,更严重的是如果Redis里存了未持久化的重要数据,数据直接就丢了。
Redis支持主从复制(Master-Slave),从节点会不断从主节点拉取数据变更,保持与主节点基本一致。这样即使主节点挂了,可以把从节点提升为新的主节点,服务不至于中断。同时从节点也可以分担一部分读流量,让主节点专心写。
搭建主从复制很简单,在从节点的配置文件里加一行replicaof <主节点IP> <端口>即可。但生产环境里通常不手写配置,而是用Docker或者编排工具管理。很多团队会选择用Docker的network把主从节点串起来。我提供一个最基本的docker-compose主从示例,你可以在此基础上扩展:
version: '3' services: redis-master: image: redis:7.0 container_name: redis-master command: ["redis-server", "--appendonly", "yes"] ports: - "6379:6379" redis-replica: image: redis:7.0 container_name: redis-replica command: ["redis-server", "--slaveof", "redis-master", "6379", "--appendonly", "yes"] depends_on: - redis-master ports: - "6380:6379"主从复制有一个要注意的地方:全量复制阶段,主节点会把整个数据集生成RDB快照发给从节点,这个过程中主节点会产生一定的磁盘IO和网络压力。如果数据量很大,建议低峰期搭建从节点,并且考虑在互联网络质量好的环境内部署。
4.2 哨兵模式:让故障转移自动发生
主从复制能解决数据备份和读扩展的问题,但它不能自动做主节点切换。主节点挂了之后,应用依然连接的是原来的主节点地址,从节点虽然拥有全量数据,却不会自动接管写入流量。这时候就需要哨兵(Sentinel)登场了。
哨兵是Redis的高可用方案,它的主要工作有:
- 监控主节点和从节点的在线状态
- 当主节点宕机时,自动从从节点中选举出新的主节点
- 通知客户端新的主节点地址,让应用可以重新连接
哨兵本身通常也要部署成奇数个节点,满足过半机制。在主从基础上配置哨兵模式后,Redis的可用性会提升一个量级:一两个节点宕机,对业务无感,服务不会中断。
部署哨兵时有一个常见问题,就是“启动后没生成known节点”。这通常是因为哨兵的配置文件里没有正确指定sentinel monitor的配置项,或者网络端口没放通,哨兵发现不了主节点的存在。排查时可以先手动跑redis-cli -p 26379 info sentinel看输出,如果显示master0:name=mymaster,status=odown说明哨兵认为主节点挂了,要检查网络连接和配置文件里的地址是否正确。还有一个容易疏忽的点:哨兵配置里的IP地址、端口必须能被其他哨兵节点和客户端真实访问到,如果用Docker部署哨兵而映射端口不一致,也会出现类似的问题。
4.3 Cluster:真正意义上的水平扩展
当数据量或者读写流量大到单机无法承载时,主从模式也没辙了,这时要考虑Redis Cluster集群模式。
Redis Cluster采用数据分片的思路,把整个键空间分成16384个槽位(slot),每个节点负责其中一部分槽位。客户端通过CRC16(key) % 16384计算出某个key应该落在哪个槽位上,再由槽位找到对应的节点。这样整个集群可以水平扩展:数据量增长时,往集群里加新节点,把部分槽位迁移过去就行。
集群模式下,每个主节点还建议配一个从节点,形成“主从+分片”的结构。这样既解决了单机容量瓶颈,又保证了高可用。
有一点特别需要提醒:使用Redis Cluster时,客户端要使用支持集群模式的库。比如Java生态里的JedisCluster或者LettuceClusterConnection,命令行则用redis-cli -c开启集群模式。如果普通客户端连上集群后执行跨节点的multi-key操作,会得到CROSSSLOT Keys in request don't hash to the same slot的错误。要解决这个限制,可以使用HashTag,把需要一起操作的key放在同一个大括号里,比如{user:123}:profile和{user:123}:posts会落在同一个槽位上,就可以做原子操作了。
对绝大多数中小团队来说,我的建议是:数据量没到单机瓶颈之前,别急着上集群。一个4主4从的集群,运维复杂度远高于单机加哨兵,日常监控、数据备份、槽位迁移都要额外关注。过早引入集群架构,反而可能让自己深陷运维泥潭。
4.4 Docker部署Redis生产环境的注意点
热词里出现“redis docker compose 生产环境部署”,说明很多人都在用容器化方式管理Redis。Docker确实能极大简化部署流程,但生产环境里用Docker跑Redis,有几个坑得提前知道。
数据卷必须挂载,且建议使用Bind Mount而非Docker Volume。如果你不在启动参数里加-v挂载宿主机目录,Redis的数据只存在容器内部,容器一删数据全没了。正确的做法是挂载一个宿主机路径,比如/data/redis:/data,并且设置--appendonly yes,这样AOF文件和RDB文件都会写到宿主机磁盘上。
内存限制要设置。Redis主要吃内存,如果容器内某个应用把数据塞爆了内存,系统可能会触发OOM杀掉容器,这会让你在排查问题时一头雾水。建议在容器层面设置mem_limit,同时给Redis自身配置maxmemory,再配合合理的过期策略和内存淘汰策略,避免内存完全耗尽。
还有网络模式的选择。默认的bridge网络在容器重启后IP可能变化,如果客户端配置的是容器IP,下次重启后可能就链接不上了。最好在docker-compose里使用固定服务名的网络。
现在给出一个适合生产环境基础使用的docker-compose示例,你可以按需要调整参数:
version: '3.8' services: redis: image: redis:7.0 container_name: redis-prod restart: always command: > redis-server --appendonly yes --maxmemory 4gb --maxmemory-policy allkeys-lru --requirepass mypassword ports: - "6379:6379" volumes: - /data/redis/data:/data - /data/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf environment: - TZ=Asia/Shanghai这里重点解释几个命令参数:--appendonly yes开启AOF持久化;--maxmemory 4gb限制Redis最大可用内存,多余的key会根据后面的--maxmemory-policy allkeys-lru策略淘汰掉,避免内存溢出;--requirepass设置访问密码,不要用默认空密码部署到公网,否则分分钟被扫描攻击。
5. 我把这些坑都踩了一遍,现在告诉你
5.1 缓存穿透、击穿、雪崩,三种“缓存放倒”的姿势
这三个词估计你在面试题里见过几百次了,但实际项目里遇到时能不能快速判断并处理,完全是另一回事。我用最直白的语言说说它们之间的区别。
缓存穿透指的是请求查一个“根本不存在”的数据。比如用user:10001当key查用户,但用户表里压根没有10001,缓存里也不可能命中,于是每次请求都打到数据库。如果有人恶意循环发起大量这种请求,数据库压力会瞬间爆表。应对办法有两个:一是把查不到的数据也缓存起来,value设置为空字符串,过期时间设置短一些,比如30秒,这样同一个key短时间内不会再次穿透到数据库;二是用布隆过滤器,把所有可能存在的数据key提前加到布隆过滤器里,请求来了先判断key是否存在,不存在直接返回,连缓存都不用查。我更推荐两者结合:布隆过滤器挡住大部分不存在的key,空值缓存兜底。
缓存击穿指的是一个热点key到了过期时间,突然大批请求同时来查它,缓存未命中,所有请求都扎到数据库上,数据库可能扛不住。最简单的缓和思路是“互斥锁”:发现缓存没有数据时,先去拿一个分布式锁,只有拿到锁的线程才能去查数据库并重新填缓存,其他线程等待一会儿重新查缓存。锁的粒度要控制好,不同key用不同锁,别一把锁全锁住。
缓存雪崩的范围更大,通常指大量key在同一时间段集体失效,或者Redis本身崩了,导致数据库瞬间涌入大量请求。应对办法有几个:一是给缓存过期时间加一个随机值,比如每个key的过期时间在基础值上再加0到300秒的随机数,打散过期时间;二是提前做好Redis的高可用(哨兵、集群),不让Redis单点挂掉;三是做双级缓存,Redis之外再加一层本地缓存,Redis挂了本地缓存还能顶一阵子。
5.2 大Key和热Key:慢查询的头号元凶
Redis是单线程执行的,所以只要有某个命令执行时间过长,整个实例都会被拖慢。两类坑是最常见的源头:大Key和热Key。
大Key是指那个key对应的value特别大,比如一个Hash里有几百万个field,一个List里有上千万条记录,一个String在内存里占了几百兆。对这样的大Key执行HGETALL、LRANGE等操作时,单次命令就要消耗大量内存和网络IO,执行时间可能达到几秒,期间Redis把所有其他操作都堵住。我在排查生产故障时最常见的场景就是:某个接口突然变慢,查慢日志发现一条HGETALL命令执行了2000多毫秒,一查value里有上百万个filed。
大Key的预防远远重要于治理。设计阶段就要避免单个key无限膨胀,可以通过拆分key范围、把大的Hash拆成多个小Hash、大的List换成Stream等方式来规避。发现已经存在的大Key,建议把数据迁移出去,然后删除旧key。删除时不要直接DEL,因为这会阻塞,要用UNLINK命令让Redis在后台异步回收内存。
热Key则是指某个key访问频率极高,比如某个明星的粉丝数key在活动期间每秒被读几万次。虽然读取本身不慢,但它会把Redis单实例的CPU打满,影响全局。应对方案有:给热key建立多个副本,在key后面加多级不超过实例数的后缀,把访问分散到多个key上;或者把你的热点数据做本地缓存,让应用进程内存扛掉第一层流量,再去访问Redis。热key问题非常考验架构能力,因为没有统一的解法,得结合业务形态来做取舍。
5.3 序列化问题:存进去是String,读出来是乱码
这是一个新手经常踩的坑,尤其在SpringBoot项目里用Redis时。默认的RedisTemplate使用JdkSerializationRedisSerializer,存入Redis的值经过Java序列化之后变成一串\xAC\xED开头的编码,肉眼根本看不懂。在另一个服务里如果使用不同的序列化器读取这个key,就会得到乱码,甚至直接反序列化报错。
我的建议是一开始就规范化序列化方案。如果只存String类型,直接使用StringRedisTemplate;如果存对象,在网上一般配置Jackson的GenericJackson2JsonRedisSerializer,将对象序列化为JSON字符串。这样存进去的value长什么样能一眼看懂,排查问题也会容易很多。
还要注意一个点:key也可以设置一个字符串前缀,比如order:create:{id}这种带业务语义的命名规则,方便在Redis Desktop Manager之类的可视化管理工具里按前缀搜索定位。这看起来是个小事,但对排障效率的提升非常明显。热词里反复出现的“redis desktop manager”和“another redis desktop manager”,都是这个用途,可视化管理工具能让人直观地看到key的结构和数据形态。顺便说一句,另一个Redis桌面客户端 [Another Redis Desktop Manager] 更推荐,因为它开源免费,界面和性能都不错。
5.4 SpringBoot集成Redis的几个细节
SpringBoot里集成Redis非常快,引入spring-boot-starter-data-redis,配置一下连接信息,注入StringRedisTemplate就能用。但几个细节容易出错。
连接池配置。默认情况下Lettuce连接池是禁用的,意思是并发请求多的时候,真的可能把Redis连接数打爆,从而出现连接超时。需要显式在配置里声明连接池参数:spring.redis.lettuce.pool.max-active=20、spring.redis.lettuce.pool.max-idle=10、spring.redis.lettuce.pool.min-idle=5。这里max-active不是越大越好,要结合业务并发量和Redis的连接能力来定,一般20到50够用。
还有一个是IPv6的坑。有些云服务器默认启用了IPv6,而Spring Boot配置的Redis地址如果写成域名,解析时会优先解析到IPv6地址,但Redis没配置IPv6监听,结果就是“Connection refused”。排查这个问题最快的方式是配置Redis地址时直接写IPv4,或者在系统层面禁用不必要的IPv6解析。热词里有“springboot2.1 redis 连接ipv6地址”,说明不少人被这个坑卡过。
事务支持也要谨慎。Redis的事务和关系型数据库事务概念不完全一样,它只是把一组命令打包起来顺序执行,中间不会被其他客户端插入命令,但不支持回滚。如果一条命令执行失败,前序命令的结果仍然会被保留。所以在SpringBoot里用@Transactional控制Redis操作时要有这个预期,不要指望它会像MySQL事务那样回滚。
6. 高频面试题汇总与学习建议
6.1 面试官最爱问的那几个问题
热词列表里“redis面试题”出现频率非常高,说明很多人在准备面试时都比较关心这个话题。我以技术面试官的角度,把最常考的问题整理一下,并附上回答时应该突出的重点。
第一个是“Redis为什么这么快”。面试官想知道你是否理解单线程、IO多路复用、纯内存操作这些核心点。答题时先说清楚内存,再提单线程避免锁竞争,最后补充IO多路复用能支撑高并发连接。
第二个是“Redis持久化机制说一下RDB和AOF的区别”。能答出RDB是按时间点生成快照、恢复快但可能有数据丢失;AOF是追加日志文件、数据更安全但体积大且恢复慢,以及两者可以同时开启就差不多了。面试官如果追问,一般会问“如果RDB和AOF都开启了,以哪个为准”,答案是AOF,因为AOF的数据通常更新。
第三个是“缓存穿透、击穿、雪崩的区别以及解决方案”。这题基本是必考,把5.1节的内容讲清楚就够他点头了。
第四个是“Redis的过期删除策略和内存淘汰策略”。过期删除有定期删除和惰性删除两种结合使用;内存淘汰策略里问到maxmemory-policy的配置项时,要能答出allkeys-lru、volatile-lru、allkeys-random、volatile-ttl等,并说清楚各自适用场景。
第五个是“怎么用Redis实现分布式锁”。要能答出SET key value NX EX seconds的原生做法、释放锁需要Lua脚本比较value、以及Redisson看门狗续期机制。如果面试官问到RedLock,了解过即可,能说出它实现高可用锁的思路,但也要指出它有一定争议。
最后一个是“你们项目里Redis是怎么用的”。这个问题看起来开放,但其实是考察你是否真的干过活。建议按场景归类来答:缓存了哪些数据、怎么保证一致性、排行榜怎么做、分布式锁怎么用、Redis宕机了会有什么后果,怎么防止。能把这些讲清楚,面试官基本不会觉得你是背题背出来的。
6.2 新手学习Redis的路线与工具推荐
Redis上手其实不难,难的是深入和踩坑之后能总结出经验。我建议新手按这个路线走:
第一步,先把官网的“Introduction to Redis”文档过一遍,搞清楚基本概念和数据结构定义。
第二步,本地装一个Redis环境,用命令直接操作每一种数据结构。不用背命令,但至少要亲手敲一遍。这一步可以配合可视化管理工具观察数据形态,把命令行和界面结合起来理解。
推荐几个工具:
- redis-cli:官方自带的命令行客户端,防火墙查问题必备。
- Redis Desktop Manager / Another Redis Desktop Manager:可视化管理工具,浏览key、查看value、执行命令都方便。
- redisinsight:Redis官方出的GUI工具,功能很全,支持数据可视化分析,适合做更深入的性能观测。
第三步,找一个具体业务场景做小项目。比如给自己的博客增加一个“今日热榜”功能,或者写一个带用户登录态的模块,把String、Hash、ZSet、分布式锁都用到。全流程做一遍,比看一堆面试题记得牢。
第四步,深入读一下《Redis设计与实现》这类经典书籍,把底层数据结构、持久化机制、事件驱动的实现原理补上。只要把这本书啃下来,上边说的面试题绝大多数都能做到“知其所以然”。
最后的几句大实话
文章断断续续写了这么多,其实Redis的生态远不止上面这些内容。你会发现真正把一个工具用好的关键,不是背会了多少命令和面试题,而是在动手过程中反复试错、总结出来的那份体感。
我自己踩过最大的一个坑,就是刚接触Redis时总觉得“它就是个缓存”,于是项目里所有数据都往里塞、过期时间随便定、序列化不规划。等到线上出故障,面对一堆看不懂的key和慢日志时才幡然醒悟:越是简单的工具,越需要提前设计。现在每接入一个新功能,我都会先想清楚三个问题——这个数据适合用什么结构存、它的生命周期是多长、如果Redis挂了会有什么后果。这三个问题想清楚了,Redis这块基本不会出大乱子。
如果你正准备学Redis,我给的建议只有一条:先动手,别先去背题。把一个简单的登录、排行榜、限流小功能从头到尾实现一遍,比看十篇文章都管用。毕竟真正的理解都藏在踩过坑之后。