ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从一家奶茶店讲透 Redis:缓存、持久化与高可用的一次完整拆解

从一家奶茶店讲透 Redis:缓存、持久化与高可用的一次完整拆解

下午四点,店里挤了二十多号人,收银台的小票机"嘀嘀"响成一片。你低头刷 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 的分工完全不同,这一点决定了后面所有的坑:

维度RedisMySQL
数据位置内存(可持久化到磁盘)磁盘
主要用途缓存、计数器、排行榜、分布式锁、轻量消息队列持久化的业务真相
访问方式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 省内存的核心手段。

类型常用底层编码典型命令奶茶店场景
Stringint / embstr / rawSETGETINCR库存计数、验证码、整单 JSON
Hashlistpack(7.x)/ hashtableHSETHGET商品详情字段
Listquicklist(内部 listpack)LPUSHLPOP排队叫号队列
Setintset / listpack / hashtableSADDSISMEMBER门店黑名单、配料集合
Sorted Setlistpack(7.x)/ skiplistZADDZRANGEBYSCORE销量榜、积分榜

先说两个容易忽略的上限: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 官网写着"单线程处理请求",很多人立刻怀疑:那不得被一个慢查询拖死?

实锤:这里要区分两件事:

  1. 执行命令是单线程的——所以单条命令天然原子,不需要锁。INCR不会被两个请求算成同一个数,LPUSH也不会串行错乱。

  2. 网络层是多路复用(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 是内存服务,内存里的东西默认不持久化。它有两种持久化方式,思路完全不同:

维度RDBAOF
记什么某一时刻的数据快照(二进制)每一个写命令(追加写)
触发自动按规则 /BGSAVE/ 手动SAVE自动每秒 /always/no
恢复速度快,直接加载二进制慢,要重放命令
数据丢失可能丢最后一次快照后的数据everysec最多丢 1 秒
文件体积小大(但可重写压缩)
典型用途全量备份、快速启动尽量不丢数据

RDB 的触发规则是三档"时间 + 变更次数"的组合,任一条满足就存一次快照:

配置项含义
save 900 1900 秒内至少 1 次修改
save 300 10300 秒内至少 10 次修改
save 60 1000060 秒内至少 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
某条命令卡住大 keyredis-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,用 SCANSCAN 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 管分片。

如果上面哪一条你也卡过,评论区说一声「缓存穿透」,我把这套实验代码整理成完整项目发出来——你可以自己改参数重跑一遍,比看文章直观得多。

返回列表