跳转至

Redis

Redis(Remote Dictionary Server)是一个基于内存的高性能键值存储,支持丰富的数据结构,常用作缓存、消息队列和分布式锁。其单线程模型(命令执行层面)配合 I/O 多路复用,在避免锁竞争的同时达到极高的吞吐量。

数据类型与典型场景

类型 底层实现 典型场景
String SDS(Simple Dynamic String) 缓存、计数器(INCR)、分布式锁
Hash listpack / hashtable 对象属性存储(如用户信息)
List quicklist(listpack + 链表) 消息队列、最新动态流
Set intset / listpack / hashtable 标签系统、共同好友(SINTER)
Sorted Set (ZSet) listpack / skiplist + hashtable 排行榜、延迟队列

扩展类型:HyperLogLog(基数统计)、Bitmap(签到/布隆过滤器)、Stream(消息流,5.0+)、GEO(地理位置,底层为 ZSet)。

Redis 7.0 起 ziplist 被 listpack 取代,Set 从 7.2 起使用 listpack。

线程模型与底层结构

线程模型

Redis 快的原因:数据在内存中、数据结构高效、命令单线程执行没有锁竞争、通过 I/O 多路复用(如 epoll)用一个线程处理大量连接。

“单线程”只是指命令执行:

  • 后台线程(4.0+):负责异步删除(UNLINK、FLUSHALL ASYNC)、关闭文件等耗时操作。
  • 多线程 I/O(6.0+):通过 io-threads 开启,默认关闭。I/O 线程只负责网络读写和协议解析,命令仍由主线程执行。

命令串行执行也意味着一条慢命令会阻塞所有请求,见阻塞命令。

底层结构

  • SDS:记录字符串长度,O(1) 获取长度,二进制安全,并通过预分配减少内存重分配。
  • listpack:连续内存的紧凑结构,元素少且小时使用,解决了 ziplist 的连锁更新问题。
  • 跳表:ZSet 同时使用跳表和哈希表,跳表负责按 score 排序和范围查询,哈希表负责 O(1) 查询成员的 score。相比平衡树,跳表实现简单,范围遍历方便,插入删除时只需调整局部指针。
  • 渐进式 rehash:哈希表扩容时不一次性迁移,而是同时保留新旧两张表,在每次操作和定时任务中迁移一批数据,避免长时间阻塞主线程。迁移期间新增数据写入新表,查询时两张表都要查。

内存淘汰

常见淘汰算法

  • LRU(Least Recently Used):淘汰最近最少使用的 key——优先移除最久未被访问的数据。
  • LFU(Least Frequently Used):淘汰使用频率最低的 key——根据访问次数而非时间判断。
  • FIFO(First In First Out):先进先出——按写入顺序淘汰最早的 key。
  • TTL:在设有过期时间的 key 中,淘汰剩余存活时间最短的 key。
  • Random:随机淘汰。

Redis 淘汰策略(maxmemory-policy)

当内存使用达到 maxmemory 上限时,Redis 根据配置的策略决定如何处理新写入:

策略 说明
noeviction 默认。拒绝写入并返回错误,读操作不受影响
allkeys-lru 在所有 key 中使用近似 LRU 淘汰
volatile-lru 仅在设有过期时间的 key 中使用近似 LRU 淘汰
allkeys-lfu 在所有 key 中使用 LFU 淘汰(Redis 4.0+)
volatile-lfu 仅在设有过期时间的 key 中使用 LFU 淘汰(Redis 4.0+)
allkeys-random 在所有 key 中随机淘汰
volatile-random 仅在设有过期时间的 key 中随机淘汰
volatile-ttl 淘汰 TTL 最小(即最快过期)的 key

近似 LRU:Redis 并非维护全局链表,而是在淘汰时随机采样 maxmemory-samples(默认 5)个 key,从中淘汰最久未使用的。样本量越大越精确,但开销也更大。

选型建议:缓存场景通常选择 allkeys-lru;若有部分数据不应被淘汰(如配置类 key),可选择 volatile-lru 并确保可淘汰的 key 都设置了过期时间。

过期删除策略

