做了几年后端,被问得最多的一个词就是“高并发”。很多人觉得高并发是那些大厂才需要考虑的事,自己做的项目量级上去了再说。但恰恰相反,高并发的思维不是等流量来了才有的,而是从一个小项目里练出来的。我两年前就在本机搞过一个练手项目,名字就叫 high-concurrency-article,核心是做“文章内容”这种典型读多写少场景的并发支撑。文章详情页看着简单,但把缓存分层、热点治理、异步写入、限流降级这几个点都揉进去以后,压力测试数据从最开始的几百 QPS 一路提到接近五位数,整个过程踩的坑一点不比真实业务少。
这篇文章就把这个项目的完整复盘写出来:为什么要拿文章场景练高并发、整体架构怎么搭、核心链路怎么实现、压测数据怎么调、以及那些只会在生产环境炸出来的幺蛾子。如果你也想找个自带真实流量特征的项目来验证自己对高并发的理解,这篇文章可以直接当参考模板用。
1. 先把 high-concurrency-article 的定位讲清楚
1.1 标题拆开看:这其实是一个“场景题”
high-concurrency-article 从字面上拆,就是 high-concurrency + article,一个“高并发下的文章系统”。文章系统这个词很泛,但落到具体业务就非常清晰——文章的发布、列表、详情、点赞、评论、收藏,这些动作组合起来,就是一个完整的内容社区后端原型。
真正有价值的不是“文章”两个字,而是“高并发”三个字。不同业务的并发特征完全不一样,比如秒杀是瞬间写多、读也多,转账是强一致性要求极高,而文章系统是典型的读多写少。一个热点事件出来以后,一篇稿子可能在几十秒内被几万人同时打开,这些请求几乎全都是读。所以这个项目的本质是:如何用尽可能低的成本,支撑住突发性的海量读请求,同时在写路径不做无谓的复杂度堆叠。
1.2 我给自己定的目标和使用场景
当时给这个项目定的目标很具体:
- 在一台 2 核 4G 的普通云主机上,模拟单篇热点文章的读流量,稳定支撑 8000 以上的 QPS。
- 文章详情接口的 P99 延迟不超过 200ms,最好控制在 100ms 以内。
- 点赞、评论这类写入动作不能被高并发读拖死,也不能反过来把数据库打崩。
- 整个系统能模拟出缓存穿透、击穿、雪崩等经典故障,并能通过代码层面的兜底策略自动恢复。
这套指标未必适合所有项目,但对练手来说足够有挑战。如果连单机的极限都摸不到,就直接上微服务、分布式那一套概念,其实是本末倒置的。先在某云厂商一台最普通的机器上把单机性能榨干,再想怎么横向扩展,这个顺序最务实。
1.3 主动砍掉的“伪需求”
架构设计里最重要的不是加东西,而是砍东西。这个项目我一开始也想学大厂那样搞分布式缓存集群、跨机房容灾、分布式事务,后来全部砍掉了。
原因很简单:做技术选型要看收益和成本。单机房、单实例、没有全局强一致要求的前提下,引入分布式协调组件只会让排查问题变得非常痛苦。真正的设计原则是:先把访问链路缩短到极致的短,再用异步把写链路和读链路解耦,最后才考虑集群。这个项目到最后依然是单机部署,只是在进程内部做了非常细的分层和降级处理。很多高并发问题,到进程边界就已经解决了一大半。
2. 为什么文章场景最适合拿来练高并发
2.1 读多写少的流量模型是天然的减压阀
不夸张地说,内容业务是中文互联网里最典型的读放大场景。用户刷文章列表,一次请求可能只返回 20 条摘要,但这 20 条摘要对应的文章详情、作者信息、点赞数,每一个都是独立的资源。一个列表页背后通常要查好几张表,如果每次都打到数据库,数据库自己就垮了。
所以项目一开始我就做了请求路径的梳理:文章详情接口实际上只需要三种数据——文章主体内容(HTML 或 Markdown 源码)、文章的元信息(标题、分类、发布时间)、文章的互动数据(阅读数、点赞数、评论数)。三种数据天然适合放进缓存,而且缓存粒度可以拆得很细。某个数据失效了,其他数据还能继续命中,不会因为整条缓存过期导致全部回源。
2.2 热点效应:单 key 压力比全量流量更可怕
文章场景另一个独特之处在于热点非常集中。一个社区平台平时可能是几百 QPS,一旦某个内容上了热搜,两三秒内流量冲到几千 QPS,而且这些流量高度集中在那么一两个 key 上面。
这就是缓存击穿最容易爆发的场景:缓存刚好过期,同一个瞬间来了几万个请求,全部穿透到数据库。数据库被这一下就打懵了。所以热点文章的处理必须单独做,不能和普通文章共用一套缓存过期策略。我在项目里专门写了热点识别模块,对访问频率超过阈值的文章 key 做标记,给它们设置不同的过期策略,甚至直接在进程内维护一个热点本地缓存。
2.3 高并发的本质是分层抗流量,而不是硬扛
真正理解高并发以后会发现,核心思想就八个字:分层挡住,逐级兜底。请求打到 Nginx,先由 Nginx 层的缓存挡一部分;挡不住的进到应用进程内的本地缓存,这里再挡一部分;还剩下的才去查 Redis;Redis 也没有,才落库。每一层都只承担“漏过上一层的流量”,而不是全部流量。
文章场景因为数据结构简单、读取频繁,特别适合验证这套分层思路。普通业务的缓存命中率可能只有 80%,但文章详情场景的缓存命中率可以做得很高。层数越多,每一层承受的压力就越小,这就是高并发架构的乐趣所在。
3. 整体架构:我是怎么用单机方案做分层的
3.1 技术选型:便宜、简单、够用
技术栈非常克制,全是开源组件,没上任何服务网格或容器编排:
- 接入层:Nginx,直接承担 TLS 终止、静态资源访问和一部分路由转发。
- 应用层:Go,处理动态接口。
- 缓存层:Redis,主要存文章热数据、限流计数器和分布式锁。
- 存储层:MySQL,存文章、评论、点赞等全量数据。
- 异步层:本地内存队列 + 一个轻量级消息队列,做写操作削峰。
选 Go 不是因为语言本身多高级,而是它处理高并发连接时非常省心,goroutine 可以轻松撑起上万并发连接,配合 GMP 调度器,写并发代码的心理负担比写回调式异步小很多。选 Redis 是因为它的性能损耗极低,单机就能跑十万级 QPS,对这个小项目来说完全够用。消息队列选轻量级的,生产环境可以换成更常见的开源消息队列,核心逻辑不变。
3.2 读路径:先把最长的一跳省掉
文章详情的读请求链路是:
浏览器/客户端 → Nginx → Go 服务 → 本地缓存 → Redis → MySQL
这个链路里每一跳都是有代价的。Nginx 到 Go 服务这跳,走的是本机回环网络,延迟大约 0.1ms 量级;Go 服务到 Redis,走 TCP,大约 0.2ms 到 0.5ms;Redis 到 MySQL 是网络请求,延迟直接跳到一个数量级以上。
所以在实现上,读请求的第一步不是查 Redis,而是先查进程内本地缓存。本地缓存的 TTL 很短,通常 30 到 60 秒,热点文章可以更短。这个本地缓存和 Redis 缓存是两层互相独立的,本地缓存没命中再去查 Redis;Redis 也没命中,才触发回源逻辑,从 MySQL 捞数据,然后回填两级缓存。
3.3 写路径:所有写操作都先进队列,再落库
文章的发布、点赞、评论这些写入操作,第一反应是直接写库,但高并发下这么干会立刻把数据库打爆。我的做法是:写请求先进一个异步队列,由消费端慢慢消化,落库后再更新缓存。
这里的精髓是“削峰填谷”。用户看到的反馈是“发布成功”,但这个成功并不代表数据已经落到 MySQL,而是代表请求已经进入待处理队列。对文章系统来说,从发布到被搜索到、被推荐到,用户本来就有几秒钟的心理预期,这个异步延迟完全被接受。点赞、评论更不用说,互动数据晚几十毫秒展示出来没人感知得到。
3.4 接口设计与数据模型
接口设计走的是最简单直白的一版:
- GET /article/{id}:文章详情
- GET /article/list?page=1&size=20:文章列表
- POST /article:发布文章
- POST /article/{id}/like:点赞
- POST /article/{id}/comment:评论
MySQL 表结构也很常规,核心就三张表:文章主表、评论表、互动计数表。文章主表存标题、摘要、正文内容、状态、发布时间;互动计数表存文章 ID、阅读数、点赞数、评论数。数据模型不是重点,重点是缓存里怎么组织这些数据。
缓存 key 的设计我遵循了一个规则:按数据类型拆 key,而不是按整个接口响应来缓存。文章主体一个 key,互动计数一个 key,评论列表一个 key。好处是文章正文更新时,互动计数缓存完全不受影响;某篇文章被大量读取导致计数 key 失效时,也不会影响正文内容的缓存命中。
4. 核心实现:缓存、并发控制和异步写
4.1 文章详情接口的缓存读取逻辑
详情接口是整个项目的主路径,代码写得好不好,直接决定最终能扛多少 QPS。我贴一段核心读取逻辑的伪代码,只保留最必要的部分:
func GetArticleDetail(ctx context.Context, articleID int64) (*ArticleDetail, error) { // 第一层:进程内本地缓存 if data, ok := localCache.Get(articleID); ok { return data.(*ArticleDetail), nil } // 第二层:Redis 缓存 cacheKey := buildArticleKey(articleID) if data, ok := redisCache.Get(ctx, cacheKey).Result(); ok { // 回填本地缓存 localCache.Set(articleID, data, 30*time.Second) return data, nil } // 第三层:缓存未命中,回源数据库;用 singleflight 防止击穿 detail, err := singleFlight.Do(articleID, func() (interface{}, error) { article := loadFromDB(articleID) if article == nil { return nil, ErrArticleNotFound } redisCache.Set(ctx, cacheKey, article, 300*time.Second) localCache.Set(articleID, article, 30*time.Second) return article, nil }) if err != nil { return nil, err } return detail.(*ArticleDetail), nil }这段逻辑的核心不是“查缓存”,而是 singleflight 的合并回源机制。当热点文章的缓存刚好失效,同一时间冲过来 1000 个请求,如果每个请求都自己去查库,数据库直接被打穿;但加上 singleflight 以后,这 1000 个请求里只有 1 个会真正执行数据库查询,其他 999 个会等待这个查询结果,然后共享同一个返回值。
注意:singleflight 只解决了“同一个 key 的并发回源”问题。如果是大量不同 key 同时失效,那就是缓存雪崩,需要用随机过期时间和主动更新来规避,这一点后面专门讲。
4.2 热点文章的本地缓存与更新策略
本地缓存选的是进程内 LRU 结构,容量控制在几千条。热点文章单独标记,标记规则很简单:每分钟对文章 ID 的请求计数,超过阈值就加入热点集合;热点集合里的文章本地缓存 TTL 缩短到 5 到 10 秒,确保数据足够新鲜。
本地缓存不能做到强一致,这是必然的。高并发下本地缓存的一致性由“短 TTL + 主动失效”共同保证。文章编辑修改正文时,通过内部消息通知各实例删除本地缓存。单机版没有多实例的问题,但我也人为模拟了多个实例的通信,因为这套逻辑是通用的。
这里有个容易被忽略的细节:本地缓存里的数据不用存完整 HTML 大字段,可以只存文章的元信息和访问计数。因为本地缓存不是用来替代 Redis 的,它的目的是把那些“重复到离谱”的请求拦截在最前面。正文这种大字段放到 Redis 里,本地缓存只放轻量元数据,内存占用和序列化耗时都更可控。
4.3 异步写入:队列消费与幂等
写路径的异步化,核心代码是生产者-消费者模式。以点赞为例,用户请求是先写入“点赞事件队列”,然后返回成功。消费端从队列里取事件,去更新 MySQL 里的计数表,同时删除计数缓存。
队列用本地内存 + 消息队列两段式:本地内存队列承担主要的削峰作用,消息队列负责可靠投递。这个设计能保证进程重启时,尚未消费的本地消息会重新塞回消息队列,避免丢数据。
消费端最关键的是幂等。消息队列投递是 at least once 语义,同一个点赞事件可能被消费两次,如果不做幂等,计数就会重复累加。我的做法是在消费表里加一个业务唯一键,比如 user_id + article_id + action_type,执行前先插入去重表,插入成功才更新计数,重复消息直接跳过。
4.4 限流和降级:把故障控制在最小的爆炸半径内
只做缓存不做限流,系统照样会被突发流量冲垮。我在 Nginx 层和应用层都加了限流。应用层用的是令牌桶算法,对单机维度做总 QPS 限制;对热点文章 key 单独做限流,防止某个 key 的流量把整个服务拖垮。
降级策略分两层:第一层,查 Redis 失败时不直接报错,而是降级到“过期本地缓存仍可读取”,把之前缓存的旧数据返回给用户;第二层,如果热点数据回源数据库特别慢,就返回一个简化的详情壳,标题和摘要照常展示,正文区显示“内容加载中”,让用户感知不到系统已经进入了降级状态。
降级的核心原则是:主链路不可用了,至少要有兜底数据;兜底数据也没有,宁可牺牲部分用户的部分体验,也不能让请求全部超时。
5. 压测结果与调优过程
5.1 压测环境与方法
压测工具用的 wrk,压测机与服务器都在同一内网,服务器配置为 2 核 4G,操作系统为 Linux,数据库在同一台机器上运行,避免网络成为瓶颈。压测场景是模拟 1000 个并发连接,持续压 5 分钟,统计 QPS、平均延迟、P99 延迟。
压测命令大致是这样的:
wrk -t8 -c1000 -d300s http://127.0.0.1:8080/article/1234568 个线程,1000 个连接,跑 300 秒。第一次跑的时候,结果惨不忍睹,平均响应时间到了 200ms,QPS 只有 800 左右,CPU 直接打满。
5.2 从裸接口到分层缓存的数据变化
我把调优过程分成了四个阶段,每个阶段记录一组数据,对比很直观:
| 阶段 | 缓存策略 | QPS | P99 延迟 | 数据库压力 |
|---|---|---|---|---|
| 第一阶段 | 直接查数据库 | 480 | 620ms | 全量请求打库 |
| 第二阶段 | 加 Redis 缓存 | 3200 | 180ms | 仅缓存未命中回源 |
| 第三阶段 | Redis + 本地缓存 | 6100 | 95ms | 回源量显著下降 |
| 第四阶段 | 全链路 + singleflight | 8600 | 72ms | 回源量几乎可忽略 |
第二阶段到第三阶段的提升是最明显的。原因并不复杂,本机回环网络访问 Redis 虽然在个位数毫秒以内,但开启 1000 个并发连接以后,Redis 单实例的 CPU 和网络协议栈开销是实打实的。本地缓存把超过一半的请求拦在了进程内部,连网络都省了,QPS 自然翻倍。
5.3 调优过程中发现的关键瓶颈
第一轮调优发现 CPU 主频被压测跑满,perf 看热点,发现不是业务代码的问题,而是 JSON 序列化和网络协议解析占了大头。后来把详情接口的响应结构体做了精简,减少不必要的字段,序列化耗时降了差不多一半。
第二轮是在并发拉满时,Go 进程的 GC 时间明显增长,原因是详情对象里有大字符串字段,频繁创建导致堆内存压力过大。调整方案有两个:一是把大字段单独缓存,不进本地内存;二是把 GC 参数调成更激进的 CPU 阈值。内存占用上升了一些,但 GC 暂停时间降下来了,P99 延迟立刻回落。
第三轮是连接池。压测时 Go 服务往 MySQL 发的查询连接一度把 MySQL 的 max_connections 打满,后来把 database/sql 的连接池上限从默认值调小,回源动作都走了 singleflight,并发连接数自然就下来了。
6. 实战中踩过的坑与排查技巧
6.1 缓存雪崩:一次 TTL 设置引发的连锁反应
有次做压测,我把所有文章缓存都设置了 600 秒过期。因为压测的热点文章较多,很多 key 在同一时刻过期,一瞬间大量请求同时回源,MySQL 直接变成省道慢车,接口超时率飙到 30%,Redis 连接堆积,CPU 冲到 90% 以上。
排查过程并不复杂:看监控面板,Redis 的请求命中率在某个时间段出现陡降,MySQL 的慢查询数量同时暴涨,时间点完全重合,基本确认是缓存同时失效导致的雪崩。修复很快:缓存过期时间加随机偏移量,比如 base 600 秒再加 0 到 60 秒的随机值;另外增加后台定时任务,对热门 key 做提前刷新,而不是等过期了再回源。
6.2 消息重复消费导致计数错乱
压测点赞接口时,出现过一次严重的计数错乱:同一条点赞事件被消费了 3 次,阅读数和点赞数多出一大截。排查后发现是消费者处理完消息后,还没来得及更新消费位点就挂了,重启后才从旧位点继续消费。
之前说的唯一键幂等机制就是从这个坑里长出来的。有了幂等表以后,哪怕消费者重启一百次,重复事件也不会影响计数。这个经验让我养成了一个习惯:凡是异步处理写操作,第一件事就是设计幂等,而不是先想着性能。
6.3 连接池耗尽:写路径把读路径拖下水
有一次压测模拟了同时爆发写流量和读流量。评论接口的异步队列消费不过来,消费端 MySQL 连接迟迟不释放,连接池被写操作耗尽,读接口到了 Redis 以后需要回源时,发现已经拿不到数据库连接了,导致正常的读请求也大面积失败。
这个问题的根子是资源隔离没做好。修复方式是把读和写的数据库连接池拆开,给写操作单独分配连接池,上限设小一点,读连接池保持充足的余量。应用层也加了超时控制:拿不到数据库连接超过 200ms 直接返回兜底数据,绝不无限等待。
6.4 日志和链路追踪:没有 traceId 的排查都是瞎忙
高并发下排查问题,最怕的就是“知道出了问题,但不知道是哪个环节出了问题”。项目一开始我只在业务日志里打印一些普通信息,结果线上报了个慢请求,看日志根本不知道慢在哪,因为日志只记录了最终耗时,中间调用了缓存还是数据库、走了哪一段链路,完全没有标记。
后面我在入口处统一生成 traceId,经过每一层中间件时都追加一条带有 traceId 的日志,这样一次请求的完整路径就能串起来。排查慢请求时直接按 traceId 搜索,立刻能看到耗时是花在 Redis、MySQL 还是本地缓存回源上。这一条我认为对任何高并发项目都是刚需,越早接入越省钱。
6.5 一份值得收藏的避坑速查表
| 故障现象 | 可能原因 | 快速排查方法 | 兜底措施 |
|---|---|---|---|
| 瞬间大量超时 | 缓存雪崩 | 看 Redis 命中率陡降时间点 | 过期时间加随机偏移 |
| 某个文章接口极慢 | 缓存击穿 | 看回源数据库的并发数 | singleflight 合并回源 |
| 数据库 CPU 拉满 | 缓存穿透 | 看是否存在数据库查不到的空 key | 布隆过滤器拦截不存在的 ID |
| 计数异常偏大 | 消息重复消费 | 查幂等表是否有重复记录 | 消费前先插入唯一键 |
| 缓存命中率很高但延迟仍然大 | 本地缓存缺失 | 看是否走了网络请求 | 加热点文章本地缓存 |
7. 如果从头再做一次,我会这么升级
7.1 热点探测从静态规则升级为动态识别
现在项目的热点识别还是基于固定阈值,比如每分钟请求超过 500 次就认为是热点。但真实流量不是平的,热点可能在几十秒内快速崛起,也可能在一分钟内沉寂。更好的方案是引入滑动窗口计数器,加一个实时计算模块,把最近的访问频率、访问量增长率、消息趋势都作为特征,动态调整热点名单。
另外热点文章可以单独预取到本地缓存中,甚至提前放到接口层缓存里,让请求到达应用层之前就被拦截掉。这个思路是从高频抓取场景里借鉴来的,代价是提前占用内存,收益是分摊掉更大比例的流量。
7.2 给想练高并发的人一个建议:先单机,再分布式
我见过太多人一上来就聊微服务、聊容器编排、聊服务发现,却连一个简单的接口都扛不住压测。如果你的目标是理解高并发,我建议先把自己的单机能力榨干:
- 先跑裸接口,记录极限 QPS。
- 加一层缓存,看 QPS 变化。
- 加本地缓存和 singleflight,看衰减曲线。
- 引入异步队列,看写路径的削峰效果。
- 把限流、降级、熔断全部加上,看系统的韧性。
这一套下来,你再去读分布式相关的技术,会看得懂背后的动机。很多分布式方案呈现出来的复杂度,本质上是多机后带来的一致性、通信和运维问题,而不是并发本身变大了。
7.3 高并发文章系统还能扩展什么
内容场景天然适合继续扩展的方向很多:全文搜索、内容推荐、评论社区、多租户内容管理,每一个都是独立的大课题。单从“并发”角度看,后续可以接更完善的服务发现,把无状态的应用节点横向扩到多台;可以把文章详情页的静态部分全部下沉到 CDN,动态部分通过接口异步加载;可以把计数模块用更契合高并发的存储来承担,减少对关系型数据库的依赖。
我个人在实际操作中的体会是:架构这个东西,没有一步到位的方案,只有一次次被流量逼出来的调整。high-concurrency-article 这个项目最后并没有真正上线,它对我的价值在于:它让我在一台普通机器上把分层缓存、异步削峰、限流降级这些词都变成过实际代码,知道它们各自能扛住多少压力、在什么条件下会失效。等你哪天真的遇到线上流量暴涨,你会发现,能救你的不是哪篇技术文章,而是你脑子里已经跑过无数遍的那条链路。