找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4980

积分

0

好友

707

主题
发表于 1 小时前 | 查看: 3| 回复: 0

很多团队把 Redis 用成了“快一点的 Key-Value 存储”,但真正决定它能否扛住百万并发的,从来不是一句“Redis 是单线程,所以很快”。

真正的答案是:Redis 用事件循环驯服网络 I/O,用精细的数据结构压缩 CPU 与内存开销,用串行化执行换掉锁竞争,再用复制、分片、持久化、观测和治理能力把这些机制包装成一套可在生产环境长期运行的系统。

本文不讲入门命令,而是围绕 Redis 的底层原理、工程设计和生产实践,系统回答四个关键问题:

  1. Redis 为什么能在高并发场景下维持低延迟?
  2. Redis 的“快”到底快在网络、内存还是执行模型?
  3. 为什么有些团队 Redis 跑到十几万 QPS 仍稳定,有些团队几千 QPS 就开始抖动?
  4. 如果要把 Redis 真正用进秒杀、缓存、排行榜、会话、计数器和分布式协调场景,架构应该怎么设计?

如果你希望把这篇文章读完后,既能解释 Redis 的核心机制,也能指导真实项目落地,这篇文章就是为你写的。

一、先把问题说透:Redis 扛百万并发,扛的到底是什么

很多人讨论 Redis 时,默认把“百万并发”理解成“单机每秒处理一百万条命令”。这并不严谨。

在生产环境里,所谓 Redis 扛住百万并发,通常对应的是下面几类能力组合:

  • 百万级连接总量下仍能稳定维护长连接与事件分发。
  • 数十万到百万级 QPS 的读写请求在集群范围内被平稳消化。
  • 在热点 key、批量请求、复制、持久化、故障切换同时存在时,延迟不发生灾难性抖动。
  • 在缓存、分布式锁、计数器、会话、排行榜、流式消费等不同业务模型下,仍能保持语义正确。

也就是说,Redis 的核心竞争力不是“单命令很快”,而是下面这条完整链路同时足够强:

连接接入
  -> 事件分发
  -> 命令解析
  -> 内存访问
  -> 数据结构操作
  -> 结果回写
  -> 复制 / 持久化 / 集群转发
  -> 客户端重试与业务兜底

只要其中任意一个环节失控,Redis 的“高并发神话”就会在真实生产中破功。

所以理解 Redis,不能只盯着命令时间复杂度,还要把它当成一个完整的高性能内存中间件系统来看。

二、Redis 的第一性原理:它不是“单线程数据库”,而是事件驱动的内存执行引擎

2.1 从一次请求开始看 Redis 的真实执行路径

客户端向 Redis 发起一次 GET user:1,在 Redis 内部经历的大致流程如下:

Client Socket
  -> TCP 接收缓冲区
  -> epoll / kqueue 感知可读事件
  -> Redis 事件循环分发读取
  -> 请求协议解析
  -> 查找 key 对应对象
  -> 执行命令逻辑
  -> 将响应写入客户端输出缓冲区
  -> 事件循环监听可写事件并回写

这条链路里有三个特征决定了 Redis 的性能上限:

  • 网络 I/O 是非阻塞的,不会因为某一个连接卡住整个进程。
  • 数据访问绝大多数发生在内存,不受磁盘随机 I/O 支配。
  • 命令执行阶段采用串行化模型,避免了共享数据结构上的锁竞争。

换句话说,Redis 的主线程做的并不是“傻等网络 + 执行计算”,而是把 CPU 时间尽量花在有价值的请求处理上。

2.2 事件循环才是 Redis 的真正心脏

Redis 主流程可以理解为一个典型 Reactor 模型:

                +---------------------------+
                |        Event Loop         |
                |                           |
new connection  | accept handler            |
readable fd     | read handler              |
writable fd     | write handler             |
time event      | cron / expire / metrics   |
before sleep    | flush pending replies     |
                +---------------------------+

简化后的伪代码如下:

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        if (eventLoop->beforesleep != NULL) {
            eventLoop->beforesleep(eventLoop);
        }

        numevents = aeApiPoll(eventLoop, tvp);

        for (int j = 0; j < numevents; j++) {
            aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
            if (fe->mask & AE_READABLE) {
                fe->rfileProc(eventLoop, fd, clientData, mask);
            }
            if (fe->mask & AE_WRITABLE) {
                fe->wfileProc(eventLoop, fd, clientData, mask);
            }
        }

        processTimeEvents(eventLoop);
    }
}

这个模型的关键价值不在“代码简洁”,而在以下几个工程结果:

  • 主线程没有线程竞争,状态一致性天然更强。
  • 连接数上升时,Redis 不需要为每个连接分配工作线程。
  • 网络层的吞吐主要受限于就绪事件数量、系统调用效率和网卡带宽,而不是线程切换能力。

2.3 为什么 epoll 比多线程连接模型更适合 Redis

高并发网络编程里常见的两条路线是:

  • 一连接一线程或线程池模型。
  • 多路复用 + 事件循环模型。

Redis 选择后者,不是因为“多线程一定慢”,而是因为它更适合 Redis 的请求特征:

  • 单条命令大多很短。
  • 每次命令执行时间通常远小于一次线程调度成本。
  • 数据结构高度共享,若并行执行需要大量加锁。
  • 大量请求本质是网络收发而非复杂计算。

对比一下:

方案 优势 代价
一连接一线程 编程直观 线程数爆炸、上下文切换重
线程池 + 阻塞 I/O 易与业务线程模型结合 空转、排队、锁竞争明显
epoll + 事件循环 连接利用率高、切换少 需要精细控制非阻塞和缓冲区

Redis 的判断很务实:当瓶颈在网络和内存访问,而非复杂 CPU 计算时,事件驱动模型收益最大。

2.4 Redis 6 之后为什么引入 I/O 线程,但仍坚持命令串行执行

很多人听说 Redis 6 开始支持多线程,就误以为“Redis 已经不是单线程了”。这句话只对了一半。

更准确的说法是:

  • 命令执行与核心数据结构修改,仍然以串行化为主。
  • 部分网络读写可以交给 I/O 线程并行处理。

这背后是一次非常克制的工程妥协:

  • Redis 团队承认网络读写在高吞吐场景下会成为瓶颈。
  • 但又不愿意把字典、跳表、过期表、对象系统全面改造成细粒度锁结构。

因此它选择了最划算的拆分方式:

  • 把“搬数据”这件事并行化。
  • 把“改状态”这件事仍然串行化。

这也是生产系统非常典型的设计哲学:不是为了追求理论并行度而牺牲系统可控性,而是在收益最高的局部引入并行。

三、Redis 为什么比很多缓存系统更快:核心不只是内存,而是内存上的数据结构工程

3.1 Redis 的对象模型决定了它不是“裸内存数组”

Redis 内部不是把 key 和 value 简单塞进哈希表就结束了,它有一套对象系统来描述:

  • 对象类型:String、List、Hash、Set、ZSet、Stream 等。
  • 对象编码:int、embstr、raw、quicklist、listpack、hashtable、skiplist 等。
  • 引用计数、共享对象、淘汰语义。

同样是一个“字符串”,Redis 内部可能有完全不同的编码策略:

String value
  -> int      适合数值型内容
  -> embstr   适合较短字符串,一次内存分配
  -> raw      适合较长字符串,独立 SDS

这意味着 Redis 的高性能并不是简单来自“在内存里”,而是来自“根据数据形态选择最合适的内存布局”。

3.2 SDS:为什么 Redis 不直接使用 C 字符串

Redis 的字符串底层是 SDS(Simple Dynamic String),而不是普通 char *。原因并不抽象,都是实际工程问题:

  • C 字符串求长度要 strlen,时间复杂度是 O(n)。
  • 二进制数据里可能包含 \0,普通字符串会被截断。
  • 频繁扩容如果每次都 realloc,性能和碎片都很差。

SDS 的典型结构如下:

struct sdshdr64 {
    uint64_t len;
    uint64_t alloc;
    unsigned char flags;
    char buf[];
};

它带来的收益很直接:

  • len 让长度获取变成 O(1)。
  • alloc 让 Redis 可以做预分配,减少频繁扩容。
  • 二进制安全让字符串可以承载 JSON、序列化对象、压缩内容甚至音视频切片索引。

如果你的业务里有大量 APPEND、日志缓冲、消息拼装、批量序列化,SDS 的价值会非常明显。

3.3 dict:为什么绝大多数 KV 操作都足够快

Redis 的 key 空间底层主要依赖哈希表 dict。但重点不只是 O(1),而是 Redis 对 rehash 的处理方式很工程化。

如果普通哈希表在扩容时一次性迁移所有桶,会发生什么?

  • 主线程阻塞。
  • 单次请求延迟抖高。
  • 热点流量下直接诱发超时。

Redis 的做法是渐进式 rehash:

  • 保留旧哈希表 ht[0] 和新哈希表 ht[1]
  • 每次增删改查顺手搬迁一部分 bucket。
  • 把一次“大停顿”拆成多次“小成本”。

这类设计非常值得后端工程师借鉴,因为很多高并发系统的稳定性,恰恰来自“把一次重活拆散”,而不是“把重活做快”。

3.4 ZSet 为什么选择跳表 + 哈希表,而不是只用一种结构

很多排行榜、延迟队列、调度优先级场景都依赖 ZSET。它之所以强大,是因为 Redis 没有只用单一结构硬扛所有需求,而是用了两套结构协同:

  • 哈希表:按 member 做 O(1) 查找。
  • 跳表:按 score 保持有序,支持范围查询和排序。

这对应两类不同业务需求:

  • “用户 10001 当前积分多少?”需要点查。
  • “展示前 100 名榜单”需要有序范围。

如果只用哈希表,排序会很重。
如果只用平衡树,按 member 查找成本就高。

Redis 直接把二者组合起来,用空间换取稳定的访问性能,这就是典型的工程取舍。

3.5 编码压缩带来的收益,往往比算法复杂度更“值钱”

在真实生产里,Redis 的瓶颈经常不是某个命令复杂度太高,而是:

  • 内存占用过大。
  • CPU Cache 命中率下降。
  • 网络返回体变大。
  • 主从复制压力升高。

因此 Redis 很重视紧凑编码,比如小型 Hash、List、ZSet 在元素规模较小时,倾向使用更节省空间的编码结构。

这背后的收益至少有三层:

  • 更少的内存占用意味着同一台机器能放下更多热数据。
  • 更紧凑的内存布局意味着更好的 CPU Cache 命中率。
  • 更小的对象意味着复制、持久化、迁移成本更低。

所以理解 Redis 的快,不能只看“算法复杂度”,还要看“内存布局是否友好”。

四、Redis 为什么敢不用锁:串行化执行模型是它最被误解、也最值钱的设计