Redis 对已过期的 key 采用惰性删除 + 定期删除的组合策略:

  • 惰性删除:访问某个 key 时才检查是否过期,过期则删除。节省 CPU 但可能导致大量过期 key 长期占用内存。
  • 定期删除:每隔一段时间(默认每秒 10 次)随机抽取一批设有过期时间的 key 进行检查,删除已过期的。在 CPU 开销和内存释放之间取得平衡。

两者配合使用:定期删除兜底清理大部分过期 key,惰性删除确保被访问的过期 key 不会返回脏数据。

持久化

维度 RDB(快照) AOF(追加日志)
原理 按时间间隔将内存数据快照写入磁盘(BGSAVE fork 子进程) 记录每条写命令,追加到日志文件
数据安全 可能丢失最后一次快照之后的数据 取决于 fsync 策略:always(每条命令)/ everysec(每秒)/ no(交给 OS)
恢复速度 快(直接加载二进制快照) 慢(需重放所有写命令)
文件体积 小(压缩二进制) 大(文本格式,可通过 BGREWRITEAOF 重写压缩)
适用场景 灾难恢复、全量备份 对数据安全性要求高的业务

生产建议:同时开启 RDB + AOF。AOF 保障数据安全,RDB 用于快速灾难恢复和从节点全量同步。Redis 4.0+ 支持混合持久化(aof-use-rdb-preamble yes),AOF 文件前半段为 RDB 快照、后半段为增量 AOF 日志,兼顾恢复速度与数据完整性。

主从同步

主从同步分为全量同步与增量同步:

  • 全量同步:从节点首次连接或复制积压缓冲区不足以覆盖断连期间的数据时触发。主节点执行 BGSAVE 生成 RDB 快照并发送给从节点(选用 RDB 是因为二进制格式体积小、加载快,而 AOF 文件大且需逐条重放,效率较低)。BGSAVE 期间产生的新写命令暂存于复制缓冲区,待 RDB 传输完成后再发送给从节点。
  • 增量同步:从节点短暂断连后重新连接时,主节点根据复制偏移量(offset)从复制积压缓冲区(repl_backlog)中提取断连期间的增量命令发送给从节点,无需重新生成 RDB。如果断连时间太长,则从节点会清空自己的数据,从主节点完整同步。

故障转移

Redis Sentinel(哨兵)负责监控主从节点的健康状态并在主节点故障时自动完成切换,流程如下:

  1. 主观下线:哨兵通过心跳检测发现主节点无响应,将其标记为主观下线(SDOWN)。单个哨兵的判断可能受自身网络问题影响,因此不能直接触发故障转移。
  2. 客观下线:该哨兵向其他哨兵发起询问,若认为主节点不可达的哨兵数量达到配置的 quorum,则标记为客观下线(ODOWN)。
  3. 选举 Leader 哨兵:哨兵之间通过类似 Raft 的协议选举 Leader 来主导本次故障转移,需要获得过半哨兵的投票。
  4. 选择新主节点:Leader 哨兵根据从节点的优先级、复制偏移量、运行 ID 等条件,选出最优的从节点晋升为新的主节点。
  5. 切换与通知:其他从节点改为从新主节点同步数据;哨兵持续监控旧主节点,当其重新上线时自动降级为从节点。

集群

Redis Cluster 用于解决单机内存容量和吞吐量的瓶颈,通过数据分片将数据分布到多个节点上,同时提供一定程度的高可用能力。

哈希槽(Hash Slot):Redis Cluster 将整个键空间划分为 16384 个哈希槽,每个 key 通过 CRC16(key) % 16384 计算所属槽位,再路由到负责该槽的节点。用槽而不是直接哈希到节点,是为了扩容时能按槽粒度做迁移,只搬一部分数据。16384 这个数是因为节点间心跳要带槽位图,2KB 一个包,再大带宽吃不消。客户端访问到错误节点时,会收到 MOVED 重定向指令,并更新本地的槽位映射。槽正在迁移时,已迁走的 key 会返回 ASK 重定向,只对这一次请求生效,不更新映射。

