如果你在一个日活几十万的项目里做过一两次缓存治理,你会明白一件事:Redis缓存这个东西,部署起来三分钟,真正麻烦的是上线之后的那堆幺蛾子。可能是某个没设TTL的key把内存吃满了,可能是深夜一大片key同时过期把数据库打到报警,也可能是你用JDK序列化存了一堆用户信息,在控制台里看起来跟天书一样。我最近刚好帮一个团队从头梳理了一遍Redis缓存体系,今天把完整思路和实操细节都整理出来,聊一聊到底怎么用、怎么配、怎么排雷。
1. Redis缓存到底在项目中扮演什么角色
先说结论:Redis缓存最大的价值,是把上游流量挡在数据库外面。数据库的随机读走磁盘,单机MySQL一般扛个几千QPS就发热了,而Redis是纯内存操作,单节点跑到十万级QPS很正常。这就是为什么现在几乎每个业务系统里都能看到它的影子,商品详情、用户会话、验证码、排行榜、秒杀库存,几乎都是Redis在顶着。
但我要泼一盆冷水:Redis不是用来装所有数据的万能桶。你把它当成一个单纯的KV存储来用,能解决眼前的性能问题,但治不了后期的架构病。真正会用缓存的人,心里会装着三件事:这个key该不该缓存、缓存多久、万一缓存和数据库不一致怎么办。
1.1 它最大的价值是“把上层流量挡在数据库外面”
我习惯用一个很土但很准确的类比:MySQL是仓库,Redis是仓库门口的保温柜。仓库里什么都有,但每次都要进去翻一遍,费时费力。保温柜只放最常卖的那几样,顾客来了直接取走,不需要每次都进仓库。
具体到技术指标上,Redis的单线程模型加上I/O多路复用,让它在处理简单命令时几乎不会因为并发竞争而卡顿。一套常见的电商商品详情页,三层结构是这样的:CDN扛静态资源,Redis扛商品热度数据,MySQL兜底。用户第一次访问某个商品时,Redis里没有,回源查MySQL并写回Redis,之后同一件商品再被访问时,直接走Redis。这个流程里,缓存命中率就成了系统性能的关键指标。
还有个容易被忽略的价值:Redis可以把原本需要多表查询的复杂结果提前算好存起来。比如首页的Feed流、报表统计的聚合结果,这些内容如果在每次请求时实时计算,数据库根本受不了。我会在缓存里直接保存“结果”,而不是保存“原始数据”,读取时一次GET就完事。
1.2 不是所有数据都适合放Redis,先想清楚这三点
选型永远比动手重要。判断一个数据是否要进Redis,我一般问三个问题。
第一个问题:这个数据的读写比例是多少?如果写入量巨大,读取量也巨大,但读取的总次数还比不上写入的总次数,那缓存帮不上什么忙。典型的反面教材是把用户操作日志全量写进Redis,日志本身是追加型数据,读得少,写得多,Redis内存会被快速消耗,性价比极低。
第二个问题:能不能接受短时间不一致?任何缓存方案都有延迟窗口,从数据库更新到缓存真正刷新,中间可能有几毫秒甚至几秒的时间,旧数据会被读到。订单状态这种强一致场景,老老实实查数据库,别玩缓存。商品介绍、用户昵称这种最终一致就能接受的数据,才是缓存的主场。
第三个问题:数据体量可控吗?Redis是内存服务,每个字节都是成本。一个key如果动辄存几十MB的文本内容或大数组,建议先把数据做拆分和压缩。我在项目里遇到过同事把一整份报表JSON塞进一个key,结果这个key成了大key,网络传输超时、主从复制延迟一起爆发。
| 适合缓存 | 不适合缓存 |
|---|---|
| 热点商品详情、用户资料、配置项 | 操作日志、全量报表、强一致订单状态 |
| 排行榜、计数器、验证码 | 一次性的临时流式数据 |
| 高频只读、低频更新的聚合结果 | 超大文件、二进制大对象 |
2. 上线前必须先绕开的环境与部署问题
很多新手第一步就卡在环境上。Redis国内下载难、Windows不友好、Docker配置一堆参数,说实话这些东西我全踩过。环境部署这件事,分开本地开发和生产部署两部分聊。
2.1 本地环境:Windows / macOS / Docker 三选一
Windows上最省事的方案其实不是去装一个第三方编译的exe,而是直接用WSL或者Docker Desktop。我早期在公司Windows机器上调试,图省事下载过一个民间编译版本,结果跑着跑着就崩溃,命令支持还不全。后来换成了Docker Compose方案,环境一致,团队协作也不用各装各的。
macOS上就简单多了,Homebrew直接搞定。安装命令是:
brew install redis # 启动临时实例 redis-server /opt/homebrew/etc/redis.conf # 开机自启 brew services start redis装好之后要记得,Homebrew默认配置文件在/opt/homebrew/etc/redis.conf(Apple Silicon)或/usr/local/etc/redis.conf(Intel),本地调试不要直接裸跑redis-server,因为默认配置下没有持久化、没有密码、内存无上限,玩着玩着突然丢数据或者被扫到端口爆破都是常见的坑。
Docker是现代化团队的首选,一条命令就能跑起来标准实例:
# 拉取稳定版本 docker pull redis:7.2 # 带密码、开启AOF持久化 docker run -d --name redis-stable \ -p 6379:6379 \ -v /data/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf \ redis:7.2 \ redis-server /usr/local/etc/redis/redis.conf如果你想在本地测试主从复制,可以在同一个Docker网络里启动两个容器,从节点配置里加上replicaof master 6379。这里要注意,Redis从Redis 5版本开始把slaveof改成了replicaof,现在看旧教程会有兼容问题,但新版本还保留着旧命令兼容。
2.2 生产部署的几个关键配置,少一个都是坑
跑本地随便玩无所谓,上生产至少要把下面这几个配置过一遍。
# 限制最大内存,防止Redis把宿主机内存吃完 maxmemory 2gb # 内存淘汰策略:全局LRU vs 只淘汰有TTL的key maxmemory-policy allkeys-lru # 开启AOF持久化,至少每秒刷盘一次 appendonly yes appendfsync everysec # 设置密码 requirepass 你的强密码 # 只监听内网地址 bind 0.0.0.0 # 关闭保护模式(前提是你已经配置了密码和bind) protected-mode no这些配置里最容易被忽视的是maxmemory-policy。我见过一个团队把Redis当永久存储用,所有key都不设TTL,结果内存满了之后默认策略noeviction直接拒绝写入,线上报错一个接一个。如果你的业务里大量key都设置了过期时间,用volatile-lru更合理;如果所有key都是缓存性质,没有保数据的诉求,allkeys-lru没问题。还有一个细节:别把maxmemory设成宿主机全部内存,要给操作系统和应用程序留至少20%的余量,否则Redis内存没满,机器先OOM了。
2.3 redis-cli连接与密码设置的一次讲清
开发环境有时候会跳着连接Redis,手忙脚乱的拿redis-cli尝试连接,最常见的问题就是认证失败。REDIS的密码配置了之后,进入命令行交互模式前必须先认证:
# 带密码连接 redis-cli -h 192.168.1.10 -p 6379 -a 你的密码 # 或者进入命令行后认证 redis-cli 127.0.0.1:6379> AUTH 你的密码还有个大坑是本机连接没问题、远程连不上。检查顺序:第一,Redis配置文件里bind是不是默认的127.0.0.1,如果是本地回环地址,远程自然连不上。第二,protected-mode默认是开启的,在没有配置密码或bind的情况下,它会拒绝远程访问。第三条,确认云服务器的安全组和防火墙放行了6379端口。不要一上来就怀疑网络,自己把redis-cli -h 内网IP试一遍。
3. 缓存里的数据怎么放:数据类型与序列化是隐性杀手
这应该是Redis缓存最容易“埋雷”的部分。我见到太多人把所有数据都用String硬存,对象就序列化成一坨JSON塞进去,需要更新某个字段时就把整个对象读出来改完再塞回去,性能差不说,并发更新还会互相覆盖。Redis给你准备了五种基础类型,每一种都有它擅长的使用场景。
3.1 五种基础类型,选错类型会埋雷
String是最基础的KV类型,适合存短文本、计数器和token。INCR命令做秒杀库存扣减和点赞计数非常顺手,它是原子操作,不需要自己加锁。
Hash是对象类型的最佳拍档。用户信息这种需要频繁读写单个字段的数据,如果非要用String存JSON,修改一个昵称都要整个对象倒腾一遍。用Hash的话,HSET user:1001 nickname "新昵称",一次只改一个字段。这个类型在实际项目中比String更好用,但很多人没用上。
List适合做消息队列和最新列表。LPUSH加LRANGE可以快速拿到最新N条内容。需要注意的是,List的索引访问是O(N),如果你要频繁按下标访问,请先评估列表长度,超过几百上千条就该考虑换ZSet。
Set用来做去重和集合运算,共同好友、关注列表交集都是它的主战场。SADD、SINTER、SUNION命令非常好用。
ZSet是最有含金量的类型,每个元素带一个score,可以用来做排行榜、延时队列、滑动窗口限流。排行榜用ZADD rank 100 userA,再通过ZREVRANGE从高到低取排名前N。注意ZSet的score是double类型,如果用来做订单排序,把订单号直接当score会有精度问题,建议用订单金额、时间戳这类数值。
| 类型 | 典型场景 | 核心命令 |
|---|---|---|
| String | 验证码、计数器、token | SET、GET、INCR |
| Hash | 用户资料、商品对象 | HSET、HGET、HGETALL |
| List | 消息队列、最新列表 | LPUSH、BRPOP、LRANGE |
| Set | 去重、好友交集 | SADD、SINTER、SUNION |
| ZSet | 排行榜、限流、延时队列 | ZADD、ZRANGE、ZINCRBY |
3.2 序列化:为什么你存进去的数据在控制台全是乱码
这是让我最头疼的一个问题。很多Java项目用RedisTemplate默认的JdkSerializationRedisSerializer存数据,存进去之后用redis-cli一看,key前缀全是\xAC\xED\x00\x05t...,不但人看不懂,其他语言的服务也读不懂。
序列化方案的选择直接决定了缓存跨语言、跨平台的可读性。我现在的默认方案是:key一律用String,value用JSON字符串。在Java的Spring Data Redis里,正确的配置方式是:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key使用String序列化器 template.setKeySerializer(RedisSerializer.string()); template.setHashKeySerializer(RedisSerializer.string()); // value使用JSON序列化器 GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); return template; }配好之后再进redis-cli看,就是一行可读的字符串,比如"{"id":1024,"name":"商品A"}",不知道省了多少排查成本。还有个小技巧:如果项目里存在不同版本的服务读写同一个key,推荐value里显式带上类型标识字段,防止反序列化时版本不匹配报错。
3.3 key规范与TTL:管理混乱往往从命名开始
缓存治理的基本功,是给key起一个好名字。我见过最离谱的key就是业务字段直接裸奔:一个叫做name的key,全项目都不知道它属于哪个业务,后天想清理都没法下手。规范的做法是采用冒号分层的命名结构:
业务域:模块:唯一标识:属性 例如:shop:item:1024:detail,user:profile:1024:info冒号分隔的key在Redis里有实际好处,SCAN命令支持模式匹配,运维时可以按业务前缀批量扫描和清理。Redis客户端工具也会把同前缀的key当作一个树形目录展示,排查问题的时候非常清晰。
TTL的问题是另一座大山。一条规则我必须强调:默认每个缓存key都应该设置过期时间。没有TTL的key就像没人扫的垃圾,总有一天会堆满房间。但TTL又不能全设成一个值,否则大促零点一到,所有缓存集体失效,所有请求同时回源数据库,雪崩就是这么来的。我在设计时会给基础过期时间加一个随机偏移,比如300 + random(0, 60)秒,让过期时间在时间轴上撒开。
4. 缓存系统的四个核心设计难点
聊完基础使用,接下来说说设计层面的关键点。这部分才算真正进入“缓存治理”的深水区,也是面试和线上故障的高频区。
4.1 缓存穿透、击穿、雪崩,一套组合拳
这三个概念经常被人记混,但实际上它们是完全不同的故障模式,解决方案也不同。
缓存穿透是指请求的数据在缓存和数据库里都不存在,导致每次请求都直接打到数据库。比如恶意用户用不存在的商品ID反复刷接口,Redis里永远查不到,数据库被打爆。解决方案有两个:第一,把空结果也缓存起来,但TTL要短,比如30秒;第二,用布隆过滤器把不存在的ID拦在前面,挡住大概率不存在的请求。
缓存击穿是指某个热点key在过期瞬间,大量并发请求同时发现缓存里没有,然后一起回源数据库。注意它跟穿透的区别:穿透的数据是“不存在”,击穿的数据是“存在但key刚好过期”。解法思路是“只让一个请求去回源建缓存,其他请求等着”。实际项目中可以用互斥锁:
// 用SET NX实现简单互斥 String lockKey = "lock:hotkey"; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // 查数据库,重建缓存 String value = loadFromDb(key); redisTemplate.opsForValue().set(key, value, 30 + new Random().nextInt(60), TimeUnit.SECONDS); redisTemplate.delete(lockKey); } else { // 其他线程短暂等待后重试 Thread.sleep(50); // 再读一次缓存 }还有一种策略更优雅:逻辑过期。给缓存value加一个逻辑过期时间,线程发现逻辑过期后,先返回旧数据,然后异步去更新缓存。这样请求永远不会打到数据库,代价是有一段短暂延迟,适合能容忍短时数据不新鲜的热点数据。
缓存雪崩是大量key在同一时间集体失效,或者Redis节点本身故障,导致流量同时冲击数据库。预防的办法就是上面提到的TTL随机化,加上Redis主从和哨兵保证高可用,再配合后端的限流熔断兜底。
| 故障 | 触发条件 | 核心解法 |
|---|---|---|
| 穿透 | 查不存在的key | 布隆过滤器、空值缓存 |
| 击穿 | 热点key刚好过期 | 互斥锁、逻辑过期 |
| 雪崩 | 大量key同时失效或节点故障 | TTL随机化、主从高可用 |
4.2 缓存一致性:先更新DB还是先删缓存?
缓存和数据库的双写一致性问题,是每个后端都会被问到的题。常见的错误方案是“先更新缓存,再更新数据库”,这种方案有一个致命问题:更新数据库失败后,缓存和数据库就永久不一致了,而且并发场景下两个请求交错执行,最后谁后写谁覆盖,极易出现脏数据。
正确的底座方案是Cache Aside旁路缓存:读的时候先读缓存,没命中就读数据库再回写缓存;写的时候先更新数据库,再删除缓存。这个方案里删除缓存而不是更新缓存,是因为更新缓存成本高且容易产生并发写覆盖问题,而删除缓存就算失败了,下一次读会重新从数据库加载。
但“先更新DB再删缓存”也有一个经典的失败窗口:线程A更新数据库后还没来得及删缓存,线程B正好读到旧缓存。这个问题的进阶解法是“延迟双删”:先删缓存、再更新数据库、过几百毫秒再删一次缓存。
# 延迟双删的核心步骤 DEL key UPDATE database ... # 异步延迟约200ms后再次删除 DEL key“延迟双删”的关键在于,第二次删除的延迟必须大于“读DB + 写缓存”的时间,否则第二次删除还没执行,读请求已经把旧数据写回缓存了。实际项目中我一般用消息队列异步执行第二次删除,顺带把删除失败的重试机制也做了。
注意:如果对一致性要求极高,比如支付、库存强扣减这类场景,就别走缓存了,直接查库。缓存一致性方案能保证的是最终一致,不是强一致。任何一个缓存设计,都应该明确接受“短暂不一致”这个前提。
4.3 分布式锁:Redis分布式锁不是setnx这么简单
分布式锁最经典的实现确实是基于Redis,但网上到处是“SETNX一把梭”的教程,我看完之后一般会补一句:别直接用,有几个细节你必须处理。
最早的实现是两个命令:SETNX key value,然后EXPIRE key seconds。但这两个命令不是原子的,如果程序在SETNX和EXPIRE之间宕机了,锁就成了永不过期的死锁。正确做法是用一条原子命令:
SET lock:order:1001 uuid_value NX EX 30这条命令同时设置了NX(不存在才生效)和过期时间,避免了死锁。但这还不够,释放锁的环节也有大坑:如果线程A执行得太慢,30秒后锁自动过期了,线程B拿到了新锁,这时线程A处理完毕,直接DEL会把B的锁删掉。解决办法是释放锁之前先比对value:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这个Lua脚本用Redis执行一套原子操作,保证了“比对+删除”的完整性。如果用Java,配合RedisTemplate执行这段脚本就能获得一把安全的分布式锁。如果不想自己维护这些细节,直接用Redisson客户端,它内置了看门狗机制,会定时自动续期,防止业务没跑完锁消失了。
还有一个很少被提到的坑:主从架构下的锁丢失问题。如果Master节点刚写入了锁,还没同步到Slave就宕机了,主从切换后新Master上没有这把锁,另外一个线程就能同时拿到锁。严格解决这个问题需要引入RedLock,但对大多数场景来说过度设计,我一般会权衡之后放弃,转而保证Redis本身的高可用和快速切换。
4.4 大key、热key、慢查询怎么治理
这三个问题是我实际排查中遇到频率最高的,每个都可以挂半天。
大key:通常指value值超过几十KB,或者一个集合类型key里存储了超过几千个元素。大key的危害在于:它会让Redis处理单个命令的时间变长,阻塞所有请求;它复制到从节点时会占用大量带宽;集群模式下还会让数据分布不均衡。发现大key的办法:
# 扫描全部key,找出大小异常的key redis-cli --bigkeys处理大key没有银弹,常见方案是拆分,把一个大Hash按字段拆成多个小Hash,或者把大列表改成多级结构。比如每天的埋点数据不要一次性全塞进一个List,按小时建key。千万别直接对线上大key执行易阻塞命令,比如KEYS *、SMEMBERS这种会一次性返回海量数据的操作,要用SCAN、HSCAN这类游标式命令。
热key:单个key的访问频率超高,比如微博明星热搜、秒杀商品,Redis单线程处理一个超级热点key时,会把所有请求串行处理,延迟拉高。热key的解法有几种:一是加本地缓存,比如Caffeine或Guava Cache,把热点数据放在应用进程内,扛掉大部分流量;二是热key复制,把hotkey:1024备份成hotkey:1024:0到hotkey:1024:9十个key分散存在集群里,读取时随机选择其中一个。
慢查询:Redis服务端会统计执行时间超过阈值的命令,默认10毫秒。排查方式:
# 在Redis命令行中查看慢日志 SLOWLOG GET 10经常出现在慢日志里的命令,除了大key的删除和查询,还有SORT、KEYS、GETRANGE这种高复杂度的操作。可用CONFIG SET slowlog-log-slower-than 5000调低阈值,收集更多慢命令样本。
4.5 可视化工具选型:从连接到生产安全
命令行虽然通用,但日常调试和巡检效率低。目前主流可视化客户端有RedisInsight(官方出品)、Another Redis Desktop Manager(开源免费)、Redis Desktop Manager(老牌收费)。我自己用Another Redis Desktop Manager多一点,因为它免费、支持跨平台、还能直接浏览JSON数据。
有一个细节很重要:可视化工具有一键执行危险命令的能力,连接生产环境时必须自律。我一个朋友在排查问题时,本想D掉一个测试key,结果工具自动补全成了FLUSHALL,直接把一个中队的缓存全清了,场面一度非常尴尬。生产环境建议使用只读账号连接,或者开启Redis的ACL把管理命令禁用掉。
5. 缓存命中率低、内存爆炸、雪崩恢复:排查实录
最后分享一些我实际排查过的线上问题,每一个都对应一份可以抄的作业。
5.1 缓存命中率为什么这么低
有一次线上其他的指标都正常,就缓存命中率掉了二十个百分点,数据库压力陡增。我先用INFO stats看了两个计数器:
# 统计缓存命中和未命中次数 INFO stats # 输出中的 keyspace_hits 和 keyspace_misses发现misses增长异常,于是我通过MONITOR命令抓一下线上的实时访问key。这里特别提醒:MONITOR很耗性能,高峰期慎用,我在低峰期抓了几秒钟,马上定位到问题——同一条数据被两个不同的key前缀同时访问,一个叫goods_detail,一个叫detail_goods,两边都是有效请求,但互相命不中。原因就是前后端对key的命名约定不一致,一个接口老版本用老前缀,新版本改了新前缀,没有灰度清楚。这个问题在多人协作的项目里很常见,根治办法是建立key命名审查清单,任何新的key前缀都需要review和登记。
另外一种常见命中率低的原因,是缓存过期时间设得太短。比如有的团队把验证码TTL设成30秒,但用户收短信可能要一分钟,每次请求验证码都重新发,老验证码查不到就一直回源数据库。遇到这种问题,先把TTL拉长,再观察命中率曲线,往往立竿见影。
5.2 内存增长失控的排查路径
内存报警是Redis运营中最常见的故障。我习惯按下面的路径排查:
第一步,看内存分布:
# 查看内存总量和组成 INFO memory # used_memory_human 看总内存占用;mem_fragmentation_ratio 看碎片率第二步,扫描大key和异常key:
# 先看哪些key占用的内存最大 redis-cli --bigkeys第三步,找不带TTL的key:
redis-cli --scan --pattern '*' | while read key; do ttl=$(redis-cli -a 你的密码 TTL "$key") if [ "$ttl" -eq -1 ]; then echo "no expire: $key" fi done注意,生产环境禁止直接执行KEYS *,这个命令会阻塞Redis。上面示例用SCAN替代,游标式遍历,不会卡服务。定位到问题key后,该加TTL的加TTL,该清理的清理。从治理角度,我还建议上线之前就开发一个巡检工具,定期扫描无TTL和大key,把隐患消灭在直接爆炸之前。
5.3 缓存雪崩后的恢复与预防
雪崩的特征是短时间内的缓存失效风暴,数据库连接数瞬间被打满。有一次知道凌晨有大批数据要更新,我就按“错峰+多级”的思路处理这个隐患。
首先是TTL错峰,更新任务不只是把缓存重新写一遍,还会把同一个业务的缓存过期时间加一个随机数分散开。其次是准备双缓存,一个主缓存和十几秒延时的备份缓存,回源数据库时只在主缓存未命中时触发,备份缓存作为抵挡流量的第二道屏障。最后是限流熔断,数据库层设置最大连接数和排队等待时间,宁可让少数请求走降级返回默认值,也不能让DB被全部拖垮。这套组合拳放上去之后,凌晨更新流程再也没出过事故。
5.4 日志与监控:上线之后怎么盯缓存
生产环境里Redis要盯的东西很多,除了常规的监控面板(可用率、QPS、内存、网络),我建议把下面三个指标也放进去:
keyspace_hits/keyspace_misses:缓存命中率的变化趋势;connected_clients:连接数是否异常增长,通常是连接池泄漏的前兆;slowlog数量:慢命令是否变多,可能是大key正在形成。
监控接入层面,如果是云厂商提供的Redis,直接用控制台自带监控最省事。自建Redis可以用Prometheus加redis_exporter,配几条告警规则就够。我自己在实践里只留了三类告警:内存超过80%、命中率断崖式下跌、慢查询超过阈值。告警太多反而会被忽略,抓核心就好。
最后再分享一个小技巧:上生产前,我会跑一遍
redis-cli --latency看网络延迟基线;每次发布前,用小流量先预热一遍热点缓存,避免发布窗口期发生缓存击穿。这些看起来琐碎,但都是在真实项目里靠踩坑攒出来的经验,多花十分钟,能省一整晚。