4.1 单线程并不等于性能差,关键要看锁竞争是否被彻底消掉

后端开发者天然容易接受“多线程 = 更高并发”。但这条经验在 Redis 这里不总成立。

如果 Redis 把核心命令执行改成多线程,立刻会遇到三类开销:

  • 字典、过期字典、对象系统上的并发访问锁。
  • 线程切换和调度开销。
  • 多核下共享数据结构的缓存一致性成本。

而 Redis 的核心数据又高度共享:

  • 所有连接都在访问同一份 key 空间。
  • 热点 key 会集中打到同一块内存。
  • 过期、淘汰、复制、AOF 追加都围绕同一状态机运行。

在这样的系统中,串行化执行的真正价值不是“实现简单”,而是:

  • 避免业务级锁竞争。
  • 保证命令原子语义天然成立。
  • 让请求延迟更加可预期。

4.2 Redis 的原子性是“单命令天然原子”,不是“跨业务流程天然正确”

Redis 常被夸“原子性强”,但这里有个很重要的边界:

  • 单条命令在服务端执行时通常是原子的。
  • 多条命令跨客户端、跨网络往返时,并不天然具备业务原子性。

举个最常见的库存扣减例子:

Client A: GET stock:sku:1001 -> 10
Client B: GET stock:sku:1001 -> 10
Client A: DECRBY stock:sku:1001 8
Client B: DECRBY stock:sku:1001 5

如果你把“读库存”和“扣库存”拆成两次请求,Redis 再快也救不了你,因为问题出在业务操作被分裂了。

4.3 生产环境里,Lua 比 MULTI/EXEC 更常作为业务原子单元

在需要“检查条件 + 修改数据 + 返回结果”的场景里,Lua 脚本通常比简单事务更稳。

一个更接近生产的库存扣减脚本如下:

-- KEYS[1]: stock key
-- KEYS[2]: reserved set key
-- ARGV[1]: order id
-- ARGV[2]: quantity
local stockKey = KEYS[1]
local reservedKey = KEYS[2]
local orderId = ARGV[1]
local quantity = tonumber(ARGV[2])

if redis.call("SISMEMBER", reservedKey, orderId) == 1 then
    return { "DUPLICATE", redis.call("GET", stockKey) or "0" }
end

local stock = tonumber(redis.call("GET", stockKey) or "0")
if stock < quantity then
    return { "INSUFFICIENT", tostring(stock) }
end

redis.call("DECRBY", stockKey, quantity)
redis.call("SADD", reservedKey, orderId)
redis.call("EXPIRE", reservedKey, 1800)

return { "OK", tostring(stock - quantity) }

这个脚本比“裸 DECRBY”更生产级,原因在于它补齐了两个常见缺口:

  • 幂等:同一个订单重复提交时,不会重复扣库存。
  • 状态封装:业务判断和数据修改在一次服务端原子执行中完成。

4.4 Java 客户端的调用方式,也必须体现幂等和异常边界

public class StockReservationService {

    private final StringRedisTemplate redisTemplate;
    private final DefaultRedisScript<List> reserveScript;

    public StockReservationService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.reserveScript = new DefaultRedisScript<>();
        this.reserveScript.setScriptText("""
            local stockKey = KEYS[1]
            local reservedKey = KEYS[2]
            local orderId = ARGV[1]
            local quantity = tonumber(ARGV[2])

            if redis.call("SISMEMBER", reservedKey, orderId) == 1 then
                return { "DUPLICATE", redis.call("GET", stockKey) or "0" }
            end

            local stock = tonumber(redis.call("GET", stockKey) or "0")
            if stock < quantity then
                return { "INSUFFICIENT", tostring(stock) }
            end

            redis.call("DECRBY", stockKey, quantity)
            redis.call("SADD", reservedKey, orderId)
            redis.call("EXPIRE", reservedKey, 1800)
            return { "OK", tostring(stock - quantity) }
            """);
        this.reserveScript.setResultType(List.class);
    }

    public StockReserveResult reserve(String skuCode, String orderId, int quantity) {
        List<String> keys = List.of(
                "stock:sku:" + skuCode,
                "stock:sku:" + skuCode + ":orders"
        );

        List<?> result = redisTemplate.execute(reserveScript, keys, orderId, String.valueOf(quantity));
        if (result == null || result.size() < 2) {
            throw new IllegalStateException("reserve stock script returned empty result");
        }

        String status = String.valueOf(result.get(0));
        long remain = Long.parseLong(String.valueOf(result.get(1)));

        return switch (status) {
            case "OK" -> StockReserveResult.success(remain);
            case "DUPLICATE" -> StockReserveResult.duplicate(remain);
            case "INSUFFICIENT" -> StockReserveResult.insufficient(remain);
            default -> throw new IllegalStateException("unknown stock reserve status: " + status);
        };
    }
}

这里要注意两个工程点:

  • Redis 脚本只负责原子状态转换,不负责把整个订单系统事务都包起来。
  • 脚本执行成功之后,数据库落单、消息发送、补偿回滚仍然要由业务层接力完成。

4.5 分布式锁不是“加个 SETNX 就结束”,而是完整的时序控制问题

Redis 经常被拿来做分布式锁,但生产系统里真正棘手的不是“能不能抢到锁”,而是:

  • 锁过期后,旧持有者是否还可能误操作资源?
  • 业务超时后,锁续租是否可靠?
  • 主从切换时,锁状态是否可能丢失?
  • 是否需要 Fencing Token 来防止旧请求越权写入?