节点通信:集群节点之间通过 Gossip 协议进行去中心化通信,周期性交换节点状态、槽位映射和故障检测信息,无需中心协调节点。

Cluster 的代价是不支持跨槽的多 key 操作和事务,要用 hash tag {} 把相关 key 强制放同一个槽。

高可用的数据丢失

主从、哨兵和集群都不能保证不丢数据:

  • 异步复制:主节点写入后立即返回,不等从节点确认。主节点宕机时,尚未同步的写入会丢失。WAIT 命令可以等待从节点确认,但也不能保证强一致。
  • 脑裂:主节点与哨兵、从节点之间发生网络分区,哨兵选出新主后,客户端仍在向旧主写入。分区恢复后,旧主降级为从节点并清空数据,这段时间的写入全部丢失。可以配置 min-replicas-to-write 和 min-replicas-max-lag,让主节点在健康从节点不足时拒绝写入,这样能缩小丢失窗口,但不能完全消除。

缓存问题与应对

缓存穿透

问题:查询一个数据库中不存在的数据,缓存无法命中,请求每次都穿透到数据库。

应对方案:

  • 拦截非法请求:对于已知的异常用户、IP 做限流、黑名单处理
  • 缓存空值:数据库未命中时,在 Redis 中缓存一个空值(设置较短 TTL),避免重复查库。
  • 布隆过滤器:在缓存层前增加布隆过滤器,将所有合法 key 预载入过滤器(Redis 提供了 bitmap)。请求到达时先经过过滤器,不存在的 key 直接拦截。缺点则是布隆过滤器有假阳性和不支持删除操作。

缓存击穿

问题:某个热点 key 过期的瞬间,大量并发请求同时穿透到数据库。

应对方案:

  • 互斥锁:缓存未命中时,使用分布式锁(如 SETNX)让一个请求去重建缓存,其他请求等待或返回兜底数据。
  • 逻辑过期:不设置 Redis 过期时间,而是在 value 中存储逻辑过期时间。发现逻辑过期后,异步更新缓存,当前请求仍返回旧数据。

区分穿透与击穿

穿透是缓存和DB都没有,击穿是DB有。

缓存雪崩

问题:大量 key 在同一时刻过期(或 Redis 宕机),导致请求集中涌入数据库。

应对方案:

  • 过期时间打散:在基础 TTL 上添加随机偏移,避免集中过期。
  • 多级缓存:本地缓存(如 Caffeine)+ Redis,即使 Redis 不可用,本地缓存仍可兜底。
  • 高可用架构:使用 Redis Sentinel 或 Cluster 模式,避免单点故障。

缓存与数据库一致性

缓存与数据库是两个独立的数据源,任何非原子的更新操作都可能导致二者数据不一致。以下是三种常见的更新策略:

1. Cache Aside(旁路缓存,推荐)

最经典、最常用的缓存策略:

  • 读:先读缓存 → 命中则返回;未命中则查数据库 → 写入缓存 → 返回。
  • 写:先更新数据库 → 再删除缓存(而非更新缓存)。
写操作流程:
┌────────┐    1. UPDATE DB  ┌────────┐
│        │ ───────────────→ │   DB   │
│ Client │    2. DEL Cache  ├────────┤
│        │ ───────────────→ │  Cache │
└────────┘                  └────────┘

为什么删除而非更新缓存?

  • 避免并发写场景下的覆盖问题:线程 A 和 B 同时更新,可能导致缓存被旧值覆盖。
  • 遵循懒加载思想:缓存只在下次读取时按需重建,避免写多读少场景下的无效计算。

如果删除缓存失败如何处理?

把删除动作交由MQ,失败重试。或者监听MySQL的binlog来同步(对业务代码零入侵)。

残留的不一致窗口:

  • 读到旧缓存:在"更新数据库"与"删除缓存"之间的极短时间内,其他请求可能读到旧缓存。对于绝大多数业务场景,这个毫秒级窗口是可接受的。
  • 回填旧值:缓存未命中的读请求查到旧值后,写请求完成了更新和删除,读请求才把旧值写回缓存。这种情况发生概率低,但脏数据会一直留到过期,因此缓存要设置 TTL 兜底。要求更高时,可以在回填时校验版本号,或使用 lease 限制回填请求。

