Redis 从 0 到 1(五):单线程凭什么 10 万 QPS?epoll 拆解 + Go 压测 3 结论
我不会起名字322· 后端 / 算法 / 数据库 技术栈 力扣 Hot100 Go 项目 Redis MySQL文章目录Redis 从 0 到 1五单线程凭什么 10 万 QPSepoll 拆解 Go 压测 3 结论一、先压测后讲理1 并发就有 3 万 QPS二、单线程到底单在哪三、epoll 事件循环一个线程怎么盯住 1 万个连接四、Go 实操压测器收藏点 1五、压测验证的 3 个结论六、命令与配置清单收藏点 2七、总结Redis 从 0 到 1五单线程凭什么 10 万 QPSepoll 拆解 Go 压测 3 结论上一篇《Redis 从 0 到 1四一条 KEYS * 拖垮网关 30 秒——key 规范与 SCAN 的 Go 实现》我们见识了单线程被一条 O(N) 命令堵死的威力链接https://blog.csdn.net/2501_92769340/article/details/167399384 。本篇回答那个自然产生的问题单线程的 Redis 为什么还能这么快一、先压测后讲理1 并发就有 3 万 QPS理论之前先看数字。用 Go 写一个压测器比 redis-benchmark 更贴近真实业务调用方式在同一台机器上打本地 Redis并发 goroutine模式实测 QPSP99 延迟1逐条 GET~32,0000.1ms50逐条 GET~110,0001.2ms500逐条 GET~105,0009.8ms50Pipeline(100)~580,0003.5ms数字为作者本机 Docker 环境实测仅看量级。三个值得注意的点单并发就有 3 万 QPS并发 50 到 500 几乎不涨单核打满了Pipeline 把吞吐放大 5 倍——但 Pipeline 不是并行这点第三节专门讲。二、单线程到底单在哪先纠正一个常见误解Redis 不是整个程序只有一个线程。准确的说法是命令执行是单线程的所有命令排队一条一条执行这就是 KEYS * 能堵死全局的原因也是为什么不需要锁的原因网络 I/O 也是这个主线程做的Redis 6 前accept、读请求、写响应全在一个线程里后台有其他线程BIO 线程做 fsync、大 key 惰性删除UNLINK、AOF 重写收尾Redis 6 还有io-threads可以把网络读写分给多线程但命令执行仍单线程。单线程模型的两大红利没有锁竞争INCR 天然原子不用像多线程程序那样加锁没有上下文切换一个线程顺序执行CPU 缓存友好。但单线程要处理上万个连接的收发靠的就是 epoll。三、epoll 事件循环一个线程怎么盯住 1 万个连接朴素的做法是一个线程轮询 1 万个 socket“有数据吗有数据吗”——O(N) 轮询连接多了全耗在空转上。epoll 的思路是反过来的让内核来通知。Redis 主线程的事件循环简化 ┌─────────────────────────────────────────────┐ │ 1. epoll_wait问内核哪些 fd 就绪了 │ │ —— 没就绪就睡眠不占 CPU │ │ 2. 对就绪的读事件读请求、解析命令 │ │ 3. 执行命令单线程无锁 │ │ 4. 对就绪的写事件把响应写回 socket │ │ 5. 处理定时任务过期键清理等回到 1 │ └─────────────────────────────────────────────┘对应到 Redis 源码就是三个角色aeEventLoop事件循环aeApiPollepoll_wait 封装 读写回调函数。一次 epoll_wait 能拿到一批就绪的 fd主线程挨个处理单个命令又都是微秒级——这就是单线程 内存操作 epoll能到 10 万 QPS 的完整链条快 内存操作纳秒级× 单线程无锁 × epoll 批量就绪通知 × 命令本身 O(1)推论也很直接任何一个环节引入慢整体就崩——大 key 全量读O(N) 命令、AOF fsync 卡在磁盘上、fork 子进程时页表拷贝都会让 10 万 QPS 瞬间塌掉。第 21/29 篇的排查全都围绕这条链。四、Go 实操压测器收藏点 1packagemainimport(contextflagfmtsortsyncsync/atomictimeredis-0to1/redisx)funcmain(){concurrency:flag.Int(c,50,并发数)duration:flag.Int(d,10,压测秒数)pipeline:flag.Int(P,1,Pipeline 批量大小1逐条)flag.Parse()rdb,err:redisx.NewClient(redisx.Config{Addr:127.0.0.1:6379,Password:redis123,PoolSize:*concurrency10,MinIdleConns:10,// 池必须 ≥ 并发否则在测池而不是测 Redis})iferr!nil{panic(err)}deferrdb.Close()ctx:context.Background()rdb.Set(ctx,bench:key,v,0)// 预热 keyvarops atomic.Int64varmu sync.Mutex latencies:make([]time.Duration,0,120)deadline:time.Now().Add(time.Duration(*duration)*time.Second)varwg sync.WaitGroupfori:0;i*concurrency;i{wg.Add(1)gofunc(){deferwg.Done()fortime.Now().Before(deadline){start:time.Now()varerrerrorif*pipeline1{_,errrdb.Get(ctx,bench:key).Result()}else{pipe:rdb.Pipeline()forj:0;j*pipeline;j{pipe.Get(ctx,bench:key)}_,errpipe.Exec(ctx)}iferrnil{ops.Add(int64(*pipeline))mu.Lock()latenciesappend(latencies,time.Since(start))mu.Unlock()}}}()}wg.Wait()sort.Slice(latencies,func(i,jint)bool{returnlatencies[i]latencies[j]})p99:latencies[len(latencies)*99/100]fmt.Printf(并发%d Pipeline%d QPS%d P99%v\n,*concurrency,*pipeline,ops.Load()/int64(*duration),p99)}go run bench.go-c1-d10# 单并发基线go run bench.go-c50-d10# 常规并发go run bench.go-c50-d10-P100# Pipeline 模式五、压测验证的 3 个结论#结论压测证据工程含义1单命令快是吞吐的根1 并发就 3 万 QPS保持命令 O(1)拒绝大 key2并发到顶后加并发没用50 → 500 并发 QPS 不涨、P99 涨 8 倍单实例有天花板要扩就上 Cluster第 10 篇3Pipeline 不是并行Pipeline QPS×5 但 P99 也上升它是省 RTT 的批量打包100 条打包意味着最慢的那条决定整批延迟且执行仍单线程大包会挤占其他客户端六、命令与配置清单收藏点 2命令/配置作用redis-benchmark -c 50 -n 100000 -P 16官方压测-P 是 pipelineINFO statsinstantaneous_ops_per_sec实时 QPSINFO cpu主线程 CPU 占用io-threads 4io-threads-do-reads yesRedis 6 网络读写多线程命令执行仍单线程SLOWLOG GET找拖慢单线程的命令第 21 篇七、总结Redis 快 内存操作 单线程无锁 epoll 就绪通知 命令微秒级四者缺一不可单线程指的是命令执行后台线程和 io-threads 是另一回事单并发 3 万、50 并发约 11 万 QPS 是常见量级再往上请走 ClusterPipeline 省的是 RTT不是并行计算大包会抬高所有人的延迟。下一篇预告QPS 解决了但数据只进不出内存迟早写满——写满那一刻 Redis 会做什么下一篇《内存写满那一刻发生了什么8 种淘汰策略对比 Go 实验复现 2 种删除时机》我们把 maxmemory 配小亲手把实例写满看看。