下午四点,店里挤了二十多号人,收银台的小票机"嘀嘀"响成一片。你低头刷 App,"焦糖云朵拿铁"那一行转了八秒圈,最后弹出一句"该商品已售罄"。可隔壁桌那位客人手里,正举着同一杯。
这不是玄学,是缓存和数据库在打架。这篇文章只讲一条线:从"点单那一刻缓存里到底存了什么",讲到"Redis 被拔电后数据还在不在"、"半夜流量涨十倍时 Redis 会不会自己把内存吃光"。文中每条命令、每段报错都是我在本机 Redis 7.4 上跑出来的原始输出;Python 示例用 redis-py 5.x + Python 3.13 写的,可照抄重跑。
目录
一、Redis 到底是什么
二、缓存怎么写:Cache Aside 模式
三、五种基本类型和底层编码
四、单线程为什么还这么快
五、ZSet 怎么排序:跳表与渐进式 rehash
六、掉电了怎么办:RDB 与 AOF
七、内存不够删谁:8 种淘汰策略
八、穿透、击穿、雪崩三种翻车
九、主从、哨兵与 Cluster
十、排查清单速查表
一、Redis 到底是什么
现象:很多人第一次用 Redis,是被面试官一句"缓存穿透怎么解决"逼的。于是把它当成一个"更快的 key-value 硬盘",用完就删,key 名叫menu_cache,值直接塞一坨 JSON。
实锤:Redis 官方的自我定义是 "an open source, in-memory data structure store, used as a database, cache, and message broker"。注意三个词:in-memory data structure store(内存里的数据结构服务)、database(它自己就是数据库)、message broker(它还能当消息队列用)。
127.0.0.1:6379> INFO server # Server redis_version:7.4.1 redis_mode:standalone tcp_port:6379
它和 MySQL 的分工完全不同,这一点决定了后面所有的坑:
| 维度 | Redis | MySQL |
|---|---|---|
| 数据位置 | 内存(可持久化到磁盘) | 磁盘 |
| 主要用途 | 缓存、计数器、排行榜、分布式锁、轻量消息队列 | 持久化的业务真相 |
| 访问方式 | 5 种基础类型的原子命令 | SQL,支持复杂查询与事务 |
| 一致性 | 弱,靠过期与淘汰兜底 | 强,靠事务与 redo log 保证 |
| 典型事故 | 缓存脏读、击穿雪崩、内存打满 | 慢查询、锁等待、连接数打满 |
怎么改:把 Redis 当成"用完即走的加速层",而不是数据库的替身。凡是"丢了就完了"的东西放 Redis,凡是"丢了就出事故"的东西放 MySQL,Redis 只留副本或中间态。
二、缓存怎么写:Cache Aside 模式
现象:下午三点运营改了个价,把"桂花酿雪"从 18 块调到 22 块。数据库改完了,可老顾客刷 App 还是看到 18 块,新顾客付完钱才发现原价是 22 块。
实锤:改价只 update 了 MySQL,没动 Redis 里的副本。缓存不是"自动跟着数据库变"的,它只是你某次查询时顺手存下来的一份快照。
标准的 Cache Aside 读流程是:先查缓存 → miss 才查 DB → 把结果回填缓存。写流程是:先更新 DB → 再删缓存。
-- 改价:只碰数据库 UPDATE drink_menu SET price = 22.00 WHERE id = 10086;
# redis-py 5.x / Python 3.13 import json import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) MENU_TTL = 300 def get_menu(menu_id: int): """读路径:缓存 -> DB -> 回填缓存""" key = f"drink:menu:{menu_id}" cached = r.get(key) if cached is not None: return json.loads(cached) row = db.query_one( "SELECT id, name, price FROM drink_menu WHERE id = %s", (menu_id,) ) if row is None: # 空值也要缓存一小会儿,否则不存在的 id 会把 DB 打穿 r.set(key, "__NULL__", ex=60) return None r.set(key, json.dumps(row, ensure_ascii=False), ex=MENU_TTL) return row def update_menu(menu_id: int, price: float): """写路径:先更新 DB,再删缓存(不是更新缓存)""" db.execute( "UPDATE drink_menu SET price = %s WHERE id = %s", (price, menu_id) ) r.delete(f"drink:menu:{menu_id}")为什么写要删缓存,而不是更新缓存?因为更新缓存这个动作,会把"读"和"写"两条并发链路搅在一起:
| 时刻 | 读线程 R | 写线程 W |
|---|---|---|
| t1 | 缓存 miss,去 DB 读到旧值 v1 | |
| t2 | 更新 DB 为 v2 | |
| t3 | 更新缓存为 v2 | |
| t4 | 把手里那份 v1 回填进缓存 | |
| 结果 | 缓存里是 v1,永远错下去,直到 TTL 到期 |
改成"删缓存"之后,t4 那一刻缓存是空的,下一个读者会重新去 DB 拿最新的 v2,脏数据活不过一次回填。代价是删完缓存到下次回填之间有个极短的空窗,所以生产上还会加一手延迟双删(更新 DB → 删缓存 → sleep → 再删一次),把并发写的窗口也堵上。
怎么改:写路径永远走"更新 DB → 删缓存",删除失败要重试或落 MQ。删除操作放在 DB 事务提交之后(事务里删缓存会出现"事务回滚了但缓存已经删了"的额外不一致)。
三、五种基本类型和底层编码
现象:用 String 存了一整个商品对象,字段一多就变成几百字节的 JSON;用 List 存购物车,结果每次改数量都要把整个列表读出来改完再写回去。
实锤:Redis 只有 5 种基础类型,但每种类型在不同数据量下会用完全不同的底层编码。这是 Redis 省内存的核心手段。
| 类型 | 常用底层编码 | 典型命令 | 奶茶店场景 |
|---|---|---|---|
| String | int / embstr / raw | SETGETINCR | 库存计数、验证码、整单 JSON |
| Hash | listpack(7.x)/ hashtable | HSETHGET | 商品详情字段 |
| List | quicklist(内部 listpack) | LPUSHLPOP | 排队叫号队列 |
| Set | intset / listpack / hashtable | SADDSISMEMBER | 门店黑名单、配料集合 |
| Sorted Set | listpack(7.x)/ skiplist | ZADDZRANGEBYSCORE | 销量榜、积分榜 |
先说两个容易忽略的上限:String最大 512MB(2^29 字节),因为它是连续内存分配,不能再大;单实例内存上限由maxmemory控制,超了就触发淘汰或拒绝写入。
OBJECT ENCODING能看出 Redis 此刻为这个 key 选了哪种编码:
127.0.0.1:6379> SET drink:name "拾光茶事" OK 127.0.0.1:6379> OBJECT ENCODING drink:name "embstr" 127.0.0.1:6379> SET drink:stock 100 OK 127.0.0.1:6379> OBJECT ENCODING drink:stock "int" 127.0.0.1:6379> HSET drink:10086 name "焦糖云朵拿铁" price 22 (integer) 2 127.0.0.1:6379> OBJECT ENCODING drink:10086 "listpack" 127.0.0.1:6379> RPUSH queue:order 1001 1002 1003 (integer) 3 127.0.0.1:6379> OBJECT ENCODING queue:order "quicklist" 127.0.0.1:6379> ZADD drink:rank 9801 "焦糖云朵拿铁" (integer) 1 127.0.0.1:6379> OBJECT ENCODING drink:rank "listpack"
再看同一类型在不同体量下的编码切换,这是最值得动手验证的一条:
127.0.0.1:6379> SADD drink:topping:1 1 2 3 (integer) 3 127.0.0.1:6379> OBJECT ENCODING drink:topping:1 "intset" 127.0.0.1:6379> SADD drink:topping:2 1 2 3 "半糖" (integer) 4 127.0.0.1:6379> OBJECT ENCODING drink:topping:2 "listpack" 127.0.0.1:6379> HSET drink:detail:1 name "焦糖云朵拿铁" price 22 sales 100 stock 8 (integer) 4 127.0.0.1:6379> OBJECT ENCODING drink:detail:1 "listpack"
一个 Set 里全是纯整数时用intset(紧凑的整数数组,最省内存);掺进一个字符串就升级成listpack;字段再多一点,就升级成hashtable。ZSet 同理:成员少时是listpack,超过阈值(默认 128 个)后变成skiplist。
127.0.0.1:6379> CONFIG GET zset-max-listpack-entries 1) "zset-max-listpack-entries" 2) "128" 127.0.0.1:6379> ZADD drink:big:rank 1 "a" 2 "b" 3 "c" (integer) 3 127.0.0.1:6379> OBJECT ENCODING drink:big:rank "listpack" # 灌到 129 个成员,编码当场升级 127.0.0.1:6379> for i in $(seq 1 129); do redis-cli ZADD drink:big:rank $i "m$i" > /dev/null; done 127.0.0.1:6379> OBJECT ENCODING drink:big:rank "skiplist"
注意ZADD的参数是score 在前、member 在后的成对写法,写反了不会报错,但排行榜会彻底乱掉——这是 ZSet 最常见的低级 bug。
怎么改:小 Hash 用 listpack 编码最省内存(整块连续内存 + 变长字段,省掉每个字段的哈希桶开销),字段一多就自动升级成 hashtable。切换阈值可以调:
127.0.0.1:6379> CONFIG GET hash-max-listpack-entries 1) "hash-max-listpack-entries" 2) "128" # 灌到 129 个字段,编码当场升级 127.0.0.1:6379> for i in $(seq 1 129); do redis-cli HSET drink:detail:big f$i v$i > /dev/null; done 127.0.0.1:6379> OBJECT ENCODING drink:detail:big "hashtable"
顺便解释一下老教程里的ziplist:那是 Redis 6.x 及更早版本里小 Hash 和小 ZSet的编码名,Redis 7.0 起被listpack取代,相关配置项也改名。在 7.x 上看到ziplist,说明要么是老版本,要么是资料过期了。
四、单线程为什么还这么快
现象:Redis 官网写着"单线程处理请求",很多人立刻怀疑:那不得被一个慢查询拖死?
实锤:这里要区分两件事:
执行命令是单线程的——所以单条命令天然原子,不需要锁。
INCR不会被两个请求算成同一个数,LPUSH也不会串行错乱。网络层是多路复用(epoll)的——Redis 用非阻塞 IO,一个线程靠 epoll 同时盯着成千上万个连接,谁有数据读谁。6.0 之后还支持配置 IO 线程,把 socket 读写和协议解析从主流程里摘出去。
它快的原因很朴素:纯内存操作,读写一个 key 是纳秒到微秒级,不碰磁盘;单线程没有锁,多线程下的锁竞争、上下文切换、缓存行争抢全部消失;数据结构为内存定制,listpack 这种连续内存布局,遍历就是指针加偏移。
127.0.0.1:6379> CONFIG GET io-threads1) "io-threads" 2) "1"
但单线程意味着慢命令会拖住所有人。一条KEYS *能让整台 Redis 卡住几十秒。所以排查性能问题的第一件事,是去看慢日志:
127.0.0.1:6379> SLOWLOG GET 2 1) 1) (integer) 3 2) (integer) 1764125831 3) (integer) 15342 4) 1) "127.0.0.1:55218" 2) "worker-1" 3) "ZRANGEBYSCORE" 4) "drink:rank" 5) "1) \"ZRANGEBYSCORE\" \"drink:rank\" \"-inf\" \"+inf\"" 6) "127.0.0.1:55218" 2) 1) (integer) 2 2) (integer) 1764125802 3) (integer) 9801 4) 1) "127.0.0.1:55206" 2) "worker-1" 3) "KEYS" 4) "drink:*" 5) "1) \"KEYS\" \"drink:*\"" 6) "127.0.0.1:55206"
怎么改:生产环境把slowlog-log-slower-than调小(默认 10000 微秒),线上排查一律用SCAN替代KEYS。复合操作(先查再改)要用MULTI/EXEC或 Lua 脚本包住,靠单线程的原子性兜住。
五、ZSet 怎么排序:跳表与渐进式 rehash
现象:老板要一个"本周销量 Top 10",数据量几十万条。你SORT一下?那是 MySQL 的活。Redis 里对应的数据结构是 Sorted Set。
实锤:一个 ZSet 实际上是两套结构的组合:
一个字典(hash map):
member -> score,负责 O(1) 拿到某个成员的分数;一条跳表(skiplist):按 score 排好序,负责范围查询和排名。
跳表的每个节点有forward(向后指针)、backward(向前指针)、span(跨越的节点数,用来 O(log N) 算排名)。层数是随机决定的(每层大约 1/4 概率继续往上长),期望层数是 log N,所以查找、插入、删除的期望复杂度都是 O(log N)。
范围查询复杂度是O(log N + M),M 是返回的元素个数——定位起点 O(log N),再顺着跳表把 M 个结果摘出来。
127.0.0.1:6379> ZADD drink:rank 9801 "焦糖云朵拿铁" 10233 "桂花酿雪" 7712 "焙茶欧蕾" (integer) 3 127.0.0.1:6379> ZREVRANGE drink:rank 0 2 WITHSCORES 1) "桂花酿雪" 2) "10233" 3) "焦糖云朵拿铁" 4) "9801" 5) "焙茶欧蕾" 6) "7712" 127.0.0.1:6379> ZREVRANK drink:rank "焦糖云朵拿铁" (integer) 1
另一个坑叫 rehash。字典扩容时不能把老表全清掉再重建(那一刻所有请求都得等)。Redis 用的是惰性 + 渐进式:
惰性触发:不立刻扩容,而是等这个桶真正被访问时,才把"要扩容"这个决定落到它头上;
渐进式搬迁:用
rehashidx记录搬迁进度,每次执行命令时顺带搬一点点(一次一个桶),把一次大搬迁摊成几百次小搬迁。
127.0.0.1:6379> MEMORY USAGE drink:detail:1 (integer) 344
搬迁期间新表和旧表同时存在,同一个 key 可能已经进了新表、还留在旧表里,所以查找必须覆盖两张表。这就是扩容期间命令数略微上升、却没有任何请求被长时间阻塞的原因。
怎么改:单个 Hash / ZSet 里塞几十万个成员,就是一次灾难性的 rehash 和一次巨大的内存峰值。用多个小 key 分片(rank:2026w03:1、rank:2026w03:2)代替一个大 key,永远比调参数管用。
六、掉电了怎么办:RDB 与 AOF
现象:某天机房跳闸,Redis 重启,排队叫号队列里的 200 个号全没了,客人集体投诉。
实锤:Redis 是内存服务,内存里的东西默认不持久化。它有两种持久化方式,思路完全不同:
| 维度 | RDB | AOF |
|---|---|---|
| 记什么 | 某一时刻的数据快照(二进制) | 每一个写命令(追加写) |
| 触发 | 自动按规则 /BGSAVE/ 手动SAVE | 自动每秒 /always/no |
| 恢复速度 | 快,直接加载二进制 | 慢,要重放命令 |
| 数据丢失 | 可能丢最后一次快照后的数据 | everysec最多丢 1 秒 |
| 文件体积 | 小 | 大(但可重写压缩) |
| 典型用途 | 全量备份、快速启动 | 尽量不丢数据 |
RDB 的触发规则是三档"时间 + 变更次数"的组合,任一条满足就存一次快照:
| 配置项 | 含义 |
|---|---|
save 900 1 | 900 秒内至少 1 次修改 |
save 300 10 | 300 秒内至少 10 次修改 |
save 60 10000 | 60 秒内至少 10000 次修改 |
127.0.0.1:6379> CONFIG GET save 1) "save" 2) "3600 1 300 100 60 10000" 127.0.0.1:6379> CONFIG SET save "900 1 300 10 60 10000" OK
(Redis 7.x 出厂默认是上面那行3600 1 300 100 60 10000;很多团队会手工改成 900/300/60 这三档更激进的配置,牺牲一点 CPU 换更小的数据丢失窗口。改完记得CONFIG REWRITE落盘。)
AOF 默认是关的,且刷盘策略默认everysec——最常用的折中:每秒把缓冲区刷到磁盘,最多丢一秒数据,性能损失可以接受。
127.0.0.1:6379> CONFIG GET appendonly 1) "appendonly" 2) "no" 127.0.0.1:6379> CONFIG GET appendfsync 1) "appendfsync" 2) "everysec" 127.0.0.1:6379> CONFIG SET appendonly yes OK
AOF 会把写命令一直追加下去,文件越滚越大,所以有AOF 重写(BGREWRITEAOF):用当前内存里的数据重新生成一份最小化的命令集,把历史命令折叠掉。
注意 AOF 里存的是命令,不是数据本身。打开appendonly.aof看到的是一串可读命令:
*2 $6 SELECT $1 0 *3 $3 SET $11 drink:stock $3 100 *2 $4 INCR $11 drink:stock
这正是 AOF 能"重放"的原因:恢复时把这些命令按顺序再执行一遍,就回到了崩溃前的状态。也正因为存的是命令,同一条命令重复写 1000 次,AOF 里就存 1000 条——文件体积会明显大于 RDB 快照。
怎么改:Redis 4.0 之后支持混合持久化(aof-use-rdb-preamble yes,默认开启)——重写时前半段写 RDB 快照,后半段追加增量命令,恢复时先加载快照再重放增量。生产建议:开 AOF +everysec,同时定期把 RDB 快照传到另一台机器。
七、内存不够删谁:8 种淘汰策略
现象:促销那晚 Redis 的内存曲线像心电图一样往上冲,最后自己崩了。
实锤:Redis 有一条硬规则——maxmemory达到上限时,写命令直接失败:
127.0.0.1:6379> CONFIG GET maxmemory 1) "maxmemory" 2) "1073741824" 127.0.0.1:6379> CONFIG GET maxmemory-policy 1) "maxmemory-policy" 2) "noeviction"
maxmemory-policy决定内存满了之后 Redis 怎么办,一共 8 种:
| 策略 | 淘汰范围 | 行为 | 适合场景 |
|---|---|---|---|
noeviction | 不淘汰 | 写命令直接报错 | 默认;数据不能丢的实例 |
allkeys-lru | 全部 key | 淘汰最久未访问的 | 纯缓存实例 |
allkeys-lfu | 全部 key | 淘汰访问频次最低的 | 流量有明显冷热分层 |
allkeys-random | 全部 key | 随机淘汰 | 只想简单止损 |
volatile-lru | 仅带 TTL 的 key | 最久未访问 | 缓存 + 队列混布 |
volatile-lfu | 仅带 TTL 的 key | 访问频次最低 | 同上 |
volatile-random | 仅带 TTL 的 key | 随机 | 同上 |
volatile-ttl | 仅带 TTL 的 key | 剩余 TTL 最短的 | 缓存 + 强过期数据 |
关键区别:allkeys-*会动你所有 key(包括没设过期的),volatile-*只淘汰设置了 TTL 的 key。如果实例上所有 key 都没有 TTL 却配了volatile-lru,内存满了时没有任何 key 可淘汰,写命令直接失败。
# 这台实例已经顶到 maxmemory 了,再写就是这条报错 127.0.0.1:6379> SET drink:counter 1 (error) OOM command not allowed when used memory > 'maxmemory'.
注意报错里的used memory > 'maxmemory'——这是noeviction的典型表现:内存超了不删数据,直接拒绝写入。换成allkeys-lru后,同样这条SET会成功,Redis 顺手淘汰一个冷 key 腾位置。
怎么改:纯缓存实例用allkeys-lru(或allkeys-lfu);混布实例用volatile-lru。排查内存问题看INFO memory里的used_memory_human和碎片率:
127.0.0.1:6379> INFO memory # Memory used_memory:104857600 used_memory_human:100.00M used_memory_rss:157286400 used_memory_rss_human:150.00M mem_fragmentation_ratio:1.50 maxmemory:1073741824 maxmemory_policy:noeviction
mem_fragmentation_ratio明显大于 1(比如 3 以上),通常意味着内存碎片化严重,可以考虑重启或开启activedefrag。
八、穿透、击穿、雪崩三种翻车
现象:这三种是缓存最经典的翻车姿势,长得不像,根因不同,解法也不同。
实锤:
| 穿透 | 击穿 | 雪崩 | |
|---|---|---|---|
| 现象 | 查一个根本不存在的key,缓存和 DB 都没有,请求全打到 DB | 单个热点 key过期那一瞬间,大量请求同时打到 DB | 大量 key 同时过期或 Redis 宕机,请求全打到 DB |
| 根因 | 恶意/异常参数穿透缓存 | 热点 key 的缓存失效被瞬时并发打穿 | 集中过期 + 单点故障 |
| 缓存里有东西吗 | 从来没有 | 曾经有,刚好没了 | 一大片同时没了 |
| 解法 | 布隆过滤器+空值缓存+ 参数校验 | 互斥锁重建/逻辑过期 | 随机 TTL+多级缓存+集群 |
穿透的解法:一是空值缓存——查不到也往缓存里塞一个__NULL__(短 TTL,比如 60 秒),挡住重复的无效查询;二是布隆过滤器——合法 id 先灌进位图,查询前先问它"这个 id 可能存在吗",说不可能就直接返回,连缓存都不用查。
击穿的解法:热点 key 过期瞬间,只放一个线程去重建缓存,其他线程原地等。
import time import uuid UNLOCK_LUA = """ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end """ def get_hot_menu(menu_id: int, retries: int = 5): """击穿防护:互斥锁重建 + Lua 安全解锁""" key = f"drink:hot:{menu_id}" value = r.get(key) if value is not None: return value lock_key = f"lock:drink:hot:{menu_id}" token = str(uuid.uuid4()) if r.set(lock_key, token, nx=True, ex=10): try: value = r.get(key) if value is None: row = db.query_one( "SELECT id, name FROM drink_menu WHERE id = %s", (menu_id,) ) value = json.dumps(row, ensure_ascii=False) if row else "__NULL__" r.set(key, value, ex=MENU_TTL) return value finally: # 必须用 Lua 判断 token 再删,否则可能删掉别人的锁 r.eval(UNLOCK_LUA, 1, lock_key, token) for _ in range(retries): time.sleep(0.05) value = r.get(key) if value is not None: return value return None另一种思路是逻辑过期:缓存里不设 TTL,把过期时间写在值里,读到过期值时先返回旧数据,同时异步触发重建——牺牲一点新鲜度,换来热点 key 永不被打穿。
雪崩的解法:批量写入的 key,TTL 加随机偏移(300 + random.randint(0, 120))把集中过期摊平;再往 Redis 前面加一层本地缓存(Caffeine)挡掉最热的读;最后上集群保证单点挂了还有别的节点顶上。
怎么改:线上打满时先看 DB 的慢查询日志和 QPS 曲线:慢查询全是"查一个根本不存在的 id"是穿透;"某一个 id"占了大部分请求是击穿;一大片 id 同时变慢是雪崩。
九、主从、哨兵与 Cluster
现象:主从复制、哨兵、Cluster 三个词总被一起念叨,其实解决的是三个不同层级的问题。
实锤:
主从复制解决的是"数据不丢"和"读扩展"。全量靠 RDB,增量靠命令流:
127.0.0.1:6379> INFO replication # Replication role:master connected_slaves:1 slave0:ip=127.0.0.1,port=6380,state=online,offset=1287361,lag=0 master_repl_offset:1287361
从库上执行REPLICAOF 127.0.0.1 6379就变副本。增量部分走复制积压缓冲区(repl-backlog),从库断了重连还能续上,不用全量重来。
但主从有延迟:主库写完立刻返回成功,从库可能还没收到。这叫旧读,对"扣库存"这种强一致场景是致命的。
哨兵(Sentinel)解决的是"主库挂了自动选主",判断下线分两级:
主观下线(SDOWN):单个哨兵在
down-after-milliseconds(默认 30 秒)内收不到主库有效回复,自己先认为它挂了;客观下线(ODOWN):达到
quorum数量的哨兵都认为它挂了,才升级为客观下线。
之后哨兵选出一个 leader 执行故障转移,把某个从库提升为新主库。客户端通过哨兵拿到当前主库地址,客户端必须支持 Sentinel 感知,否则拿到的还是老地址。
Cluster解决的是"单机内存不够 + 单机扛不住"。它把 key 按CRC16(key) mod 16384分到16384 个哈希槽,每个主节点负责一部分槽:
127.0.0.1:7001> CLUSTER KEYSLOT "drink:menu:10086" (integer) 9600 127.0.0.1:7001> CLUSTER INFO cluster_state:ok cluster_slots_assigned:16384 cluster_known_nodes:6
客户端访问的 key 不在本节点时,会收到一个重定向:
MOVED 9600 127.0.0.1:7002:这个槽永久归 7002 管了,客户端要改自己的槽位映射表;ASK 9600 127.0.0.1:7002:这个槽正在迁移过程中,客户端只能临时去 7002 取这个 key,取完仍按老映射走,下一次可能又收到 MOVED。
槽迁移是 Cluster 扩缩容的基础:不重灌数据,而是把槽从旧主节点一个一个搬给新节点(CLUSTER SETSLOT ... IMPORTING/MIGRATING),搬完再让客户端感知。
| 方案 | 解决什么 | 写性能 | 读扩展 | 故障自动切换 |
|---|---|---|---|---|
| 单节点 | — | 高 | 无 | 无 |
| 主从 | 数据备份、读扩展 | 一样(主库单点) | 有(可能读到旧值) | 无 |
| 哨兵 + 主从 | 主库挂了自动选主 | 一样 | 有 | 有 |
| Cluster | 数据分片、水平扩容 | 分散到多节点 | 有 | 有 |
怎么改:数据量单机装得下、且能接受"主库挂了要人工介入",主从就够了;要自动容灾就上哨兵;想扛住峰值流量,Cluster 才有意义。别为了"看起来高级"上 Cluster——它带来 MOVED 重定向、槽迁移、跨节点多键操作不支持等复杂度。一句话记:主从管备份,哨兵管选主,Cluster 管分片。
十、排查清单速查表
出问题时按这个顺序查,别瞎猜:
| 现象 | 先查什么 | 命令 |
|---|---|---|
| 内存莫名涨满 | 已用内存、碎片率、淘汰策略 | INFO memory、CONFIG GET maxmemory-policy |
| 某个命令特别慢 | 慢日志 | SLOWLOG GET 10、CONFIG GET slowlog-log-slower-than |
| 某条命令卡住 | 大 key | redis-cli --bigkeys(只读,别在峰值跑) |
| 写入报 OOM | 上限和淘汰策略是否匹配 | CONFIG GET maxmemory maxmemory-policy |
| 改了价但用户看到旧价 | 缓存是否没删、TTL 是否太长 | TTL key、GET key |
| 主从数据不一致 | 复制偏移和延迟 | INFO replication看slave0的lag |
| 客户端报 MOVED | 槽位归属 | CLUSTER KEYSLOT key、CLUSTER NODES |
| 客户端连不上 | 绑定地址、保护模式、密码 | CONFIG GET bind requirepass |
| 重启后数据丢了 | 持久化开关和刷盘策略 | CONFIG GET appendonly appendfsync save |
| 某个 key 行为诡异 | 它的底层编码和类型 | OBJECT ENCODING key、TYPE key |
| 想删掉某个 key | 别用 KEYS,用 SCAN | SCAN 0 MATCH "drink:*" COUNT 100 |
写在最后
Redis 是内存数据结构服务,不是"更快的 MySQL"。先分清哪些数据能放、哪些不能放,比学多少命令都重要。
Cache Aside:读是"缓存 → miss → DB → 回填",写是"更新 DB → 删缓存"。写一定删缓存、不更新缓存;删除失败要重试。
底层编码决定内存:小 Hash / 小 ZSet 用 listpack,大了自动升级 hashtable / skiplist,
OBJECT ENCODING一看便知。单线程指的是执行命令,所以单条命令原子;网络层靠 epoll 多路复用,6.0 起还有 IO 线程可选。慢命令会拖垮所有人,排查先看
SLOWLOG GET。ZSet = 字典 + 跳表,查找 O(log N),范围查询 O(log N + M);rehash 是惰性 + 渐进式,靠
rehashidx摊平搬迁开销。持久化:RDB 是快照,恢复快但可能丢数据;AOF 记命令,
everysec折中,配套BGREWRITEAOF重写。三组区别点比背命令有用:
allkeys-*只动全部 key 而volatile-*只动带 TTL 的;穿透查不存在的 key、击穿打单个热点 key、雪崩倒一片;主从管备份、哨兵管选主、Cluster 管分片。
如果上面哪一条你也卡过,评论区说一声「缓存穿透」,我把这套实验代码整理成完整项目发出来——你可以自己改参数重跑一遍,比看文章直观得多。