所以如果你的业务是“保护关键外部资源”,比如库存中心、支付出款、调度器主控,Redis 锁必须和下面这些机制一起设计:

  • 唯一请求 ID。
  • 自动过期。
  • 续租机制。
  • 幂等消费。
  • Fencing Token 或版本号校验。
  • 失败补偿与告警。

只讲 SET key value NX PX 30000 的文章,远远不够支撑生产系统。

五、Redis 真正的性能杀手,往往不是命令执行,而是网络往返

5.1 单条命令很快,不代表整体吞吐就高

一次 Redis 请求的总耗时通常由几部分构成:

  • 客户端编码请求。
  • 网络发送。
  • 服务端解析和执行。
  • 响应回写。
  • 客户端反序列化。

其中在局域网场景下,服务端真正执行业务逻辑所占比例可能很低。很多团队压测 Redis 时,看到 CPU 没打满,却发现吞吐上不去,根因往往有三个:

  • 请求太碎,RTT 被无限放大。
  • 连接池太小,客户端排队。
  • 批量操作方式错误,重复网络往返过多。

5.2 Pipeline 的价值,本质上是减少 RTT 税

如果有一万个 key 需要写入,对比三种方式:

  • 循环一万次 SET
  • 一次 pipeline 发送一万条 SET
  • 按业务语义改造成 MSET 或批量结构写入。

性能差异经常不是 10%,而是一个数量级。

一个更贴近生产的 Python 示例:

import redis
from typing import Iterable

class UserCacheWriter:
    def __init__(self, client: redis.Redis, batch_size: int = 500):
        self.client = client
        self.batch_size = batch_size

    def save_profiles(self, rows: Iterable[dict]) -> int:
        batch = []
        total = 0

        for row in rows:
            batch.append(row)
            if len(batch) >= self.batch_size:
                total += self._flush(batch)
                batch.clear()

        if batch:
            total += self._flush(batch)

        return total

    def _flush(self, rows: list[dict]) -> int:
        pipe = self.client.pipeline(transaction=False)
        for row in rows:
            key = f"user:profile:{row['user_id']}"
            pipe.hset(key, mapping={
                "nickname": row["nickname"],
                "city": row["city"],
                "level": row["level"],
            })
            pipe.expire(key, 3600)
        pipe.execute()
        return len(rows)

这个版本强调了三件比“能跑通”更重要的事:

  • 分批发送,避免一次 pipeline 过大压垮输出缓冲区。
  • 显式设置 transaction=False,避免误以为 pipeline 等于事务。
  • 把 TTL 写入同一批次,减少额外往返。

5.3 Pipeline 不是银弹,它也有边界

Pipeline 的典型风险包括:

  • 单批次太大,客户端和服务端内存飙升。
  • 响应积压在输出缓冲区,慢客户端拖累实例。
  • 某个命令失败后,后续命令仍继续执行,语义并不回滚。
  • 在 Cluster 模式下,如果 key 跨 slot,客户端可能无法把命令聚合到同一批。

因此生产实践通常会这么做:

  • 每批 100 到 1000 条命令,按对象大小压测确定阈值。
  • 批量写入时优先按 key tag 聚类,降低跨 slot 问题。
  • 对强业务一致性场景,优先 Lua、消息驱动或数据库事务配合,而非盲目 pipeline。

六、Redis 高并发的真正架构价值:不只是缓存,而是很多高频状态的“第一落点”

6.1 Redis 适合承载哪些状态

Redis 之所以在高并发系统中位置很重,是因为它擅长处理下面这些状态:

  • 热点读取频繁、允许短期不落盘的缓存状态。
  • 变化快、需快速原子更新的计数状态。
  • 生命周期短、访问密集的会话状态。
  • 需要有序结构支撑的排行榜和延迟任务状态。
  • 需要流式消费、削峰和短期缓冲的消息状态。

它本质上是“高频状态引擎”,而不只是“数据库前面的缓存层”。

6.2 典型生产架构:本地缓存 + Redis + 数据库 + MQ

一个比较常见、也比较稳的生产架构如下:

                    +----------------------+
                    |      Client/App      |
                    +----------+-----------+
                               |
                     +---------v---------+
                     |  Service Cluster  |
                     |  stateless nodes  |
                     +---------+---------+
                               |
         +----------------------+----------------------+
         |                      |                      |
  +------v------+       +-------v-------+      +------v------+
  | Local Cache |       | Redis Cluster |      | Message MQ  |
  | Caffeine    |       | hot state     |      | async drain |
  +------+------+       +-------+-------+      +------+------+
         |                      |                      |
         +----------------------+----------------------+
                               |
                      +--------v--------+
                      |   MySQL / PG    |
                      | source of truth |
                      +-----------------+

这里的职责边界要非常清楚:

  • 本地缓存扛热点读,减少跨网络访问。
  • Redis 扛高频共享状态和原子操作。
  • 数据库保存最终真实数据。
  • MQ 负责削峰、异步扩散和最终一致性。

一旦边界不清,比如把 Redis 既当唯一真相源,又没有持久化、没有补偿、没有审计,那系统迟早出事故。

6.3 秒杀系统里,Redis 最稳的角色通常不是“最终库存账本”

以秒杀为例,Redis 最适合承担的是“前置流量闸门”和“短周期预占状态”,而不是孤立地充当整个库存系统。

更合理的链路是:

