从实验到生产:Redis支撑程序的数据结构选型与工程实践
Redis 这个词,很多人第一反应是"缓存",第二反应是"面试八股"。我第一次在实验环境里敲SET、GET的时候也觉得这东西简单得不像话——不就是个远程字典吗?直到后来真拿它去给一个后台服务做支撑,才发现问题根本不在命令会不会写,而在于你把它放在了架构的哪个位置、给它配了什么参数、以及出问题的时候你有没有能力把原因定位出来。这篇内容想聊的就是这条线:从在实验平台上把 Redis 跑通,到用它真正构建出一个能扛事的支撑程序。不管你是刚接触 Redis、正在做课程实验,还是已经写过几行业务代码但心里没底,下面的内容应该都能拿到点东西——数据结构的选型逻辑、四类常见支撑程序的落地写法、实验环境里最容易卡住的几个坑,以及让服务不半夜出事的工程细节。
1. 先想清楚Redis在支撑程序里到底站哪个位置
1.1 支撑程序为什么偏爱内存数据库
先说个容易被忽略的前提:Redis 快,不只是因为数据在内存里。真正的差异在于它的访问模型。传统数据库一次查询要经过 SQL 解析、执行计划生成、磁盘页读取、事务日志落盘这一长串流程,即使有缓存池,一次访问的耗时也在零点几毫秒到几毫秒量级。而 Redis 单次命令的处理是在单线程事件循环里完成的,命令进队列、查字典、写回、返回,中间没有磁盘 IO、没有锁竞争、没有上下文切换,官方压测数据是单实例十万级 QPS 起步。
这个量级差意味着什么呢?我打个比方:数据库像一个有前台登记、有档案室、有审批流程的办事大厅,你去查个东西要走完整套流程;Redis 像一张摊在桌上的通讯录,翻到那一页就是答案。所以支撑程序里凡是"高频读、低价值密度、可以容忍丢失"的数据,都应该往 Redis 上放——会话信息、验证码、热点商品详情、页面片段、计数器、排行榜、临时锁。反过来,凡是"不能丢、要能追溯、要能复杂查询"的,老老实实留在磁盘数据库里。
这里有个新手常犯的方向性错误:把 Redis 当成"更快的数据库"来用,把主数据全塞进去,然后指望它承担唯一数据源。这在实验里没问题,因为实验数据量小、也没人拔电源;但在真实场景里,内存是昂贵的,Redis 的持久化是"尽力而为"而不是"绝对可靠",RDB 默认每 3600 秒才落一次盘,中间宕机就是一段数据凭空消失。所以定位一定要摆正:Redis 是支撑层,不是主存储层。
1.2 实验环境里第一件事:确认你连的是哪个实例
在实验平台上做 Redis 相关任务,十有八九第一步不是写代码,而是确认环境。我见过太多人代码逻辑写得完全正确,结果卡在连接上,一卡就是半小时。原因通常很朴素:客户端连的是127.0.0.1:6379,但实验容器里的 Redis 可能监听在别的端口,或者根本没起来,或者起了但开了保护模式拒绝外部连接。
标准的自查顺序是这样的:先用redis-cli -h <host> -p <port> ping确认服务活着,返回PONG说明进程正常、网络通、认证没问题(如果配了密码,这一步会返回NOAUTH Authentication required,那就是密码的事)。如果这一步就报Connection refused,说明进程没启动或者端口不对;如果报Connection timed out,那多半是网络或绑定地址的问题。这两个报错含义完全不同,排查方向也不一样,别混着猜。
确认通了之后,立刻看一眼这个实例的配置,尤其是三个参数:maxmemory(内存上限,设太小会直接写不进去)、maxmemory-policy(淘汰策略,默认noeviction意味着内存满了就报错)、databases(库的数量,默认 16 个)。这几个值在实验环境里经常被平台预先设成很小的数字,你代码里要是习惯了往 db 0 里猛塞数据,很容易撞上OOM command not allowed when used memory > 'maxmemory'。这个报错我第一次见的时候还以为是代码 bug,查了半天才发现是内存上限只有几十兆。
提示:进实验环境先跑一遍
INFO server和CONFIG GET maxmemory,把版本、内存、淘汰策略三个值记下来。这三条信息后面几乎所有异常排查都要用到,先拿到手能省很多来回。
1.3 五个数据结构对应五类支撑能力
Redis 的入门门槛低,但真正的分水岭在"选对结构"。同样一个需求,用错结构性能可能差一个数量级,代码复杂度也天差地别。我按实际用途梳理一下:
| 结构 | 底层实现 | 典型支撑场景 | 常见误用 |
|---|---|---|---|
| String | SDS 动态字符串 | 缓存 JSON、计数器、分布式锁 | 用它存大对象,导致大 key |
| Hash | 压缩列表 / 哈希表 | 对象字段局部更新、购物车 | 字段数忽多忽少,触发编码转换 |
| List | 快速列表 | 简单队列、最新 N 条 | 用它做随机访问,O(n) 性能 |
| Set | 整数集合 / 哈希表 | 去重、共同好友、抽奖 | 元素数量失控,内存暴涨 |
| ZSet | 跳表 + 哈希表 | 排行榜、延时队列、滑动窗口限流 | 排行榜同分排序踩坑 |
这张表建议直接记在脑子里。后面所有的落地写法,本质都是在这五种结构上做组合。比如"统计七天活跃用户"是 Set 的并集,"最近一小时访问次数"是 ZSet 的范围计数,"每日签到"是 Bitmap 的位操作。结构选对了,代码往往只有几行;结构选错了,你可能要写几十行还不一定对。
2. 数据结构选型:同一个需求三种写法,性能能差一个数量级
2.1 String不只是"存个字符串"
大多数人把 String 当成"键值对存储"来用,其实它的能力边界比想象中宽。除了最基础的SET/GET,它还有三个非常实用的变体:SETNX(不存在才设置,是分布式锁的基础)、SET key value EX seconds NX(原子化的设置加过期,一条命令顶三条)、INCR/DECR(原子自增,计数器的核心)。
原子性这个词值得展开说一句。很多人写计数器是这么写的:先GET拿到值,代码里加一,再SET回去。单机单线程测试时完全没问题,但一旦有并发,两个请求同时读到 100,各自加一写回 101,一次自增就凭空丢了。INCR之所以可靠,是因为"读、加、写"三个动作在 Redis 内部是一个不可分割的操作,中间不会插入别的命令。这就是为什么计数场景必须用INCR而不是"读改写"——不是性能问题,是正确性问题。
SET key value EX 60 NX这条组合命令也值得单独记一下。它等价于"设置值、设过期、仅当不存在时生效"三件事原子完成。写分布式锁、写"只初始化一次"的配置、写防重复提交的令牌,全靠它。早期很多人用SETNX再单独EXPIRE,这两条命令之间如果进程挂了,就会留下一个永不过期的死锁键,线上排查起来极其难受。
2.2 Hash与JSON字符串的取舍
存一个用户对象,有两种主流写法:一种是SET user:1001 '{"name":"张三","age":28}',把整个对象序列化成 JSON 塞进 String;另一种是HSET user:1001 name 张三 age 28,用 Hash 分字段存。
怎么选?关键看更新模式。如果这个对象每次都是整体读、整体写,那 JSON 字符串更省事,序列化反序列化一次搞定,网络传输也只有一个 RTT。但如果业务里有大量"只改某一个字段"的操作——比如只更新最后登录时间、只改积分——那 Hash 明显更合适,HSET user:1001 last_login 1720000000一条命令搞定,不需要读出整个对象、改一个字段、再整体写回。后者的风险在于并发覆盖:两个请求同时读、各自改不同字段、再写回,后写的会把先写的字段冲掉。
Hash 有个坑要注意:Redis 对小的 Hash 会用压缩列表(ziplist/listpack)存储,非常省内存;一旦字段数或单个字段长度超过阈值(hash-max-listpack-entries默认 128,hash-max-listpack-value默认 64 字节),就会转成真正的哈希表,内存占用会跳一大截。所以如果你的对象字段特别多,或者某个字段存的是长文本,这个结构可能并不划算,老老实实序列化成 String 反而更省。
我一般的经验判断是:字段数量稳定在十几个以内、且需要频繁局部更新,用 Hash;字段多、或者每次都是整体读写,用 String 存 JSON。别为了"看起来更规范"而选 Hash,内存是真的会多花。
2.3 ZSet做排行榜的分页陷阱
排行榜是 ZSet 最经典的场景,ZINCRBY加分、ZREVRANGE取前 N、ZREVRANK查排名。写起来很顺手,但有两个地方特别容易翻车。
第一个是同分排序。ZSet 的排序规则是"先按 score 排,score 相同按 member 的字典序排"。这在排行榜里往往不是你想要的效果——两个用户都是 1000 分,谁排前面取决于用户 ID 的字符串大小,看起来像是随机的。业界的通用解法是把时间戳编码进 score:用score = 分数 * 10^10 + (某个递减的时间戳),这样同分时先到的人排前面。代价是 score 失去了可读性,取出来之后要自己做一次解码。
第二个是深分页。ZREVRANGE key 100000 100010这种操作,Redis 内部要跳过前十万个元素,虽然是 O(log N + M) 的复杂度,但跳表遍历本身在高并发下依然不便宜。真要做深分页,通常的做法是限制用户只能看前 N 页,或者用"游标"方式基于上一页最后一个元素的 score 继续往下取(ZREVRANGEBYSCORE key (lastScore -inf LIMIT 0 10)。
# 加分 ZINCRBY rank:daily 10 user:1001 # 取前 10 名(含分数) ZREVRANGE rank:daily 0 9 WITHSCORES # 查某人排名(0 开始,所以展示时要 +1) ZREVRANK rank:daily user:1001 # 基于游标翻页:取分数严格小于 950 的接下来 10 个 ZREVRANGEBYSCORE rank:daily (950 -inf WITHSCORES LIMIT 0 10还有一点:排行榜要不要设置过期?日榜就该在次日凌晨清掉或换 key(用rank:daily:20240701这种带日期的 key 名,靠 TTL 自动回收更省心),别在一个固定 key 上反复DEL再重建,那样在清理的瞬间读请求会全部打到空榜上。
2.4 Set与Bitmap在去重和签到上的用法
Set 的价值在于集合运算。SADD加元素、SISMEMBER判存在、SINTER求交集。最典型的支撑场景是"共同关注"——SINTER user:1001:follows user:1002:follows一条命令就能算出两个人的共同关注,换成关系型数据库要写一堆 JOIN。
去重场景也常用 Set,比如"同一篇文章同一用户只计一次阅读"。但要注意 Set 的内存成本:它存储的是完整元素,一个元素哪怕只有十几个字节,加上哈希表的开销,实际占用往往要乘以 2 到 3。十万级元素还行,百万级就该考虑 HyperLogLog 了——用 12KB 固定空间估算基数,标准误差 0.81%。代价是它只能算"有多少个不重复的",不能判断"某个元素在不在里面",也不能取出元素。所以 UV 统计用它,去重判断还是得 Set。
签到、活跃标记这类"是/否"二值场景,Bitmap 是最省的选择。原理很简单:把每一天映射成一位,用户 1001 号在 7 月 1 日签到,就是往sign:1001这个 key 的第 0 位写 1。
# 7 月 1 日签到(偏移量按日期算) SETBIT sign:1001 0 1 # 7 月 5 日签到 SETBIT sign:1001 4 1 # 统计本月签到次数 BITCOUNT sign:1001 # 判断某天是否签到 GETBIT sign:1001 4一个用户一年的签到数据只占 365 位,也就是 46 字节。同样的事情用 Set 存,每个日期都是一个完整字符串元素,一年下来轻松几百字节甚至更多。这种"数据规模大、但每个值只有一个 bit 信息量"的场景,Bitmap 是降维打击。不过要提醒一句:Bitmap 的偏移量是稀疏的,如果用户 ID 直接当偏移量(比如 ID 是 100 万),那SETBIT会在瞬间分配 125KB 的空间,这种用法是错误的——偏移量必须是连续的小整数,通常是"日期序号"或者"自增 ID 压缩后的值"。
3. 四类支撑程序的落地写法
3.1 缓存旁路模式与三个经典失效场景
缓存最主流的用法是 Cache-Aside,也就是旁路缓存:读的时候先查缓存,没有再查数据库,查到之后回填缓存;写的时候先更新数据库,再删除缓存。逻辑就这么简单,但里面藏着三个绕不开的问题。
第一个是缓存穿透:查一个根本不存在的数据,缓存里没有,每次请求都打到数据库。恶意攻击或者代码 bug 都可能触发。两种解法:一是把"查不到"这个结果也缓存起来(值设为空字符串,TTL 设短一点比如 60 秒),二是用布隆过滤器在入口拦一道——它用很小的空间判断"这个 key 一定不存在"或"可能存在",能挡掉绝大多数无效查询。
第二个是缓存击穿:某个热点 key 刚好过期,一瞬间大量请求同时涌向数据库。解法是"互斥重建"——只让一个请求去查库重建缓存,其他请求短暂等待或者直接返回旧值。用 Redis 实现就是SET lock:rebuild:xxx 1 EX 10 NX,抢到锁的那个去重建,抢不到的睡个几十毫秒重试。
第三个是缓存雪崩:大批 key 在同一时刻集体过期。解法很朴素——给 TTL 加随机抖动。比如原本设置 3600 秒,改成3600 + random(0, 600),让过期时间打散到十分钟的区间里,压力自然就分散了。这个改动只加了一行代码,效果却非常明显。
回填缓存时还有个细节:先删缓存还是先更新数据库?标准答案是"先更新库、后删缓存",而且删缓存失败要重试(可以把删除操作丢进消息队列做补偿)。为什么是删除而不是更新?因为并发写的时候,两个线程的更新顺序和落缓存顺序可能不一致,容易写出脏数据;删除则是幂等的,下次读自然回填最新值。
3.2 计数器与滑动窗口限流
计数器看着简单,坑在上面提过:必须用INCR保证原子性。但加上过期时间之后,事情就没那么单纯了。
import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True, socket_timeout=2, socket_connect_timeout=2) def incr_with_ttl(key: str, ttl: int = 60) -> int: """自增并保证首次设置过期时间,用 Lua 保证原子""" script = """ local v = redis.call('INCR', KEYS[1]) if v == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return v """ return r.eval(script, 1, key, ttl)为什么这里要用 Lua?因为INCR和EXPIRE是两条命令,如果进程在中间崩了,就会留下一个永不过期的计数器,越积越大。用 Lua 脚本包起来,Redis 会把它当成一个整体执行,中间不插入任何其他命令。这是 Redis 里保证多命令原子性的标准手段。
固定窗口计数器有个缺陷:窗口边界处的流量可能是上限的两倍。比如限制每分钟 100 次,用户在 12:00:59 打 100 次,12:01:00 再打 100 次,两个窗口加起来 200 次,但任意连续 60 秒内的实际峰值超标了。要更精确就得用滑动窗口,用 ZSet 存每次请求的时间戳,每次进来先ZREMRANGEBYSCORE清掉窗口外的,再ZCARD数一下数量,没超就ZADD进去。代价是内存占用随请求量线性增长,所以一般只在敏感接口上用。
-- 滑动窗口限流:KEYS[1] 为窗口 key,ARGV[1] 为窗口毫秒数,ARGV[2] 为上限 local now = redis.call('TIME') local ts = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000) local window = tonumber(ARGV[1]) redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ts - window) local count = redis.call('ZCARD', KEYS[1]) if count < tonumber(ARGV[2]) then redis.call('ZADD', KEYS[1], ts, ts .. '-' .. math.random(100000)) redis.call('PEXPIRE', KEYS[1], window) return 1 end return 0注意ZADD的 member 我用时间戳拼了个随机数,因为 ZSet 的 member 必须唯一,同一毫秒内的多次请求如果 member 相同会被覆盖,导致计数偏少。
3.3 分布式锁的正确姿势:SET NX EX + 唯一值校验
分布式锁是 Redis 用得最多、也最容易写错的场景。错误写法五花八门,正确的其实只有一套流程:
import uuid def acquire_lock(conn, lock_key: str, ttl_ms: int = 30000) -> str | None: token = str(uuid.uuid4()) ok = conn.set(lock_key, token, nx=True, px=ttl_ms) return token if ok else None def release_lock(conn, lock_key: str, token: str) -> bool: script = """ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end """ return conn.eval(script, 1, lock_key, token) == 1三个要点,缺一不可。
第一,加锁必须是SET key value NX PX ttl一条命令。分两步写(先SETNX再EXPIRE)会有窗口期,进程挂了就死锁。而且过期时间必须给,不给就是给自己埋雷。
第二,value 必须是唯一标识。很多人 value 随便写个1。问题出在释放锁的时候:如果线程 A 的业务执行超时了,锁自动过期,线程 B 拿到了锁;这时候 A 执行完去删锁,删掉的其实是 B 的锁。用唯一 token 加上"比对再删"的 Lua 脚本,就能避免误删。
第三,锁的 TTL 要能覆盖业务耗时,或者做续期。TTL 设短了业务没跑完锁就没了,等于没锁;设长了万一持有者挂了,别人要干等。工程上的做法是另起一个"看门狗"线程,每隔 TTL/3 去检查一次锁还在不在自己手里,在就延长。不过这会让实现复杂度上一个大台阶,简单业务场景下把 TTL 设成业务最大耗时的 3 倍左右,通常够用了。
还有个场景值得提:如果只是防重复提交、防重复初始化,不需要严格的互斥语义,那就别上分布式锁,用SET NX EX做个"一次性令牌"就行,简单得多。锁是重资产,别滥用。
3.4 List和Stream做轻量队列的边界
用 List 做队列是最省事的:生产端LPUSH,消费端BRPOP,BRPOP是阻塞版本,队列空的时候会挂起等待,不占 CPU。
# 生产 LPUSH queue:tasks '{"id":1,"type":"resize"}' # 消费(阻塞 5 秒,取不到返回 nil) BRPOP queue:tasks 5但 List 有个致命短板:消息出队即消失。消费者BRPOP拿到消息之后如果进程崩了,这条消息就永久丢了,没有任何重试机制。要解决这个,得用BRPOPLPUSH(或者新版本的BLMOVE):把消息从待处理队列原子地转移到"处理中队列",业务处理成功后再从处理中队列删掉。这样万一消费者挂了,消息还在处理中队列里,可以起个巡检任务把超时的消息挪回主队列。
# BLMOVE:从 source 弹出放到 dest,处理完再 LREM 删除 BLMOVE queue:tasks queue:processing LEFT RIGHT 5但如果你的需求已经涉及"消费组""消息确认""多消费者负载均衡",那 List 就不够用了,该上 Stream。Stream 是 Redis 5.0 引入的,设计上就是照着轻量消息队列做的:XADD生产、XREADGROUP按消费组读、XACK确认、XPENDING查未确认。它的可靠性比 List 高一个档次,但复杂度也上来了,运维成本不小。
我的判断标准很粗暴:任务丢了会不会有严重后果?丢一两条日志、丢一两次统计,List 足够;丢订单、丢支付回调,那就别在 Redis 上硬扛了,老老实实上专业的消息中间件。Redis 的队列适合"轻、快、可以接受少量丢失"的场景,超过这个范围就是在给自己找事。
4. 实验环境里最容易卡住的几处,按链路排查
4.1 连接被拒和连接超时,是两回事
看到报错别急着改代码,先把报错类型分清楚。"拒绝连接"和"连接超时"指向的是完全不同的两个环节。
Connection refused意味着 TCP 握手被明确拒绝,通常有三种可能:Redis 进程没起来、端口不对、或者监听的地址和你连的地址不匹配。排查路径:先ps -ef | grep redis看进程在不在,不在就手动起一个;在的话看启动日志里的监听端口,和客户端配置比对。还有一个隐蔽情况——Redis 默认的bind 127.0.0.1只允许本机连接,容器里跨机器访问就需要改成bind 0.0.0.0或者加上具体网卡地址。
Connection timed out则是握手包压根没得到响应,多半是网络不通或者被拦截。这时候ping <host>先看通不通,telnet <host> <port>看端口开没开,再往下就是看安全组、防火墙这些了。
还有一种特殊报错:连接成功了,但一执行命令就返回NOAUTH Authentication required。这就是配了密码没认证,AUTH yourpassword一下即可,或者客户端配置里补上password参数。另一种是DENIED Redis is running in protected mode,这是保护模式,只允许本机无密码访问,改配置或者加密码都能解决。
下面这张表我建议背下来,能省大量试错时间:
| 报错信息 | 根因 | 处理动作 |
|---|---|---|
| Connection refused | 进程未启动 / 端口错 / bind 限制 | 查进程、对端口、改 bind |
| Connection timed out | 网络不通 / 端口未放行 | ping、telnet、查防火墙 |
| NOAUTH Authentication required | 开启了密码认证 | 客户端补 password 或 AUTH |
| DENIED protected mode | 保护模式限制外来访问 | 设密码或调整配置 |
| OOM command not allowed | 达到 maxmemory 且无淘汰策略 | 调大内存或改淘汰策略 |
4.2 数据"写进去了又没了"
这个现象在实验环境里出现频率相当高,而且新手容易怀疑是 Redis 本身有问题。实际上绝大多数情况是下面四种之一。
第一种,key 被别的地方删了或者覆盖了。实验里经常多人共用同一个实例,不同题目用同样的 key 名,互相踩踏。解决办法是给 key 加个自己的前缀,比如exp1:user:1001。
第二种,过期时间被意外设置。有时候是从上一题复制过来的代码,里面带着EXPIRE没删干净,数据写进去几十秒就没了。可以用TTL key确认一下剩余时间,返回 -1 表示永不过期,-2 表示 key 已经不存在了。
第三种,写到了不同的库。Redis 默认 16 个库,SELECT 1和SELECT 0是两个完全隔离的空间。客户端连的是 db 0,命令行里敲过SELECT 2,然后就发现"数据不见了"。这个坑我自己踩过,排查了十分钟才反应过来。
第四种,实例重启了但没开持久化。数据全在内存里,进程一重启就清零。实验环境里容器被回收重建是很常见的,这时候数据丢失是必然的,只能靠落盘解决——但这通常不是题目考察的点,遇到这种情况应该确认是不是环境本身被重置了。
4.3 评测脚本对 key 命名和类型的隐性要求
在实验平台上做题,最冤枉的失分是"逻辑全对但评测不过"。多数情况下问题出在格式契约上,而不是功能上。
评测脚本通常是这样工作的:跑一遍你的程序,然后用TYPE key检查类型是不是 String 而不是 Hash,用GET key取值比对内容,用TTL key检查过期时间是不是在预期区间,用LLEN检查队列长度。这意味着几个细节必须严格对齐:key 名一个字符都不能差(大小写敏感)、数据类型必须完全一致(用HSET存的东西GET出来是空的)、值的格式要能对上(数字别存成字符串带引号的形式、JSON 字段顺序可能也要注意)。
我的建议是:写完先自己在命令行里把评测脚本关心的 key 挨个TYPE一遍、TTL一遍、取值一遍,比盲目提交省时间。另外记得清理测试数据——如果上一轮实验留下的数据还在,LLEN出来的长度会翻倍,评测自然不通过。实验开始前跑一次FLUSHDB是个好习惯(当然只在你自己的库里)。
5. 让支撑程序稳住的几个工程细节
5.1 连接池和超时参数,别用默认值
初学者最常见的写法是每个请求new一个 Redis 连接,用完关掉。这个模式在压测下会直接把 Redis 打满——每次连接都要走 TCP 三次握手、认证、分配资源,开销比执行命令本身大得多。标准做法是连接池,进程启动时建好池子,每个请求借一个连接、用完归还。
import redis pool = redis.ConnectionPool( host="127.0.0.1", port=6379, db=0, max_connections=50, # 按并发量估,别贪大 socket_timeout=2, # 单次读写超时,防止卡死 socket_connect_timeout=1, # 建连超时,快速失败 health_check_interval=30, # 定期探活,避免拿到已断的连接 retry_on_timeout=False, # 超时是否重试,写操作慎开 decode_responses=True, ) r = redis.Redis(connection_pool=pool)几个参数值得单独说。max_connections不是越大越好,它应该和应用的并发线程数匹配,设成 500 但实际只有 20 个线程就是浪费,而设得比并发数小就会出现"借不到连接"的排队等待。socket_timeout一定要设,Redis 是单线程的,万一有慢命令阻塞了事件循环,没有超时的客户端会一直挂在那里。retry_on_timeout建议关掉,因为对非幂等的写操作自动重试可能导致重复执行,宁可让上层报错处理。
还有一点:在容器环境里,health_check_interval特别重要。网络抖动或者中间设备静默断开长连接时,客户端手里的连接看起来还活着,实际上发出去的命令全都没响应,表现出来的现象就是"偶尔卡几秒然后超时"。定期探活能把这个窗口压到最小。
5.2 大key和热key,是性能问题的两大源头
大 key 指的是单个 key 的 value 过大——String 超过 10KB,或者集合类元素数量超过几千个。它的危害是多方面的:读写时占用带宽、删除时阻塞主线程(DEL一个百万元素的 Set 可能要几百毫秒,这期间所有命令都在排队)、持久化时拖慢 fork。检测手段是用redis-cli --bigkeys,它会扫描全库并按类型列出最大的 key。
删除大 key 不要用DEL,用UNLINK——它把 key 从键空间摘掉,真正的内存回收交给后台线程异步做,不会阻塞主线程。对于超大集合,还可以用SCAN配合分批SREM,一点点拆解掉。
热 key 则是另一个方向:某个 key 被极高频率访问,把单实例的 CPU 打满。典型的是大促时的爆款商品详情。应对手段有三种:本地缓存兜一层(牺牲一点一致性换性能)、把 key 复制成多个副本随机选一个读(比如hot:item:1001:0到hot:item:1001:9)、或者做读写分离。选哪种取决于你的数据更新频率——几乎不变的数据用本地缓存最划算。
5.3 持久化和淘汰策略,两个必须做决定的配置
持久化有 RDB 和 AOF 两种,也可以混合使用。RDB 是定时快照,文件小、恢复快,但两次快照之间的数据会丢;AOF 是追加写命令日志,默认everysec每秒刷一次盘,最多丢一秒数据,但文件大、恢复慢。生产环境一般开混合持久化(aof-use-rdb-preamble yes),兼顾两者。
要提醒的是 RDB 的fork操作。快照时会 fork 一个子进程,在 fork 的瞬间主进程会短暂阻塞,数据量越大阻塞越久。十几个 G 的实例 fork 一次可能卡几百毫秒,这个时间对高并发服务来说是能感知的。所以要么控制单实例的数据量,要么把快照时间安排在低峰期。
淘汰策略的选择直接关系到"内存满了会怎样"。可选值有八种,实际常用的就三个:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| noeviction | 内存满时写入报错 | 数据绝对不能丢,宁可拒绝服务 |
| allkeys-lru | 从所有 key 中淘汰最近最少用的 | 纯缓存场景,最常用 |
| volatile-lru | 只淘汰设置了过期时间的 key | 缓存和持久数据混存在一个实例 |
| allkeys-lfu | 按访问频率淘汰 | 有明显热点,LRU 容易误伤 |
纯缓存用途选allkeys-lru基本不会错。如果实例里既有缓存数据又有不能丢的数据,那要么分开用两个实例,要么用volatile-lru并确保所有缓存 key 都设了过期时间——最怕的是有人忘了设 TTL,缓存数据变成了"永久数据",把内存一点点吃干。
5.4 慢查询和日常监控,问题要在发生前发现
Redis 自带慢查询日志,slowlog-log-slower-than单位是微秒,默认 10000 也就是 10 毫秒。建议把它调到 5000 甚至更低,因为 Redis 的单命令正常执行都在微秒级,超过 5 毫秒就已经很可疑了。用SLOWLOG GET 10能看最近十条慢命令,一眼就能看出是哪个 key 出了问题。
日常监控我一般盯四个指标:used_memory看内存增长趋势、connected_clients看连接数是否异常、instantaneous_ops_per_sec看 QPS 突增突降、keyspace_hits / keyspace_misses算缓存命中率。命中率是个特别敏感的指标,正常缓存场景应该在 90% 以上,一旦掉到 70% 以下,说明缓存要么在大量过期,要么 key 设计有问题,需要立刻查。
还有个容易忽略的点:不要在生产上用KEYS *。它是 O(n) 的全量扫描,几百万个 key 会直接把主线程卡住几秒钟,这期间所有请求全部堆积。要遍历就用SCAN,它用游标分批返回,每次只扫一小部分,对主线程的影响可控。SCAN的代价是可能返回重复元素(因为扩容缩容期间槽位会变),所以业务上要能容忍重复。
真正跑过一段时间之后会发现,Redis 的问题几乎从来不是"命令写错了",而是"结构选错了""参数没配""容量没规划"。入门阶段把这些底层逻辑捋清楚,后面遇到再复杂的需求,也不过是在这几种结构上做组合。
我个人在实际操作中的体会是:每次上线新的 Redis 用法之前,先在测试环境用接近真实的数据量压一遍,重点看内存增长曲线和慢查询日志。很多时候小数据量下跑得飞起的写法,数据量上到十万级就开始暴露问题——比如一个HGETALL拿回来几千个字段、一个SMEMBERS返回几万个元素,这些在实验里完全无感,到线上就是事故。另外,凡是写进缓存的 key,一律强制要求设置过期时间,这条规矩看起来死板,但它能挡掉绝大多数"内存莫名其妙涨满"的麻烦。