2. Read/Write Through(读写穿透)

由缓存层(Cache Provider)统一代理数据库读写,应用只与缓存交互:

  • Read Through:缓存未命中时,由缓存层自动从数据库加载并回填。
  • Write Through:写操作先写入缓存,由缓存层同步写入数据库,两者在同一操作中完成。

优点是应用逻辑简单,一致性由缓存层保证;缺点是写操作延迟较高(同步写库),且需要缓存中间件支持(如 Guava LoadingCache、NCache 等)。

3. Write Behind / Write Back(异步回写)

与 Write Through 类似,但缓存层对数据库的写入是异步批量的:

  • 写操作只更新缓存,立即返回。
  • 缓存层在后台定期将脏数据批量刷入数据库。

优点是写入性能极高(类似操作系统的 Page Cache);缺点是存在数据丢失风险(缓存宕机时未刷盘的数据会丢失),且实现复杂度高,适用于写密集且允许短暂不一致的场景(如点赞计数、PV 统计)。

策略对比

维度 Cache Aside Read/Write Through Write Behind
一致性 短暂不一致窗口 较强(同步写,但两步写入非原子) 最终一致(异步写)
写入性能 中(直接写库) 低(同步写库) 高(仅写缓存)
实现复杂度 低 中(需缓存层支持) 高(异步刷盘 + 容灾)
数据安全 高 高 有丢失风险
适用场景 通用,读多写少 希望封装数据源访问的场景 写密集、容忍短暂丢失

进阶:基于 Binlog 的异步同步

通过订阅 MySQL Binlog(如使用 Canal、Debezium),将数据变更事件异步推送到消费者,由消费者负责删除或更新对应的缓存:

┌────────┐  UPDATE  ┌────────┐  Binlog  ┌─────────┐  DEL Cache  ┌─────────┐
│ Client │ ───────→ │  MySQL │ ───────→ │  Canal  │ ──────────→ │  Redis  │
└────────┘          └────────┘          └─────────┘             └─────────┘

优点:业务代码完全解耦,不侵入写操作逻辑;天然幂等(可重复消费)。缺点:引入额外中间件,存在秒级延迟,且需要保障消息队列的可靠性。适用于对一致性要求较高但允许秒级延迟的核心业务。

Lua、事务与 Pipeline

方式 作用 原子性 注意事项
Pipeline 一次发送多条命令,减少网络往返 不保证,其他客户端的命令可能穿插 适合批量读写;集群下需要客户端按槽拆分
MULTI / EXEC 命令入队,EXEC 时连续执行 执行期间不被插入,但不回滚 无法根据中间结果做判断
Lua 脚本 在服务端执行一段脚本 整体原子执行,但不回滚 可以先读后判断再写;脚本过长会阻塞主线程;集群下所有 key 需要在同一个槽

不回滚:MULTI 入队时的语法错误会拒绝整个事务,但执行时的错误(如对 String 执行 LPUSH)只影响这一条命令,其他命令照常执行。Lua 脚本中途报错时,之前的写入也不会撤销,因此要先校验、再写入。

WATCH:配合 MULTI 实现乐观锁。EXEC 前被监视的 key 发生变化时,事务不执行,由客户端重试。需要先读后写时,通常直接使用 Lua 脚本。

分布式锁

SET NX

加锁必须一条命令完成,NX 保证互斥,EX 设置超时防止死锁:

SET lock_key unique_value NX EX 30

解锁时必须先验证 value 是否为自己持有,再删除,用 Lua 脚本保证原子性:

-- KEYS[1] = lock_key, ARGV[1] = unique_value
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