请求进入
  -> 网关限流
  -> Redis 预扣库存 / 用户去重 / 活动资格校验
  -> 写入下单消息
  -> 订单服务异步消费
  -> 数据库确认真实订单
  -> 成功后固化库存流水
  -> 失败则补偿释放 Redis 预占

这样设计的好处是:

  • Redis 扛住峰值突刺。
  • 数据库只处理被筛选后的有效请求。
  • 业务能通过消息与补偿机制保证最终一致性。

这才是“Redis 用在高并发系统里”的正确姿势。

七、从单机到百万 QPS,Redis 架构如何演进

7.1 阶段一:单机 Redis,解决的是“先把热点顶住”

适用场景:

  • 团队早期。
  • 日活不高。
  • 读多写少。
  • 可以接受单点风险或通过应用降级兜底。

这个阶段重点不是搭复杂集群,而是先做好:

  • key 设计。
  • TTL 策略。
  • 缓存穿透和击穿防护。
  • 连接池与线程池参数。
  • 慢日志和基础监控。

很多系统不是死在“没上集群”,而是死在单机阶段就把 Redis 用乱了。

7.2 阶段二:主从复制 + 哨兵,解决的是可用性

当业务开始依赖 Redis 持续在线时,需要引入:

  • 主从复制:提升容灾与读扩展能力。
  • Sentinel:监控主节点并自动故障转移。

但这里一定要明确两个边界:

  • 复制是异步的,所以不能把它当成强一致数据库。
  • 故障切换期间会有短时抖动,客户端必须支持重连与重试。

因此读写分离能不能上,要看业务语义:

  • 用户画像、推荐缓存、配置缓存通常可以。
  • 刚写即读、库存、幂等标记这类状态要非常谨慎。

7.3 阶段三:Redis Cluster,解决的是容量和吞吐横向扩展

当单实例内存、带宽或 CPU 打满后,必须走分片。

Redis Cluster 的核心思想是把 key 空间映射到 16384 个哈希槽,再分配给不同节点。

key
  -> CRC16(key) % 16384
  -> slot
  -> route to target master node

它的优点很明显:

  • 原生分片。
  • 去中心化。
  • 节点故障时可切换到对应副本。

但它也引入了新的工程约束:

  • 多 key 操作必须尽量落在同一 slot。
  • 事务、Lua、pipeline 的使用边界更严格。
  • 热点 slot 可能成为局部瓶颈。

因此在 Cluster 模式下,key 设计不是“美观问题”,而是决定架构上限的问题。

7.4 阶段四:多集群分治,解决的是租户隔离与故障域收敛

当 Redis Cluster 继续做大后,很多大厂不会无限堆在一个超大集群里,而会按业务域拆成多个集群:

  • 用户会话集群。
  • 推荐缓存集群。
  • 风控计数集群。
  • 排行榜集群。
  • 分布式协调集群。

这么做的原因不是“技术炫耀”,而是避免以下问题:

  • 一个业务的大 key 或热点 key 拖垮全局。
  • 集群扩缩容影响面过大。
  • 研发权限和变更边界难以治理。
  • 故障域太大,回滚困难。

7.5 阶段五:Redis 不再是孤立组件,而是统一状态平台

在成熟架构里,Redis 往往会被纳入统一平台能力:

  • 统一客户端 SDK。
  • 统一 key 命名规范。
  • 统一限流、熔断、监控、审计。
  • 统一热点识别、慢请求分析、容量预测。
  • 统一变更流程与压测基线。

此时 Redis 的治理能力,往往比单机性能更重要。

八、Redis 的高并发工程化,真正该补的是这些能力

8.1 缓存一致性:不是删缓存这么简单

最经典的问题是数据库更新后,Redis 里的旧缓存怎么处理。

常见方案包括:

  • 先更新数据库,再删除缓存。
  • 延迟双删。
  • 通过 MQ 广播失效事件。
  • 用 binlog / CDC 驱动缓存更新。

正确方案取决于你的业务:

  • 如果允许短暂陈旧,删除缓存通常足够。
  • 如果需要大规模一致性刷新,事件驱动更稳。
  • 如果热点极高,必须结合本地缓存、版本号和过期策略一并设计。

8.2 穿透、击穿、雪崩:三个词别再混着用了

  • 缓存穿透:查不存在的数据,请求直接打到数据库。
  • 缓存击穿:某个热点 key 过期瞬间,大量请求同时回源。
  • 缓存雪崩:大量 key 在同一时间段失效,数据库被压垮。

对应治理手段也不同:

  • 穿透:布隆过滤器、空值缓存、参数校验。
  • 击穿:单飞机制、互斥构建、逻辑过期。
  • 雪崩:TTL 打散、多级缓存、降级兜底。

一个生产级的 Java 单飞构建示例如下:

public class ProductCacheService {

    private final StringRedisTemplate redisTemplate;
    private final ProductRepository productRepository;
    private final ConcurrentHashMap<String, CompletableFuture<ProductCacheValue>> inFlight =
            new ConcurrentHashMap<>();

    public ProductCacheService(StringRedisTemplate redisTemplate, ProductRepository productRepository) {
        this.redisTemplate = redisTemplate;
        this.productRepository = productRepository;
    }

    public ProductCacheValue getProduct(long productId) {
        String key = "product:detail:" + productId;
        String cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return ProductCacheValue.fromJson(cached);
        }

        CompletableFuture<ProductCacheValue> future = inFlight.computeIfAbsent(key, k ->
                CompletableFuture.supplyAsync(() -> rebuildCache(productId, key))
                        .whenComplete((v, ex) -> inFlight.remove(k))
        );

