
第一次在项目里被 Redis 救场是好几年前一个商品详情页的接口。那时候数据库的 QPS 已经被压到贴着地板走页面每次刷新都要连表查十几张表高峰期接口响应时间直接干到三秒以上。后来把详情页那份聚合数据整成一份 JSON 塞进 Redis超时率当天就掉下来了。从那以后我对这个中间件的态度就变了——它不是加个缓存这么简单而是一套需要认真理解内存模型、数据结构、持久化策略和淘汰机制的基础设施。这篇内容面向的是准备把 Redis 用进生产环境、或者已经用了但心里没底的同学从它到底解决什么问题、单线程为什么还能扛住高并发到五种基础数据结构的落地选型、安装配置、缓存三大异常的应对再到线上排查的实操手法我会按我自己踩过的顺序讲一遍。看完你至少能做到两件事知道什么数据该往 Redis 里放、什么数据不该放以及在它出问题的时候知道从哪几个命令开始查。1. 先搞清楚 Redis 到底在项目里补的是哪块短板很多人对 Redis 的第一印象是内存数据库读得快这个描述没错但太浅。真把它放进架构里你得先明白它替代的是哪一段链路否则用着用着就会出现缓存加了一堆接口还是慢或者数据对不上这类问题。1.1 从一次接口超时反推它解决的问题回到开头那个详情页的例子。当时那条链路的耗时分布大致是这样的数据库连接池等待、多表 JOIN、结果集序列化、网络传输。真正压死骆驼的是 JOIN 和连接池争抢因为详情页的读请求量是写请求的几十倍每次读都要重新拼装一遍数据数据库的 buffer pool 命中率再高也顶不住这种重复劳动。Redis 在这里的价值不是快而是把一次复杂的计算或查询结果变成一次 O(1) 的内存读取。这两者的差别在于数据库快是因为索引和数据局部性Redis 快是因为它压根不做计算直接返回你之前存好的字节。理解这一点很关键因为它决定了你该缓存什么——缓存的是重复计算的结果而不是所有查询。我在实际项目里见过反例有人把用户表整表缓存成 Hash每次查单个用户还要 HGET 一次看着是走了 Redis实际上把关系型数据库的查询能力全丢了后来要做按手机号模糊搜索、按注册时间排序全得在应用层手写最后又灰溜溜地加回数据库查询。所以第一件事要先划清边界。1.2 Redis 与关系型数据库的职责分工我的划分原则很简单数据库负责真相Redis 负责效率。具体到落地下面这几类数据适合放进去读多写少、且允许短暂不一致的热点数据比如商品详情、配置项、字典表。有明确过期语义的数据比如登录态、验证码、短信频控计数。需要原子性计数的场景比如库存扣减的预扣、接口限流、文章阅读数。需要跨进程共享的临时状态比如分布式锁、任务队列、秒杀排队。反过来下面这些我基本不往 Redis 里塞需要复杂条件查询、聚合统计的业务主数据。单条体积超过几十 KB 的大对象尤其是列表页会批量取的。需要强事务保证的跨实体写操作。生命周期极短且无复用价值的临时数据。这里有个判断口诀如果这份数据丢了业务能不能靠数据库重建能重建的才适合放进缓存层不能重建的说明它是真相的一部分应该留在数据库。还有一点容易被忽略Redis 的内存这个属性意味着成本和容量都是硬约束。一台 16G 内存的机器实际能给 Redis 用的可能只有 10G 出头剩下要留给操作系统、fork 时的写时复制、以及内存碎片。所以在设计阶段就该估算数据量而不是等 OOM 了再反过来删 key。1.3 什么规模的项目才真正需要它经常有人问小项目要不要上 Redis。我的看法是看两个指标单表数据量是否已经让查询变慢以及是否出现了跨请求共享状态的需求。如果只是几千条数据的后台管理系统查询走索引完全够用引入 Redis 只是多了一个需要运维的组件。但只要出现下面任意一种情况它就值得上了同一份数据一秒内被读几十次以上需要给接口做频率限制需要给多个服务实例共享会话需要做排行榜、延时任务这类数据库不擅长的结构。这些都是硬需求跟项目规模无关。2. 单线程还能这么快底层到底做了什么Redis 是单线程的这句话流传太广以至于很多人第一次听到会怀疑它扛不住并发。实际情况是Redis 的命令执行确实是单线程的但它的快不是因为线程少而是因为没有线程切换开销 纯内存操作 精心设计的数据结构 IO 多路复用这几件事叠加在一起。2.1 单线程模型的真实含义与版本演进早期版本里Redis 从接收连接、读取命令、执行命令到写回结果全部在一个主线程里完成靠的是 epollLinux、kqueuemacOS这类多路复用机制一个线程轮询处理成千上万个连接。这带来的好处非常直接不需要加锁不存在竞态任何一条命令执行期间数据状态都是确定的。像 INCR 这种操作天然就是原子的你不用操心并发问题。但网络读写本身是有开销的。Redis 6.0 之后引入了 IO 多线程注意是只把 socket 的读写和协议解析分给多个线程命令执行仍然在主线程串行。这个改动对高吞吐场景比如大 value 的批量读写提升明显因为瓶颈从 CPU 计算转移到了网络拷贝上。配置上对应的是这两项# redis.conf io-threads 4 io-threads-do-reads yes提示io-threads 的设置经验值是 CPU 核数的 3/4 左右且不建议超过 8 个。开太多反而会因为线程调度拖慢我在一台 32 核机器上试过开到 16吞吐量相比 8 个还降了一点。2.2 内存存储与底层数据结构的取舍纯内存这件事本身就是数量级的差距。一次内存随机访问大概在 100 纳秒量级而一次机械硬盘寻道是毫秒级SSD 也是几十微秒级差距在四到五个数量级。但光快还不够Redis 在数据结构上做了很多省内存的文章这部分决定了你的实例能装多少数据。举个具体例子一个普通的 String 并不是简单地 malloc 一块内存而是用 SDS简单动态字符串来实现头部记录了长度和剩余空间好处是获取长度是 O(1)、拼接时可以做预分配、并且二进制安全可以存图片、序列化对象。再比如小规模的 Hash底层不会一上来就用哈希表而是用 listpack老版本叫 ziplist这种紧凑的连续内存结构元素少的时候省下大量指针开销。这些细节平时不用关心但有两个配置项跟它直接相关值得记住hash-max-listpack-entries 128 hash-max-listpack-value 64 zset-max-listpack-entries 128意思是当 Hash 的字段数不超过 128、且每个字段值不超过 64 字节时用紧凑结构存储一旦超过就转成真正的哈希表。转过去之后内存占用可能翻好几倍而且这个过程是不可逆的——即使你后来把字段删到只剩 3 个它也不会变回紧凑结构。我在一个存用户标签的 Hash 上踩过这个坑前期测试时每个用户 20 来个标签内存很漂亮上线后运营批量打标某些用户标签数冲到 200整个实例内存直接涨了 40%。2.3 持久化RDB 与 AOF 的选择逻辑内存数据库最怕的是断电所以持久化机制必须搞清楚。Redis 给了两种方式理解它们的关键是搞明白各自牺牲了什么。RDB是某一时刻的全量快照通过 fork 子进程 写时复制来完成。优点是文件紧凑、恢复速度快适合做备份和灾备。缺点是两次快照之间的数据会丢而且 fork 的瞬间如果数据量大会有明显的内存膨胀和短暂卡顿。AOF是追加写命令日志按 fsync 策略分三档策略行为数据安全性能影响always每条命令都刷盘最高最多丢一条最差QPS 掉一个数量级everysec每秒刷一次最多丢一秒可接受生产常用no交给操作系统可能丢较多最好不建议生产用AOF 的问题是文件会越来越大所以需要重写rewrite本质上也是 fork 子进程根据当前内存状态生成一份精简的命令集。实际生产里我基本都用混合持久化Redis 4.0 之后支持配置是这样appendonly yes appendfsync everysec aof-use-rdb-preamble yes开了混合模式之后AOF 重写时前半段用 RDB 格式存全量后半段追加增量命令。恢复时先加载 RDB 部分再重放命令兼顾了速度和完整性。这个配置我在这几年几个项目里都用恢复时间和数据安全性都比较平衡。注意如果实例内存超过 10Gfork 时的写时复制可能带来额外的内存峰值。建议预留至少 50% 的空闲内存或者把vm.overcommit_memory设为 1避免 fork 失败。3. 五种基础数据结构对应五类真实业务场景Redis 常被叫做数据结构服务器这个定位很准确。它的价值不在于能存 key-value而在于它把常用的几种数据结构做成了服务端原语你在应用层不用自己实现加锁和并发控制。3.1 String 与 Hash缓存对象时的两种打法String 是最基础的能存字符串、数字、二进制。最典型的用法是缓存整个对象序列化后的 JSONSET product:1001 {id:1001,name:某商品,price:99.00} EX 3600 GET product:1001EX 3600 表示一小时后过期。这种整存整取的方式适合读多写少、且每次都要完整对象的场景比如详情页。Hash 则适合需要部分读写、或者字段会独立变化的场景HSET user:2001 name A同学 level 3 lastLogin 2024-05-01 HGET user:2001 level HINCRBY user:2001 level 1用 Hash 的好处是可以只更新某个字段不用把整个对象读出来改完再写回去减少了网络传输和序列化开销。缺点是过期时间只能加在整个 key 上没法给单个字段设 TTL。选型上我的经验是对象字段少、整体读写就 String对象字段多、经常单独更新某个字段就 Hash。但要注意 2.2 里提到的结构转换问题字段数别让它无限增长。有个容易忽略的细节String 存数字时可以直接用 INCR/DECR 做原子计数这是做限流、库存预扣的基础。像接口频控这种场景我是这么写的# 一分钟内同一用户最多 60 次 INCR rate:limit:user:2001:202405011200 EXPIRE rate:limit:user:2001:202405011200 60两次命令不是原子的严格场景需要用 Lua 脚本或者SET key 1 EX 60 NX配合 INCR 的组合来处理。3.2 List 与 Set队列、去重与关系判断List 是双向链表底层实际是 quicklist由多个紧凑节点串起来支持两端插入弹出天然适合做队列LPUSH queue:task job-001 BRPOP queue:task 5BRPOP 是阻塞版本队列空的时候会挂起等待超时返回 nil。用这个做简单的任务队列是可行的但要注意它没有 ACK 机制——消费者取走消息后如果进程崩了这条消息就丢了。要可靠投递得上 Stream 类型或者专业的消息队列。Set 是无序集合最大的价值是去重和集合运算SADD article:1001:liked user:2001 SADD article:1001:liked user:2002 SCARD article:1001:liked SINTER user:2001:follow user:2002:followSINTER 求交集这个能力在做共同关注共同好友时特别省事一行命令解决不用把两个列表拉到应用层做双层循环。但要注意大集合求交集的复杂度是 O(N*M)几万规模的集合还行上百万就要考虑用别的方式了。3.3 ZSet排行榜与延时任务的核心有序集合是我个人认为 Redis 最有价值的结构。它每个元素带一个 score按 score 排序底层用跳表实现插入和范围查询都是 O(logN)。排行榜是最经典的应用ZINCRBY rank:20240501 1 user:2001 ZREVRANGE rank:20240501 0 9 WITHSCORES第一行给用户加分第二行取分数最高的前十名含分数。ZREVRANGE 是倒序正序用 ZRANGE。另一个用法是延时任务。把任务的执行时间戳作为 score然后用 ZRANGEBYSCORE 按时间范围取到期任务ZADD delay:queue 1714560000 order-close:3001 ZRANGEBYSCORE delay:queue 0 1714560000 LIMIT 0 100配合定时轮询就实现了一个轻量的延时队列。我在几个项目里用它做过订单超时关闭比引入完整消息队列轻量得多精度控制在秒级完全够用。3.4 结构选型速查业务需求推荐结构关键命令注意事项对象缓存StringSET/GET体积控制在 10KB 内局部字段更新HashHSET/HGET字段数别超 128简单队列ListLPUSH/BRPOP无 ACK谨慎用于关键任务去重计数SetSADD/SCARD大集合注意内存共同关系SetSINTER规模别上百万排行榜ZSetZINCRBY/ZREVRANGEscore 用 double注意精度延时任务ZSetZADD/ZRANGEBYSCORE需要轮询进程配合频控计数StringINCR/EXPIRE建议用 Lua 保证原子4. 从零装一套能用的 Redis理论讲完落到手上。这部分我按装、配、验三步走配置部分会把几个真正影响生产稳定性的项挑出来讲。4.1 安装方式的选择与取舍三种常见方式包管理器安装、源码编译、容器运行。包管理器最省事apt install redis-server或者yum install redis但版本往往偏旧有些新特性用不上。源码编译能拿到最新稳定版代价是要自己处理依赖wget https://download.redis.io/releases/redis-7.2.4.tar.gz tar -zxvf redis-7.2.4.tar.gz cd redis-7.2.4 make make install PREFIX/usr/local/redis容器方式适合本地开发和快速验证docker run -d -p 6379:6379 redis:7.2。但生产环境用容器要额外注意数据卷挂载和内存限制容器内的 maxmemory 和宿主机的 cgroup 限制不一致时容易出现 OOM Kill 而不是正常的淘汰。提示编译前确认 gcc 版本7.x 之后要求 gcc 5.3 以上。另外make失败最常见的原因是 jemalloc 编译不过可以加MALLOClibc参数绕过。4.2 生产环境必须改的配置项默认配置是给开发用的直接上生产会出事。下面这几项我每次部署都会过一遍# 网络与安全 bind 0.0.0.0 protected-mode yes port 6379 requirepass 你的强密码 # 运行方式 daemonize yes maxclients 10000 # 内存 maxmemory 8gb maxmemory-policy allkeys-lru # 持久化 appendonly yes appendfsync everysecmaxmemory一定要设不设的话 Redis 会一直吃内存直到被系统杀掉那时候就不是淘汰几个 key 的问题了而是整个实例挂掉。maxmemory-policy的选择取决于你的数据性质noeviction内存满了直接报错适合数据不能丢的场景。allkeys-lru所有 key 里淘汰最近最少使用的纯缓存场景首选。volatile-lru只淘汰设了过期时间的 key适合缓存和持久数据混存的实例。allkeys-lfu按访问频率淘汰热点数据保护更好4.0 之后支持。我个人在纯缓存实例上一律用allkeys-lru在混合实例上用volatile-lru并且严格要求持久数据不设 TTL靠这个约定来保证它们不会被误淘汰。4.3 连通性验证与基础命令实测装完之后别急着写代码先手工验一遍redis-cli -h 127.0.0.1 -p 6379 -a 你的密码 127.0.0.1:6379 PING PONG 127.0.0.1:6379 INFO serverPING 返回 PONG 说明连通。INFO 里重点看几个字段redis_version版本、uptime_in_days运行天数、connected_clients当前连接数。再跑一组读写验证127.0.0.1:6379 SET test:key hello EX 60 OK 127.0.0.1:6379 TTL test:key (integer) 59 127.0.0.1:6379 GET test:key helloTTL 返回 59 而不是 60是因为命令执行有极小的耗时属于正常现象。如果 TTL 返回 -1 说明没设过期返回 -2 说明 key 不存在——这两个返回值经常被混淆写监控脚本时要注意区分。5. 缓存三大异常每个都有对应的打法缓存用起来之后真正让人睡不着觉的不是性能而是数据一致性和异常流量。所谓穿透、击穿、雪崩这三个词说的是三种不同的失效模式应对手段也完全不同必须分清楚。5.1 三种失效模式的成因对比异常类型触发条件现象影响范围缓存穿透查询根本不存在的数据每次都打到数据库可能被恶意利用缓存击穿热点 key 过期瞬间大量请求同时打库单个热点缓存雪崩大量 key 同时过期或实例宕机数据库瞬间承压全局性穿透的本质是缓存里没有数据库里也没有查询永远落空缓存形同虚设。击穿是缓存里本来有刚好过期了一瞬间所有请求涌向数据库。雪崩则是更大范围的集体失效。这三者的共同点是把数据库当成了兜底所以核心思路都是在缓存层就把请求挡住或者分流。5.2 穿透与击穿的落地实现对付穿透最直接的办法是缓存空值。查数据库没查到也往 Redis 里塞一个特殊标记TTL 设短一点比如 60 秒def get_user(user_id): key fuser:{user_id} cached redis.get(key) if cached is not None: if cached __NULL__: return None return json.loads(cached) user db.query_user(user_id) if user is None: redis.set(key, __NULL__, ex60) return None redis.set(key, json.dumps(user), ex3600) return user这个做法的隐患是如果攻击者用随机 ID 疯狂请求会往 Redis 里塞大量空值 key所以通常要配合布隆过滤器在入口先做一层拦截。布隆过滤器判断一定不存在是准确的判断存在有误判率刚好适合放在缓存前面。击穿的解法是加互斥锁保证同一时刻只有一个请求去回源def get_hot_data(key): data redis.get(key) if data: return data lock_key flock:{key} if redis.set(lock_key, 1, nxTrue, ex10): try: data db.query(key) redis.set(key, data, ex300) return data finally: redis.delete(lock_key) else: time.sleep(0.05) return get_hot_data(key)注意nxTrue这个参数等价于 SETNX只有 key 不存在时才设置成功这是拿锁的关键。锁的过期时间要设否则持有者崩了会死锁。5.3 雪崩的预防与兜底策略雪崩的预防核心是打散过期时间。给 TTL 加一个随机偏移量是最简单有效的做法ttl 3600 random.randint(0, 600) redis.set(key, value, exttl)这样即使批量预热过期时间也会分散在十分钟的窗口里不会同时失效。除了打散还要做几层兜底本地缓存比如 Caffeine作为二级缓存挡第一波流量数据库连接池限流避免被打死关键接口做熔断降级返回兜底数据而不是直接报错。我的实际经验有一次批量导入商品后统一预热缓存所有 key 的 TTL 都是 7200 秒结果两小时后整批同时失效数据库 CPU 直接打满。后来改成预热时按7200 random(0, 1200)设置再没出现过集体失效。5.4 双写一致性该怎么做缓存和数据库同时写一定有短暂不一致关键是控制窗口和最终一致。业界主流是 Cache Aside 模式读的时候先读缓存没有再读库并回填写的时候先更新数据库再删除缓存。为什么是删缓存而不是更新缓存因为并发更新时两个请求的更新顺序可能和写库顺序不一致导致缓存里存了旧值。删除操作更安全下次读的时候自然回填最新值。但先写库再删缓存仍有极小概率出问题读请求在写库之后、删缓存之前读到旧值并回填。常用的补救是延迟双删——写完库删一次隔几百毫秒再删一次。更彻底的方案是订阅数据库的 binlog异步删除缓存把一致性保障从业务代码里剥离出来。6. 线上出问题时我一般从这几个命令开始查Redis 的问题看起来五花八门实际上集中在几类内存涨了、变慢了、连接不上了、数据不对了。每一类都有对应的排查入口。6.1 内存类问题的定位路径第一步看整体redis-cli -a 密码 INFO memory重点看三个值used_memory_human实际使用、used_memory_rss_human操作系统看到的内存、mem_fragmentation_ratio碎片率。碎片率在 1.0 到 1.5 之间算正常超过 1.5 说明碎片较多可以考虑开启activedefrag yes做在线碎片整理或者在低峰期重启。第二步找大 keyredis-cli -a 密码 --bigkeys这个命令会扫描全库并列出每种类型最大的 key。注意它会遍历所有键在数据量大的实例上会明显增加负载建议在从节点或者低峰期执行。找到大 key 之后单个 key 的内存占用可以用MEMORY USAGE user:2001如果确认是某个大 Hash 或者大 List 占了几百兆删除时千万别用 DEL会阻塞主线程。改用UNLINK它在后台线程异步释放内存UNLINK big:hash:key6.2 变慢的常见原因与排查手法Redis 变慢无非几个方向慢命令、大 key 操作、网络问题、持久化阻塞。先用慢查询日志定位CONFIG SET slowlog-log-slower-than 10000 SLOWLOG GET 1010000 微秒就是 10 毫秒超过这个阈值的命令会被记录。SLOWLOG GET 返回最近的慢命令包含执行时间、命令内容、客户端地址。常见的慢命令有KEYS *全库扫描生产禁用、FLUSHALL清库、大集合的SMEMBERS、大 ZSet 的ZRANGE全量取。这些都在主线程执行一次就能把整个实例卡住。另一个隐蔽的原因是持久化。开着 AOF everysec 的情况下如果磁盘 IO 跟不上fsync 会阻塞写入。可以通过INFO persistence看aof_delayed_fsync这个指标如果它一直增长说明磁盘扛不住了要么换 SSD要么改配置。6.3 连接与命中率类问题连接数打满是很常见的线上故障先用这个看CLIENT LIST INFO clientsconnected_clients接近maxclients时新连接会被拒绝。原因通常是应用侧连接池配置过大或者连接没正确释放。检查连接池的maxTotal和maxIdle一般建议单实例连接池控制在 50 以内多个应用实例加起来别超过 maxclients 的 80%。命中率同样重要INFO stats看keyspace_hits和keyspace_misses两者相除就是命中率。低于 80% 就要反思缓存策略了可能是 TTL 太短、缓存的数据根本没人访问、或者 key 设计有问题导致查不到。6.4 排查速查表现象首选命令常见原因处理方式内存持续上涨INFO memory大 key、无 TTL、碎片找大 key、补 TTL、开碎片整理响应变慢SLOWLOG GET慢命令、大 key 操作替换命令、拆分 key连接被拒INFO clients连接池过大、未释放调小池子、查泄漏命中率低INFO statsTTL 过短、key 设计差调整策略、重新设计持久化卡顿INFO persistence磁盘 IO 不足换盘、调 fsync 策略数据不一致业务侧日志双写顺序错、删除失败延迟双删、订阅 binlog一个容易忽略的点INFO命令在高版本里可以带 section 参数比如INFO memory只返回内存相关字段比全量 INFO 快很多。写监控脚本时用分节查询能减少对主线程的影响。最后分享一个我在实际运维里养成的小习惯给每个业务线的 key 加上统一的命名前缀比如order:、user:、cfg:配合SCAN命令按前缀遍历千万别用 KEYS。这样做的好处是排查时能快速圈定范围做容量评估时也能按业务线统计。我一般是这么扫的redis-cli -a 密码 --scan --pattern order:* | head -100另外新实例上线前我都会跑一轮压测用 redis-benchmark 把基线摸清楚redis-benchmark -h 127.0.0.1 -p 6379 -a 密码 -n 100000 -c 50 -t set,get -q这个基线数据的价值在于线上出现性能问题时你有个对照系——是整体变慢了还是某类命令变慢了一眼就能判断。没有基线的排查基本靠猜有了基线排查效率完全是另一个层次。