分布式锁的渐进式演进:

  1. 基本加锁:使用 SETNX 实现互斥。
  2. 防止死锁:若持锁客户端崩溃,锁将永远无法释放 → 设置过期时间。SETNX + EXPIRE 是两步操作,中间崩溃仍会死锁,因此使用 SET key value NX EX。
  3. 防止误删:锁过期后可能已被其他客户端持有 → value 使用 UUID 等唯一值,释放时先判断持有者。
  4. 原子解锁:判断 + 删除是两步操作,存在竞态条件 → 使用 Lua 脚本将二者合并为一个原子操作。
  5. 锁续期:若业务执行时间超过锁的过期时间,锁会被提前释放 → 引入看门狗(Watchdog)机制(如 Redisson),在锁即将过期时自动续期。
  6. 续期退出:续期任务不能无限运行 → 解锁时取消续期任务;客户端进程崩溃时续期随之停止,锁自然过期释放。
  7. 锁竞争优化:未获取到锁的线程如何高效等待? → 如 Redisson 通过 Pub/Sub 实现:等待线程订阅锁释放事件,持锁线程解锁时发布消息,订阅者被唤醒并重新竞争锁,避免低效的轮询。

Redlock

Redlock 的设计动机是解决主从架构下锁尚未同步到从节点时主节点宕机导致锁丢失的问题。其核心思路是向多个独立的 Redis 实例同时加锁,只有过半节点加锁成功才视为获取锁成功(若要求全部节点成功,在网络分区或部分节点故障时可用性过低,因此放宽为过半机制)。然而 Redlock 需要部署多个独立 Redis 实例,运维成本较高,实际生产中通常仍使用单节点分布式锁并配合业务层幂等设计。

锁的安全性

过期时间和看门狗都无法完全保证互斥:客户端发生长时间 GC 停顿或网络延迟时,续期也会停止,锁过期后被其他客户端获取,原客户端恢复后仍以为自己持有锁,继续写入。这也是 Redlock 争议的核心。

解决方式是 fencing token:每次加锁时获取一个单调递增的 token,写入时带上,存储层拒绝比已见过的 token 更小的请求。这需要存储层配合,而且 Redis 锁本身不提供这样的 token。因此对正确性要求高的场景,通常使用 etcd、ZooKeeper 等强一致的协调服务,或依靠数据库唯一约束、乐观锁等业务层幂等兜底。

使用场景

场景 数据结构 核心命令
缓存、共享 Session String、Hash SET key value EX、HSET
分布式锁 String SET key value NX EX
限流器 String、ZSet、Hash + Lua INCR、ZADD、ZCARD
排行榜 ZSet ZINCRBY、ZREVRANK、ZREVRANGE
延时队列 ZSet ZADD、ZRANGEBYSCORE、ZREM
消息队列 List、Pub/Sub、Stream BRPOP、PUBLISH、XADD
计数器与库存预扣 String、Hash INCR、DECR、HINCRBY
防重复提交 String SET key value NX EX
社交关系 Set SINTER、SDIFF、SISMEMBER
最近浏览记录 ZSet ZADD、ZREMRANGEBYRANK
在线用户 ZSet ZADD、ZREMRANGEBYSCORE、ZCARD
签到与活跃统计 Bitmap SETBIT、BITCOUNT、BITOP
UV 统计 HyperLogLog PFADD、PFCOUNT、PFMERGE
地理位置 GEO GEOADD、GEOSEARCH、GEODIST
海量数据去重 布隆过滤器 SETBIT、GETBIT 或 RedisBloom 的 BF.ADD、BF.EXISTS

限流器

算法 实现 特点
固定窗口 INCR + EXPIRE,用 Lua 保证两步原子 实现简单,但窗口边界前后可能放行 2 倍流量
滑动窗口 ZSet,score 为时间戳,member 为唯一 ID 任意窗口内都不超过阈值,但每个请求占一条记录,内存开销大
令牌桶 Hash + Lua 允许桶满时瞬时突发,长期速率不超过补充速率
漏桶 Hash + Lua 按固定速率放行,流量平滑,不允许突发

滑动窗口的步骤如下,整个流程通过 Lua 脚本保证原子性:

  1. 用 ZREMRANGEBYSCORE key 0 <now - windowSize> 移除窗口之外的历史记录
  2. 用 ZCARD key 统计当前窗口内的请求数,超过阈值则拒绝
  3. 未超限则用 ZADD key <now> <唯一ID> 记录当前请求