        try {
            return future.get(300, TimeUnit.MILLISECONDS);
        } catch (Exception ex) {
            throw new IllegalStateException("load product cache failed, productId=" + productId, ex);
        }
    }

    private ProductCacheValue rebuildCache(long productId, String key) {
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new IllegalArgumentException("product not found: " + productId));

        ProductCacheValue value = ProductCacheValue.from(product);
        redisTemplate.opsForValue().set(key, value.toJson(), Duration.ofMinutes(10)
                .plusSeconds(ThreadLocalRandom.current().nextInt(120)));
        return value;
    }
}

这个版本补齐了两个现实问题:

  • 热点重建期间,同一 key 只会有一个线程真正回源。
  • TTL 随机打散,避免集中失效。

8.3 幂等、重试、补偿:Redis 成功不代表业务成功

高并发系统里最危险的误区之一是:Redis 执行成功,就认为业务闭环完成。

其实常见失败点很多:

  • Redis 扣库存成功,但订单库写入失败。
  • Redis 记录了用户资格,但消息发送失败。
  • Redis 锁已释放,但下游处理超时导致状态不一致。

因此 Redis 周边必须补齐:

  • 请求唯一 ID。
  • 幂等表或去重 key。
  • Outbox / MQ 可靠投递。
  • 定时补偿任务。
  • 人工审计入口。

这也是为什么真正的高并发系统文章,不能只讲 Redis 命令,而必须讲业务闭环。

8.4 限流、熔断与降级:Redis 很快,但不是无限快

Redis 作为中心缓存组件,一旦抖动,影响面通常很大。因此上层应用必须有保护策略。

一个基于滑动窗口的限流 Lua 示例:

-- KEYS[1]: rate limiter key
-- ARGV[1]: now millis
-- ARGV[2]: window millis
-- ARGV[3]: threshold
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local threshold = tonumber(ARGV[3])

redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local current = redis.call("ZCARD", key)
if current >= threshold then
    return 0
end

redis.call("ZADD", key, now, tostring(now))
redis.call("PEXPIRE", key, window)
return 1

适用场景包括:

  • API 网关用户频控。
  • 秒杀入口防刷。
  • 验证码发送频率限制。
  • 某些高成本接口的系统自保护。

但要注意,Redis 限流只是手段之一,生产上还应搭配:

  • 网关侧本地快速拒绝。
  • 熔断器避免雪上加霜。
  • 降级返回保证用户可感知体验。

8.5 可观测性:没有观测,Redis 的“快”就无法治理

Redis 运行稳定与否,不能靠“感觉”。

至少应该监控这些指标:

  • QPS、命令分布、每秒网络收发字节。
  • 连接数、拒绝连接数、慢查询数量。
  • 内存使用率、碎片率、驱逐次数。
  • 主从复制延迟、AOF 重写耗时、RDB fork 耗时。
  • 热点 key、big key、输出缓冲区积压。

可以重点关注的告警信号包括:

  • used_memory 持续逼近 maxmemory
  • mem_fragmentation_ratio 明显偏高。
  • blocked_clients 上升。
  • instantaneous_ops_per_sec 激增但命中率下降。
  • latest_fork_usec 异常飙升。
  • 某个客户端 omem 非常高。

没有这些观测,你就很难回答“Redis 是真的打满了,还是被慢客户端拖住了”。

九、生产环境最常见的 Redis 事故,往往都不是“Redis 不够快”

9.1 Big Key:不是占内存这么简单,而是拖垮整个执行链路

Big Key 的危害包括:

  • 删除耗时长,阻塞主线程。
  • 复制和迁移耗时长,切换风险大。
  • 网络返回体大,拖慢客户端。
  • 持久化和重写成本更高。

典型大 key 包括:

  • 一个用户下挂几十万条消息 ID 的 Set。
  • 一个活动下塞进几百万用户的 Hash。
  • 一个 List 长期堆积不消费。

治理思路:

  • 提前拆 key,不要把一个业务实体无限聚合。
  • 对删除大集合使用渐进式或异步删除能力。
  • 做离线扫描和告警,不要等线上抖动后才排查。

9.2 Hot Key:不是缓存命中高,而是单点资源被打穿

Hot Key 事故通常表现为:

  • 某个 key 的 QPS 远高于其他 key。
  • 某个 slot 压力异常集中。
  • 单个节点 CPU 或网卡先打满。

处理思路:

  • 应用侧增加本地缓存。
  • 对只读热点做短周期副本扩散。
  • 对可拆分业务采用分桶 key。
  • 对极热请求在网关或应用层直接兜底。

一个简单的本地缓存包装示意:

public class HotSkuSnapshotService {

    private final Cache<Long, SkuSnapshot> localCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofSeconds(3))
            .build();

    private final StringRedisTemplate redisTemplate;

    public HotSkuSnapshotService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public SkuSnapshot getSnapshot(long skuId) {
        return localCache.get(skuId, this::loadFromRedis);
    }

    private SkuSnapshot loadFromRedis(long skuId) {
        String key = "sku:snapshot:" + skuId;
        String json = redisTemplate.opsForValue().get(key);
        if (json == null) {
            throw new IllegalStateException("sku snapshot missing, skuId=" + skuId);
        }
        return SkuSnapshot.fromJson(json);
    }
}

这里的核心不是“本地缓存能省一次 Redis 请求”,而是它能把最极端的热点从中心节点上卸下来。

