做后端这八年,Redis是几乎每个项目都在用的东西,说它是缓存事实标准一点都不夸张。但越是常用的东西,埋的坑越多——我刚工作那会儿,被RedisCommandTimeoutException折腾过整整一个通宵,线上缓存全量失效,数据库被打到告警,运维半夜喊我起来看日志的时候,人都傻了。后来踩的坑多了,才慢慢总结出一条经验:Redis本身的坑其实很有限,绝大多数问题出在“不知道它怎么工作”和“想当然地用”,说白了,坑不是Redis挖的,是我们自己对它的认知盲区挖的。
这篇文章不打算讲那种“Redis五种数据结构是什么”的入门科普,这些随便搜一下就有。我想把这些年在部署、使用、调优、排障过程中实打实遇到的坑,以及对应的规避方案,按阶段整理出来。内容覆盖从安装在 Windows 还是 Linux、Docker 部署,到数据类型的误用、缓存三大难题、持久化机制、主从同步、分布式锁,再到 Lettuce 连接超时这类具体的报错排查。无论你是刚接触 Redis 的新人,还是已经被线上事故教育过的老手,我都建议把目录草草浏览一遍——可能某个你已经遇到但没当回事的报错,就在这里找到了答案。
1. 部署安装阶段的坑:环境搭不对,后面全是坑
1.1 别在 Windows 上死磕官方安装包
很多人第一次接触 Redis 是拿 Windows 环境练手,这个方向上第一个坑就来了:Redis 官方并不提供 Windows 版本。官网下载页只有 Linux 和 macOS 的源码包,Windows 下你能搜到的安装包基本都是第三方移植版,比较经典的是 tporadowski 基于 Redis 5.0 的移植版,版本号停在 5.0.14.1,后面再没有官方同步过新版本。
你自己评估一下:如果只是本地学习 String、List、Hash 这些基础操作,Windows 移植版完全能用。还有不少人问版本为什么不更新了——那是因为官方不维护,Linux 才是 Redis 的生产环境。所以我的建议很直接:本地学习用 Docker,生产直接上 Linux,Windows 移植版只当作一个临时体验工具,别在它上面花时间配主从、配集群。
如果你确实要在 Windows 上装一个,我列一下常见操作,给后来的人少绕点弯路:解压后直接运行redis-server.exe就能启动,默认端口 6379;想要后台运行和带配置文件启动,用redis-server.exe redis.windows.conf;设置密码在配置里打开requirepass这一行。唯一要注意的是 Windows 版的 fork 能力和文件持久化性能都偏弱,不建议在上面做持久化相关的验证实验,结果参考价值不大。
1.2 Docker 部署:最顺手的方案,但藏着两个坑
Docker 已经成了本地和服务器部署 Redis 的主流方式,一条命令就能跑起来,确实省心,但至少有两个细节我见过太多人翻车。
第一个是数据持久化。很多人直接docker run -d --name redis -p 6379:6379 redis,看起来跑起来了,但容器一删,所有数据跟着没了。你在 redis-cli 里set的数据存在容器可写层,容器删除后自然就清理掉了。标准做法是挂载数据目录和配置文件,我平时起测试环境用这条命令:
docker run -d \ --name redis \ -p 6379:6379 \ --restart=unless-stopped \ -v /etc/redis/redis.conf:/etc/redis/redis.conf \ -v /data/redis:/data \ redis:7.2 \ redis-server /etc/redis/redis.conf第二个是网络报错。有一些同学在 Docker 环境中执行docker search redis之类的命令,会收到docker search redis request returned 500 internal server error for api route的一长串报错。这个问题的原因普遍是 Docker 引擎或网络通道没就绪,搜索功能依赖与镜像仓库的连接。跟你写代码没关系,纯粹是环境侧不通。排查顺序就是先看 Docker Desktop 引擎是否启动、看网络到镜像仓库通不通、再试一次docker pull redis看看是否直接能拉取。如果内部环境无法直连外部仓库,就配置内网镜像仓库地址,把镜像同步到内网再拉。
还有人想用官方可视化工具 RedisInsight,这类工具通过交互式界面查看 key、分析内存使用、执行命令,确实好用。注意它默认要求 TLS 连接的话需要配证书,本地测试可以直接关闭 TLS。如果连不上,优先检查 Redis 的bind配置和protected-mode,这两个是连不上的头号原因。
1.3 macOS 安装:brew 一条命令,但路径别搞混
macOS 用户装 Redis 相对省心,brew install redis跑完就装好了。常见的坑反而是版本和运行路径的问题:Homebrew 安装的 Redis 默认配置在/opt/homebrew/etc/redis.conf(Apple Silicon 芯片)或/usr/local/etc/redis.conf(Intel 芯片),可执行文件在/opt/homebrew/bin/redis-server,服务管理用brew services start redis。
这个我不止一次看到有人搞混:用redis-server直接启动却用的是系统自带的旧版本,或者改了配置但启动时没指定配置文件,导致改动完全不生效。macOS 上做本地开发没问题,但要注意 macOS 的默认文件描述符限制可能比 Linux 低,压测前记得先查一下ulimit -n,否则可能误判是 Redis 性能问题,其实是文件句柄不够。
2. 数据使用层面的坑:不是会用命令就等于用对了
2.1 五种数据类型,各有各的“反例”
Redis 的 String、Hash、List、Set、ZSet 五种基础类型,语法都不难,但用错了才是真正的坑。
先看 String。最典型的问题是把 Java 对象直接塞进去,然后序列化各种出问题(这个留在序列化小节细讲)。另一个高频反例是缓存一个超大的 JSON 字符串,动不动就是几 MB,set 和 get 一次的网络开销就很可观。String 适合存的是计数、短文本、临时状态这类小数据,不是数据库的“大字段搬运工”。
Hash 的问题恰好相反——字段数失控。有人喜欢把一个业务实体的所有字段都放进一个 Hash,比如一个用户的所有信息塞进一个 key,单 key 字段数从几十涨到几十万,最后发现这个 key 变成了一个巨大对象,任何一次 HGETALL 都在拖垮 Redis。Hash 适合字段数在一个可控范围内的对象,真到几万、几十万字段的规模,就该拆分 key 或者换个数据结构了。
List 的坑是用它当消息队列的时候。LPUSH + BRPOP 的组合确实可以实现一个轻量队列,但 Redis 的 List 不具备消息确认机制,消费端崩了消息就丢了。你要是用 List 做业务消息队列,得接受这种“最多一次”的交付语义。真讲求可靠性,还是用专门的消息队列中间件。
Set 和 ZSet 本身相对安全,但 ZSet 有个性能隐患是分数相同成员过多。ZSet 底层是跳表,当大量成员分数相同,查找和排序会退化,写入变慢。比如排行榜场景,如果几万用户分数都是 0,就不要用 ZSet,优先考虑其他方案。
2.2 key 命名和过期时间:设计不规范后患无穷
Key 命名这件事,看起来小,影响却很大。最不推荐的命名方式包括:不带业务前缀(比如就叫user)、没有分隔符(userinfo123)、直接用中划线或特殊字符(user:info:123里的空格、换行都会让排查工具崩溃)。业界通用的规则是“业务域:对象:标识符”,比如user:profile:12345,这样同一个业务域可以按前缀扫描管理,不用的批次还可以按前缀批量清理。
过期时间设计上,常见的坑是“所有 key 设一样的 TTL”。你有没有想过,如果缓存里一百万个 key 都是同一个过期时间,比如都是凌晨两点过期,到点之后 Redis 会同时尝试清理这一大批 key,后台的过期删除线程和内存淘汰一起忙,瞬间产生突刺,请求全部落到数据库——这其实就是缓存雪崩的雏形。微小的改进是给 TTL 加上随机偏移,比如TTL = 基础值 + random(0, 300)秒,让过期时间分散开。
还有个细节是设置 TTL 时忽略了大 key。EXPIRE一个几百 MB 的大 key,删除大 key 本身是阻塞操作,TTL 到期后的释放也一样耗时。对大 key 最好避免设置短 TTL,应该走自己的淘汰机制。
2.3 序列化:Redis 里存了一堆“乱码”怎么办
Java 项目用 RedisTemplate 默认的 JDK 序列化时,你会在可视化工具里看到一串以\xAC\xED\x00\x05开头的二进制乱码——这个现象我已经被问了不知道多少次。它不影响程序读写,但有两个实际问题:一是可读性极差,排查数据时根本看不出内容;二是 JDK 序列化体积大,占用内存多。
解决办法是换 GenericJackson2JsonRedisSerializer,或者直接为不同的 value 类型订制不同的序列化器。我的经验是:
- 字符串场景直接用
StringRedisTemplate,省得为序列化的事操心; - 对象缓存用
GenericJackson2JsonRedisSerializer,同时给类加上@JsonTypeInfo支持,解决反序列化时的类型丢失问题; - 性能敏感且字段稳定的内部对象,考虑用 Protobuf 这类二进制序列化方案,体积和速度都占优。
另外还要提一下反序列化时的ClassCastException。这类问题的根源常常是配置了多个 RedisTemplate 或者同一个 key 被两套不同序列化器写进去。规避手段是把序列化器的配置收敛到一处,别到处 new RedisTemplate。
3. 缓存架构的三座大山:穿透、击穿、雪崩
3.1 缓存穿透:查询一个不存在的东西,就是一场灾难
缓存穿透指的是请求一个缓存和数据库里都不存在的 key。比如用户传入一个不存在的商品 ID,缓存查不到,就会去数据库查,数据库也没有,于是每次请求都穿透到数据库,防护机制完全失效。
解决穿透有两条主流路线。第一种是空值缓存:如果数据库查询结果为空,也把这个 key 存进缓存,只是 value 是空标记,TTL 设短一点,比如 2~5 分钟,防止大量不存在 key 反复打库。这种方案实现简单,但要注意空 key 数量多会占内存,得设个上限或缩短 TTL。
第二种是布隆过滤器:在客户端和 Redis 之间加一层布隆过滤器,存所有可能存在的主键,请求来了先判断 key 是否可能存在,不存在直接短路。布隆过滤器的问题是存在误判率,只是误判不存在的为存在,不会反过来,所以它挡的是那些根本不可能存在的 key,效果很不错。实际项目中我通常两个结合:网关层用布隆过滤器拦截恶意攻击,DB 层再用空值缓存做兜底。
3.2 缓存击穿:热点 key 过期的那一刻
穿透和击穿的差别在于是“全不存在”还是“一个热点突然消失”。缓存击穿指的是某个被高频访问的热点 key,缓存过期的那个瞬间,大量请求同时砸向数据库。因为大家都在缓存里取不到,于是同时去查 DB,数据库压力瞬间打满。
解决击穿有两个常用方案:
- 互斥锁:key 过期后,不是所有请求都去查库,而是先尝试
SETNX获取一个锁,只有拿到锁的请求才允许查库回源,其他请求短暂自旋等待。这个方案实现简单,缺点是缓存过期瞬间会有轻微阻塞,对极端低延迟的场景不友好。 - 逻辑过期:在 value 里额外存一个过期时间字段,程序读缓存时判断逻辑上是否过期,如果过期则后台异步线程回源更新,读请求继续返回旧值。这个方案好处是“读”永远不阻塞,适合读多写少的热点,缺点是有一段时间读到的是旧数据,需要业务接受短暂不一致。
如果某些热点 key 的访问量极大,甚至可以直接设置“永不过期”,改为由后台定时任务主动刷新。这是我个人比较推荐的兜底手段,配合逻辑过期可以作为双保险。
3.3 缓存雪崩:大面积同时过期,谁也扛不住
雪崩和击穿的区别是规模。击穿是单个 key,雪崩是一大批 key 同时失效,或者 Redis 整节点宕机,导致所有流量直接打到数据库。
应对雪崩,三个方向并行:
- 过期时间错峰,上面说的随机偏移就是最有效的低成本手段;
- 多级缓存,本地缓存(如 Caffeine、Guava Cache)挡掉相当一部分请求,Redis 挂了至少本地还有一层,能保证核心接口不直接雪崩;
- 熔断限流降级,在 Gateway 或服务层对下游 DB 调用做限流和熔断,不能让流量一窝蜂全冲过去。
因为 Redis 宕机导致的全量雪崩是另一个课题,核心依赖高可用,这部分我在持久化和主从章节展开。
4. 持久化与高可用:数据丢了才想起备份就晚了
4.1 RDB 和 AOF:两个都要了解,但别以为“默认就是安全”
Redis 默认的持久化不像 MySQL 那样“每次事务都落盘”,这导致很多人在数据丢失后才意识到问题。RDB 是快照持久化,默认配置是save 3600 1这种规则,意思是“3600 秒内有 1 次写操作才触发一次快照”,也就是说两次快照之间的数据有可能丢。AOF 是追加日志,配置appendfsync everysec的情况下最多丢失 1 秒的写入。
我见过一个事故:Redis 主机意外重启,丢了近半小时的缓存数据,业务方拿不回数据,最后只能从数据库重建。事后一查,配置用的是默认 RDB,快照触发频率低,而数据本质上是可以从 DB 恢复的缓存,但财务那边对账不认,好一顿折腾。
所以持久化选型没有银弹,你要先问两个问题:这些数据丢了能不能接受?能接受丢多少?不能接受的场景(如订单流水、对账数据)就别指望着缓存来保存,直接写数据库;缓存可以重建的,也要根据重建成本决定采用 RDB 还是 AOF。生产我建议开启 AOF,至少everysec,有条件再开aof-use-rdb-preamble yes,开启混合持久化,兼顾 AOF 的可靠性和 RDB 的加载速度。对纯缓存场景,很多人干脆关掉持久化,省下 fork 的开销,这个操作本身没有对错,但要有意识做选择,而不是默认。
4.2 主从同步的坑:延迟、脑裂和全量重同步
Redis 主从架构是标配,但主从之间的同步并不是实时的,这就有两个高频坑。
第一个是主从延迟导致的读写不一致。很多公司“读走从、写走主”,主和从间的网络延迟在正常情况下是毫秒级的,对大多数读多写少场景没问题。但一旦主从之间出现网络分区,延迟可能飙升到秒级,读请求会读到旧数据。规避方法一是核心读请求强制走主节点,二是对一致性要求高的场景不要读从库,三是监控master_repl_offset和从库的slave_repl_offset的差值,超过阈值及时告警。
第二个是主从切换时丢数据,这也是分布式锁失效的常见根源。主节点已经写入了一条数据,还没来得及同步给从节点,主节点宕机了,哨兵把从节点提升为主节点,刚才那条数据就没了。Redis 官方对此的方案是WAIT命令,但存在性能代价。如果你的业务在这个场景下对一致性有硬要求,单纯的主从结构不够,需要引入更重的机制或者从架构上避免依赖 Redis 的强一致。
4.3 集群部署:16384 个槽位,别把数据随便塞
Redis Cluster 用 16384 个哈希槽来管理数据分布,key 经过 CRC16 计算映射到某个槽位。集群模式下的坑主要有两个:
- 多 key 操作被限制:
MGET、MSET、事务、Lua 脚本涉及多个 key 时,这些 key 必须在同一个节点,否则会报CROSSSLOT错误。解决办法在 key 里加相同的哈希标签,比如{user:123}:profile和{user:123}:orders,Redis 会只对哈希标签部分计算槽位,把这些 key 归到同一个节点。 - 脑裂风险:集群默认
cluster-require-full-coverage yes,意思是只要有槽位的节点不可用,整个集群就拒绝服务。这个配置在生产上争议很大:保持yes可以防止读到不完整数据,但一个节点故障就带崩全集群;改成no集群还能继续服务部分数据,但业务可能拿到不完整数据。真实场景我推荐改成no,然后通过监控感知健康状态,让业务方有降级处理逻辑。
在 k8s 环境部署 Redis 集群时,还要考虑 StatefulSet 的稳定性,节点重启后挂载数据不丢,网络是稳定的 DNS 访问方式,确保集群节点互相能连上,别用动态 IP 互相注册。
5. 客户端与连接层的疑难杂症
5.1 RedisCommandTimeoutException:全网最眼熟的报错
如果你用过 Lettuce 客户端,一定见过这一长串报错:redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。这个报错不是 Redis 本身挂了,而是“客户端发出命令后,在约定时间内没有收到响应”。
我从实际排查中总结的根因优先级是这样的:
- 慢命令阻塞了 Redis 单线程。Redis 处理命令是单线程的,如果有
KEYS *、SMEMBERS(超大集合)、HGETALL(超大 Hash)、SORT这类慢命令执行,其他命令只能在队列里排队。如果这些慢命令是高频的,Lettuce 端很容易超时。排查用SLOWLOG GET 20看最近的慢日志,命中就抓住元凶。 - 网络抖动或客户端到服务端的链路问题。比如在云环境下跨可用区访问 Redis,网络延迟波动就会导致偶发超时。这类问题的特征是报错随机、不规律,慢日志里没什么可疑命令,多节点上报错时间点对不上。
- 连接池耗尽或 Lettuce 自身配置问题。连接池开太小,瞬时并发高了,请求都在池里排队,排到超时。另外 Lettuce 是异步客户端,底层基于 Netty,官方默认超时配置是 60 秒,很多人没调整过。如果业务要求快速失败,把这个值改成 3 秒甚至 1 秒更合理。
排查步骤就按“自底向上”:先看服务端SLOWLOG,再看网络监控,再看客户端连接池指标,最后看超时配置。多数时候问题在服务端或配置上,而不是网络。
5.2 连接池配置:别盲目抄网上的参数
Lettuce 默认的连接池行为比较隐蔽,如果直接用默认配置,高并发下很容易出现连接数不够的假象。很多人直接抄 Jedis 的配置来调 Lettuce,这是不行的,协议栈不同,参数映射关系不对。
实际配置时我会关注这几个参数:
- maxTotal(最大连接数):估算方式是“单连接 QPS 上限”除以“单请求耗时”。比如单连接能支撑 2 万 QPS,业务峰值需要 10 万 QPS,那至少需要 5 条连接,再加 20% 余量。网上那些“最大 100 条”的配置,在大流量业务里往往不够。
- maxIdle(最大空闲连接):这个影响突发流量的表现。maxIdle 太小,突发流量时连接还没建好,请求就超时了。
- maxWaitMillis(获取连接的最大等待时间):设为连接池等待超时,别让请求无限期等下去。
这里有个容易忽略的点:连接池维护的连接如果在主从切换期间失效,重连需要时间,期间所有请求都会超时。所以客户端要开启哨兵模式支持,并开启定期连接健康检查(如testOnBorrow或者 Lettuce 的拓扑刷新,让客户端在主从切换后能自动感知新主节点)。
5.3 大 key 和热 key:Redis 变慢的两大隐形杀手
大 key 是指一个 key 的 value 特别大,比如单个 String 超过 10KB、Hash 里有几万个字段、Set 成员上百万。大 key 的危害不用多说,删除和序列化阻塞是主要的,一个 100 MB 的大 key 删一次可能阻塞几秒甚至更久,线上事故基本都是它引发的。日常巡检用redis-cli --bigkeys扫一遍,找出所有超过阈值的大 key,然后分治:拆分成多个 key、换成 Hash 分片字段、或者直接让该数据离开 Redis。
热 key 是另一个方向,指某个 key 在单位时间内被极高并发访问。比如大促时的某个爆款商品,单 key QPS 可能超过 10 万,这时候 Redis 单分片也顶不住响应慢、甚至 CPU 被打满。做热 key 治理的标准思路是加一层本地缓存,让服务节点把高频 key 缓存在本地 JVM 内存,对 Redis 的请求直接减少一个数量级。另一个手段是把热 key 复制多份,比如加后缀#0、#1、#2分散到不同分片,但还要考虑多副本之间一致性的问题,比较麻烦,通常本地缓存更省事。
6. 框架与技术选型背后的“面试级”深水区
6.1 分布式锁:看着简单,写对很难
网上写分布式锁的代码一抓一大把,但能经得住生产环境考验的真不多。最早的版本是SETNX key value+EXPIRE key 30两条命令,后来发现 SETNX 和 EXPIRE 不是原子的,宕机就锁不释放了,于是有了SET key value NX PX 30000这条原子命令。
但即便你写了原子加锁,还有两个经典大坑:
- 锁的过期时间小于业务执行时间。业务跑了 40 秒,锁 30 秒就过期了,其他请求就拿到锁了,并发问题复现。解决思路是自动续期,也就是 Redisson 的看门狗机制,默认每 10 秒给锁续期一次,直到业务完成主动释放。
- 主从切换导致锁丢失。客户端 A 在主节点上拿到了锁,主节点还没同步就宕机了,从节点变成主节点,但锁的信息丢了,此时客户端 B 也能拿到锁,等于锁失效。Redlock 算法就是想解决这个问题,但它本身有争议,而且实现复杂度高。大部分团队最终的选择是“弱一致即可”或者把这个锁只用于低风险场景,真有强一致性要求的业务别依赖 Redis 锁。
我现在的做法是:除非确认业务对一致性的要求能容忍主从切换导致的锁失效,否则并行使用数据库乐观锁做兜底,Redis 分布式锁只用来解决高并发下的资源抢占问题,而不是当作唯一防线。
6.2 缓存一致性:先更新库还是先删缓存?
数据库和 Redis 的数据一致性,是面试不会跳过的题,也是生产中最容易出事的点。
经典的 Cache Aside Pattern 是:读的时候先读缓存,不中则读库再回写;写的时候先更新数据库,再删除缓存。其中“先更新库再删缓存”这个顺序,关键在极端场景下会有一个窗口期:一个请求刚读完旧数据还没回写缓存,另一个请求先更新了库再删缓存,这时第一个请求把旧数据写回去了,缓存里就留下了过期数据。这种概率很低(需要读和写同时命中同一个 key),但确实存在。
要规避这个窗口期,业界常用的办法是“延迟双删”:先删除缓存,更新数据库,隔几百毫秒再删一次缓存。这个方案的逻辑是用“两次删除”来覆盖掉中间可能被写入的脏数据。另外在 MySQL 场景下,还可以用 Canal 监听 binlog,异步删除/更新对应缓存。我实际做下来这种方式最省心,不存在你自己删缓存和更新库之间的顺序问题,由 binlog 的顺序保证。
最后提一嘴,如果你的业务对缓存一致性要求极高,那就别用缓存,直接把 Redis 当作高速数据库用,让写入路径直接写 Redis 并开启 AOF 持久化,避免“缓存 + 数据库”双写的天然不一致问题。这个选择看起来有些反直觉,但在某些场景下恰恰是最省事的。
写到这里,基本上把 Redis 从安装到生产维护的主干坑都盘了一遍。我这两年最深的感受是,Redis 的问题往往不是孤立的,安装时的随意、命令使用时的想当然、客户端配置的盲从,最后都会在线上某个节点爆发出来,而排查的手段永远是回到它的设计原理:单线程、内存、网络、持久化时机、主从同步机制——把这个底层模型吃透了,多数坑都能提前预判。真心建议各位在接新项目的时候,先花半天时间把 Redis 的配置逐项过一遍,慢就是快。