member 不能直接用时间戳,否则同一时刻的多个请求会被去重,导致少计数。

令牌桶用一个 Hash 存放令牌数量和最后补充时间,两个字段属于同一个 key,在集群下也位于同一个槽。不需要定时任务补充令牌,而是在请求到来时通过 Lua 脚本惰性计算:tokens = min(capacity, tokens + (now - last) * rate),取到令牌则放行,否则拒绝。

排行榜

ZSet 底层的跳表在数据更新和范围查询时具有 O(log N) 的时间复杂度。

  • ZADD / ZINCRBY:更新用户积分或热度
  • ZREVRANK:查询某用户当前的实时排名
  • ZREVRANGE:获取前 10 名或分页获取排行榜
  • ZUNIONSTORE:合并多个榜单,如将日榜合并为周榜、月榜

滚动窗口热榜

统计“最近 10 分钟的热搜”这类滚动窗口时,核心思路是按分钟分桶 + ZUNIONSTORE 滚动合并:

  1. 写入:每来一次搜索,执行 ZINCRBY search:min:{minute_ts} 1 keyword,每分钟一个桶。
  2. 合并:每分钟由定时任务执行 ZUNIONSTORE search:hot 10 search:min:{t} search:min:{t-1} ... search:min:{t-9},将最近 10 个桶合并,score 自动求和。
  3. 取榜:ZREVRANGE search:hot 0 9 WITHSCORES。
  4. 清理:为每个桶设置 EXPIRE search:min:{minute_ts} 660,移出窗口后自动回收。

每分钟向前滑动一格,因此是滚动窗口,而不是整点分段。关键词量很大时,可以用 Count-Min Sketch 预筛,只让达到阈值的关键词进入 ZSet,减少合并开销。在集群下,所有桶和结果 key 需要用 hash tag 放到同一个槽。

带过期的排行榜

需要同分排序或成员过期时,可以把两部分信息编码到 score:高位存实际分数,低位存时间。例如 score = 实际分数 * 10^10 + (MAX_TS - 过期时间戳),这样 ZREVRANGE 既能按分数降序排列,分数相同时又能让先过期的排在前面。

  • ZSet 的 score 是 double,超过 2^53 会丢失精度。低位是 10 位秒级时间戳时,实际分数不能超过约 90 万。
  • 低位无法按范围查询,清理过期成员时需要再用一个 ZSet 记录过期时间。这样可以避免为每个成员单独设置 TTL。

延时队列与定时任务

  • 将任务 ID 作为 member,执行时间戳作为 score 存入 ZSet:ZADD queue <执行时间戳> <taskId>。
  • 消费端起后台线程轮询,通过 ZRANGEBYSCORE queue 0 <now> LIMIT 0 1 取出已到期任务。
  • 取到后用 ZREM 删除,删除成功者获得执行权,避免多消费者重复消费。

相比 List 队列,优势在于支持任意延迟时长。但任务被 ZREM 后消费者宕机,或 Redis 宕机,任务都会丢失,因此需要 MySQL 兜底。

定时任务的选择:

维度 MySQL 定时扫表 Redis 延时队列(ZSet) 专业延时 MQ(如 RocketMQ 5.x)
触发精度 差(通常分钟级) 高(取决于轮询间隔,可到秒级) 高(秒级)
任务撤销/修改 容易(更新字段) 容易(ZREM / ZADD,O(log N)) 困难(发了就追不回)
消息吞吐量 低(易拖垮核心 DB) 中高(万级 QPS,受限于单节点内存) 海量(十万级以上,磁盘持久化)
数据可靠性 高(ACID) 中等(主从异步可能丢,需配合兜底) 高(多副本持久化、严格 ACK)
运维门槛 零(已有数据库) 低(已有 Redis 即可) 高(需要维护高可用 MQ 集群)

轻量消息队列

  • 简易队列:LPUSH + BRPOP,阻塞式拉取,适合简单的异步解耦。没有 ACK,消费者取走后宕机会丢消息。
  • 广播订阅:Pub/Sub,适合轻量消息广播,如配置实时刷新、聊天室广播。不持久化,消费者离线时会丢消息。
  • 可靠队列:Stream(Redis 5.0+),类似 Kafka 的机制,支持持久化、消费者组(Consumer Group)、消息 ACK、偏移量回溯,足以应对中等规模的消息队列需求。