9.3 慢客户端与输出缓冲区积压:很多 Redis 内存异常都跟它有关

Redis 的慢客户端问题很隐蔽,因为服务端命令执行可能并不慢,但响应发不出去。

常见场景:

  • 消费端网络差。
  • 大量订阅客户端处理不过来。
  • pipeline 批量响应过大。
  • 下游应用 GC 卡顿导致读响应不及时。

此时 Redis 会发生:

  • 输出缓冲区不断膨胀。
  • 内存看起来在涨,但 key 并没有明显变多。
  • 甚至触发 OOM 风险。

所以线上排查 Redis 内存问题,不能只看 key 大小,还要看客户端输出缓冲区。

9.4 fork 抖动:持久化的真实代价往往在内存页复制

很多团队发现 Redis 在做 RDB 或 AOF 重写时,延迟突然升高,误以为是磁盘太慢。实际上更常见的问题是 fork 开销和 Copy-On-Write。

原因在于:

  • fork 需要复制页表,实例内存越大,耗时越高。
  • fork 后主进程继续写入,会触发内存页复制。
  • 写入越频繁,额外内存开销越大。

因此生产环境应重点关注:

  • 单实例内存不要贪大。
  • 大促前避免在核心时段触发重写。
  • 使用合理持久化策略,不同业务不要共用一套配置。

9.5 过期淘汰与内存碎片:很多“偶发抖动”其实都有迹可循

Redis 的过期删除并不是“到点立即删完”,而是:

  • 惰性删除:访问时发现过期再删。
  • 定期删除:后台周期扫描部分 key。

如果 TTL 设计过于集中,可能会出现:

  • 某个时间窗口内大量 key 同时过期。
  • CPU 花在删除和回收上的时间暴涨。
  • 回源流量瞬间冲击数据库。

再叠加内存碎片,问题会更复杂。

因此建议:

  • TTL 打散。
  • 避免大批量相同过期时间写入。
  • 定期观察碎片率并做容量预留。

十、生产级 Redis 配置与客户端治理,应该怎么落

10.1 服务端配置重点,不是照抄模板,而是按业务类型分层

缓存型 Redis 与强恢复型 Redis,配置重点通常不同。

示意配置如下:

# 内存
maxmemory 24gb
maxmemory-policy allkeys-lru

# 网络
tcp-backlog 20480
timeout 0
tcp-keepalive 60

# 慢日志
slowlog-log-slower-than 1000
slowlog-max-len 1024

# 惰性删除与异步释放
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes

# AOF
appendonly yes
appendfsync everysec
no-appendfsync-on-rewrite yes

# 客户端缓冲区保护
client-output-buffer-limit normal 256mb 128mb 60
client-output-buffer-limit replica 512mb 256mb 120
client-output-buffer-limit pubsub 64mb 32mb 60

# I/O threads
io-threads 4
io-threads-do-reads yes

但请注意:

  • maxmemory-policy 不是越激进越好,要看业务是否允许淘汰。
  • appendonly yes 不是所有缓存业务都必须开启。
  • io-threads 也不是开得越多越好,需要结合 CPU 和网卡压测。

10.2 客户端连接池参数,很多时候比服务端配置更先出问题

线上 Redis 访问异常,经常是应用配置问题,不是 Redis 本身问题。

Java 客户端至少要明确:

  • 最大连接数。
  • 最大空闲与最小空闲。
  • 获取连接超时。
  • 命令执行超时。
  • 读超时。
  • 重试次数与退避策略。

一个更接近生产的 Lettuce 连接池配置示例如下:

@Bean
public LettuceConnectionFactory redisConnectionFactory(RedisProperties properties) {
    RedisStandaloneConfiguration server = new RedisStandaloneConfiguration();
    server.setHostName(properties.getHost());
    server.setPort(properties.getPort());
    server.setPassword(RedisPassword.of(properties.getPassword()));

    GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
    poolConfig.setMaxTotal(128);
    poolConfig.setMaxIdle(32);
    poolConfig.setMinIdle(8);
    poolConfig.setMaxWait(Duration.ofMillis(200));

    LettuceClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
            .commandTimeout(Duration.ofMillis(150))
            .shutdownTimeout(Duration.ZERO)
            .poolConfig(poolConfig)
            .build();

    return new LettuceConnectionFactory(server, clientConfig);
}

重点不是参数具体数字,而是思路:

  • 不要让应用线程无限等待 Redis 连接。
  • Redis 超时时间要纳入整体接口超时预算。
  • 当 Redis 异常时,应用要能快速失败或降级。

10.3 Key 设计规范,直接影响 Cluster 可扩展性

生产级 key 设计建议至少包含:

  • 业务域前缀。
  • 对象类型。
  • 主键标识。
  • 可选版本号。

例如:

user:profile:1001
order:pay-status:202606230001
coupon:campaign:888:inventory
rate-limit:api:/order/create:user:1001

在 Cluster 模式下,如果多个 key 需要一起操作,应考虑 hash tag:

cart:{user:1001}:summary
cart:{user:1001}:items
cart:{user:1001}:coupon

这样它们会路由到同一个 slot,方便多 key 操作、Lua 与 pipeline 聚合。

十一、一个真实可落地的案例:电商商品详情 + 库存预占 + 热点防护

为了把前面的原理串起来,我们看一个常见的电商场景。

11.1 业务目标