计数器与库存预扣

直接打到关系型数据库会导致行锁争抢,而 Redis 的 INCR、DECR、HINCRBY 由单线程执行,天然原子,轻松支撑数万 QPS。

  • 异步落库:点赞量、阅读量先在 Redis 累计,定时批量同步回 MySQL。
  • 秒杀扣库存:
    1. 活动前把库存预热到 Redis,每个商品一个 key,让 key 分散到集群各节点。
    2. 请求进来时,通过 Lua 脚本一步完成“校验用户是否已购 → 校验库存 → 扣减库存 → 记录用户”。在集群下,库存 key 和用户 key 要用 hash tag 放到同一个槽。
    3. 扣减成功才发 MQ 异步创建订单,失败直接拦截,保护数据库。
    4. 下单失败或超时未支付时,用 INCR 归还库存,避免少卖。

秒杀方案的风险

  • 主从切换丢数据:主节点宕机时,从节点可能尚未同步扣减记录,导致超卖。因此 Redis 只做前置拦截,最终以数据库的 stock >= 0 校验为准,数据库确认后才返回最终结果。
  • 热点 key:单个爆款商品仍会集中在一个分片上,可以把库存拆到多个 key 分桶扣减。

防重复提交

用户连点、网络重试都可能导致重复请求。这是幂等问题,不是限流问题:

  1. 服务端返回提交页面时下发一个唯一 token(提交时业务 ID 可能尚未生成,比如订单号)。
  2. 提交时执行 SET token uid NX EX 10,必须一条命令完成。
  3. 设置成功的请求继续处理,失败的直接拒绝。

多个限制需要同时生效时也是同样的思路:比如优惠券既有全局总量,又有单用户每日限额,把“校验两个限额 → 同时扣减”放进一个 Lua 脚本,保证一起成功或一起失败。用户维度的计数用 EXPIREAT 设置到次日零点过期。

社交关系

  • SADD:记录关注列表或粉丝列表
  • SINTER(交集):共同关注
  • SDIFF(差集):我关注了但你没关注的人
  • SISMEMBER:判断用户 A 是否关注了用户 B
  • SUNION + SDIFF:“你关注的人也关注了谁”,合并所关注者的关注列表,再去掉自己已关注的人

规模上限在于:

  • 集合运算的耗时与集合大小成正比,大 V 的集合很大,单线程下会阻塞其他命令。
  • 集群下不同用户的 key 通常不在同一个槽,无法直接做多 key 运算,需要在应用层计算。

最近浏览记录

浏览记录要求有序且唯一,使用 ZSet,score 为浏览时间戳,member 为商品 ID。重复浏览时 ZADD 会更新 score,自动排到最前面。写入后执行 ZREMRANGEBYRANK key 0 -21,只保留最新的 20 条。

在线用户

客户端定时发送心跳,超过一定时间没有心跳视为离线。使用 ZSet,score 为最后心跳时间,member 为用户 ID:

  • ZADD:收到心跳时更新时间
  • ZREMRANGEBYSCORE key 0 <now - 超时时间>:剔除离线用户
  • ZCARD:查询在线总人数
  • ZSCORE:判断指定用户是否在线(心跳时间在超时范围内)

签到与活跃统计

Bitmap 极度节省内存,1 个 bit 代表一个状态,一个用户一年的打卡记录只需要 365 bit(约 46 字节)。

  • 用户签到:以用户和年份为 key,SETBIT sign:{uid}:{year} <day> 1 记录签到,BITCOUNT 统计累计签到天数,连续签到天数则用 BITFIELD 取出后按位计算。
  • 全站活跃:以日期为 key,SETBIT active:{date} <uid> 1 记录活跃用户,BITCOUNT 统计当日活跃数,BITOP AND 统计连续活跃用户,BITOP OR 统计多日内活跃用户。用户 ID 需要是连续的整数,否则 offset 过大会浪费内存。

UV 统计

统计页面的独立访客时,如果用 Set 存储所有用户 ID,亿级 UV 将消耗数 GB 内存。在允许少量误差的情况下,可以使用 HyperLogLog:

  • 优势:基于概率基数算法,不论统计几千万还是几十亿的数据,每个 key 最多占用约 12KB 内存,标准误差约 0.81%。
  • PFADD:记录访问者
  • PFCOUNT:获取近似 UV
  • PFMERGE:合并多个 key,按小时分 key 后可以查询任意区间去重后的 UV(各小时的 UV 不能直接相加)

地理位置

GEO 可以实现附近的人、附近的共享单车、附近的医院等:

  • GEOADD:录入骑手或商家的经纬度
  • GEOSEARCH:以用户当前坐标为圆心,搜索半径 3 公里内的所有目标,并按距离升序排列(替代已废弃的 GEORADIUS)
  • GEODIST:计算两点间的直线距离

海量数据去重

布隆过滤器空间极小,能在海量数据中快速判断某个元素是否存在。它只有假阳性:判断为不存在则一定不存在,判断为存在则可能误判,适合允许少量误判的场景。

  • 缓存穿透防护:把所有合法 ID 载入过滤器,不存在的 ID 直接拦截。过滤器不支持删除,需要定期全量重建。
  • 推荐流已读去重:快速判断内容是否已经推荐过给该用户,已推荐则跳过。误判时少量未读内容会被跳过,通常可以接受。

生产常见问题

Redis 单线程执行命令,一条慢操作就会拖慢所有请求,因此线上问题大多表现为延迟突增,而不是报错。

排查工具

现象 可能的问题 排查手段
延迟突增,慢请求集中 大 key、阻塞命令 SLOWLOG GET 10、LATENCY DOCTOR、redis-cli --bigkeys
单个分片 CPU 或带宽打满 热 key redis-cli --hotkeys(需要 LFU 淘汰策略)
延迟周期性尖刺 fork 延迟 INFO stats 的 latest_fork_usec
内存占用远高于数据量 内存碎片 INFO memory 的 mem_fragmentation_ratio

SLOWLOG 记录执行时间超过 slowlog-log-slower-than(默认 10ms)的命令,只统计执行阶段,不包含排队和网络耗时。

大 key

单个 value 过大或集合元素过多。读写和删除耗时长,会阻塞主线程,还会占用大量带宽,并影响集群迁移。

  • 拆分成多个小 key,或压缩 value
  • 用 UNLINK 代替 DEL,在后台线程释放内存
  • 用 HSCAN、SSCAN、ZSCAN 分批读取和删除

热 key

少数 key 的访问量极大,请求集中到一个分片。

  • 增加本地缓存,并设置较短的过期时间
  • 给 key 加随机后缀,拆成多份副本,分散到不同分片
  • 对读多写少的 key,通过从节点分担读流量(注意主从延迟)

阻塞命令

命令 问题 替代方案
KEYS * 遍历全部 key SCAN
HGETALL、SMEMBERS、LRANGE 0 -1 大集合一次性返回全部元素 HSCAN、SSCAN、分页读取
DEL 大 key 同步释放内存 UNLINK
FLUSHALL、FLUSHDB 同步清空数据 FLUSHALL ASYNC

生产环境可以通过 ACL 或 rename-command 禁用危险命令。

fork 延迟

BGSAVE 和 AOF 重写都要 fork 子进程。fork 需要复制页表,实例内存越大耗时越长,期间主线程阻塞。子进程运行时通过写时复制(COW)共享内存,写入越频繁,额外占用的内存越多,极端情况下接近翻倍,可能导致 OOM。

  • 控制单实例内存大小,用集群分片
  • 关闭透明大页(THP),否则 COW 的复制粒度会变大
  • 为 COW 预留内存,避开业务高峰执行持久化,或放到从节点执行

内存碎片

频繁更新或删除不同大小的数据后,已释放的内存无法被复用。mem_fragmentation_ratio 持续高于 1.5 时需要处理:开启 activedefrag yes(需要 jemalloc)在线整理,或重启实例。

评论