需求如下:

  • 商品详情页是高频读场景。
  • 大促时某些商品会成为热点。
  • 下单时需要先预占库存,避免超卖。
  • 订单创建是异步流程,失败要回补库存。

11.2 推荐架构

用户请求商品详情
  -> 本地缓存命中则直接返回
  -> 未命中访问 Redis 商品快照
  -> Redis 未命中再回源数据库
  -> 异步重建缓存

用户下单
  -> 网关限流
  -> Redis Lua 原子预占库存 + 去重
  -> 发送下单消息
  -> 订单服务消费消息并落库
  -> 成功则确认预占
  -> 失败则发送补偿消息释放库存

11.3 预占库存与释放补偿的核心设计

Redis 里至少要维护三类状态:

  • stock:sku:1001:当前可预占库存。
  • stock:sku:1001:orders:已成功预占的订单集合,用于幂等。
  • stock:sku:1001:pending:待确认状态或由业务系统在数据库中持久化记录。

而业务层则要维护:

  • 下单请求表或去重表。
  • 订单状态机。
  • 补偿任务或延迟消息。

这说明一个事实:

Redis 可以非常高效地做“瞬时状态裁决”,但完整业务闭环必须由 Redis、数据库、消息系统和补偿机制共同完成。

11.4 为什么这个方案比“直接数据库扣库存”更稳

因为它把整个高峰链路拆成了两个阶段:

  • Redis 在入口做快速、原子、低延迟的筛选和预占。
  • 数据库在后链路做少量、可靠、可审计的最终落地。

这样数据库承压会小很多,系统弹性也更强。

十二、Redis 不是万能解法:这些边界必须提前说明

高质量架构文章,不只讲能力,还必须讲边界。

Redis 不适合独立承担下面这些职责:

  • 强一致金融账本。
  • 需要复杂多表事务保证的核心业务真相源。
  • 长期海量明细数据存储。
  • 没有 TTL、无限增长的大对象明细集合。

如果你的场景更强调:

  • 强一致。
  • 长期持久化。
  • 复杂关系查询。
  • 跨实体事务。

那 Redis 应该是加速层、协调层或状态层,而不是唯一数据底座。

十三、上线前检查清单:Redis 真正要看什么

如果你准备把 Redis 用进高并发核心链路,上线前至少检查下面这些项:

13.1 数据与语义

  • key 命名是否统一、可审计、可扩展。
  • 是否明确区分缓存 key、锁 key、计数 key、幂等 key。
  • 是否定义 TTL 策略和失效抖动策略。
  • 是否明确哪些状态只是缓存,哪些状态承担业务语义。

13.2 高并发链路

  • 热点 key 是否有本地缓存或多级防护。
  • 回源链路是否有单飞或互斥重建机制。
  • Redis 客户端连接池与超时预算是否经过压测。
  • pipeline、Lua、多 key 操作是否验证过在 Cluster 下的行为。

13.3 一致性与补偿

  • 库存、资格、幂等、锁等关键状态是否有异常补偿。
  • Redis 成功但数据库失败时是否有回滚或修复机制。
  • 消息发送失败、消费重复、超时重试时是否仍然正确。

13.4 稳定性与治理

  • 是否有慢日志、命中率、碎片率、复制延迟、fork 耗时监控。
  • 是否有 big key / hot key 的定期扫描。
  • 是否限制了客户端输出缓冲区。
  • 是否做过主从切换、节点摘除、扩容迁移演练。

13.5 变更与演练

  • key 结构升级是否有兼容方案。
  • 淘汰策略、持久化策略变更是否评估过影响。
  • 大促前是否做过容量评估和压测。
  • 故障应急文档是否完备,值班人员是否演练过。

十四、总结:Redis 的强,不在“快”这个字本身,而在快得可控、快得可扩展

回到文章开头的问题,Redis 为什么能扛住百万并发?

答案不是一句“单线程所以快”,而是一整套彼此配合的设计:

  • 用事件循环和非阻塞 I/O 驯服大规模连接。
  • 用紧凑对象编码和专用数据结构榨干内存与 CPU 效率。
  • 用串行化命令执行避免锁竞争和状态混乱。
  • 用 pipeline、批处理、Lua、连接池和本地缓存把网络成本降下来。
  • 用复制、分片、持久化、观测、限流和补偿机制把单机能力升级成生产系统能力。

真正的 Redis 工程实践,从来不是背几个命令,也不是一味强调 QPS,而是理解它的能力边界,然后把它放到最适合的位置上。

如果你把 Redis 当作:

  • 热点状态引擎,
  • 高并发入口裁决器,
  • 多级缓存体系核心节点,
  • 分布式系统里的高频协调组件,

并且愿意为它补齐幂等、补偿、观测、治理和演进能力,那么 Redis 的价值远不止“缓存很快”这么简单。

这才是 Redis 真正值得架构师和高级工程师反复研究的地方。在云栈社区,我们也经常探讨类似的高并发架构设计,欢迎一起交流。

参考阅读方向

  • Redis 事件循环与网络模型
  • Redis 对象系统与底层编码
  • Redis 持久化、复制与 Cluster 路由机制
  • 高并发缓存治理:穿透、击穿、雪崩、热点与大 key
  • 秒杀、排行榜、限流、分布式锁等生产实践专题



上一篇:Coding Agent训练新范式:阿里&上交提出Socratic-SWE自进化框架,轨迹驱动技能生成
下一篇:高并发秒杀系统架构设计:从库存扣减到异步削峰的工程实战
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-6-24 21:47 , Processed in 0.751007 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表