高并发不是加机器那么简单
古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。
《AI时代的程序员修养》前几篇从接口、数据库、缓存、队列讲到异步任务。它们都是高并发系统的一部分。到了这一篇,要把视角抬高一点:一个服务到底怎么扛流量。
AI 很容易把“高并发”回答成几个熟词:加缓存、加机器、用异步、上队列、分库分表。这些词都对,但都不够。真正的高并发不是把每个点都换成更快的实现,而是知道整条调用链哪里会先满,哪里要限流,哪里能降级,哪里必须保护,哪里不能再往下游传压力。
高并发不是加机器那么简单。机器只是容量的一部分,边界才是关键。
先算账,再谈优化
很多性能问题,一开始不是代码问题,而是没有算账。
假设一个接口目标是:
- 峰值 2000 QPS。
- P95 延迟低于 200ms。
- 每个请求查一次用户、一次订单、一次库存。
- 每个请求还要调用一次推荐服务。
如果直接让 AI “优化接口”,它可能建议缓存、异步、索引。更应该先算一遍:
2000 QPS * 每请求 3 次数据库查询 = 6000 次数据库查询/秒
2000 QPS * 每请求 1 次推荐调用 = 2000 次下游调用/秒
再看延迟。如果平均请求耗时 200ms,根据 Little’s Law,系统同时在处理的请求数大约是:
并发数 = 吞吐量 * 平均响应时间
2000 * 0.2 = 400
这还只是入口服务的并发。下游数据库连接、HTTP 连接、线程池、协程数量、队列长度,都要围绕这个量级设计。
如果入口服务部署 8 个实例,每个实例平均 250 QPS。每个实例数据库连接池设 50,看起来不大,但总连接数是:
8 * 50 = 400
如果数据库最大连接数只有 300,服务还没跑满就已经把数据库连接打爆了。
高并发第一步不是写代码,是把 QPS、延迟、并发、连接、下游容量算清楚。
让 AI 做高并发设计时,可以先让它算:
先不要给优化建议。
请基于目标 QPS、P95 延迟、每请求下游调用次数、实例数、连接池大小,估算:
- 入口并发数。
- 每个实例平均 QPS。
- 数据库总连接数。
- 下游服务调用 QPS。
- 哪个资源最可能先成为瓶颈。
不会算容量,谈优化就是凭感觉。
连接池是乘法,不是配置项
连接池经常被当成一个随手调大的配置。
比如:
DB_POOL_SIZE=50
HTTP_POOL_SIZE=100
WORKER_COUNT=8
问题在于,这些数字会乘起来。
如果每个进程有一个数据库连接池,4 台机器,每台 4 个进程,每个进程 50 条连接,总连接数就是:
4 * 4 * 50 = 800
这还不包括后台 worker、管理任务、迁移脚本、临时查询工具。数据库可能根本承受不了。
HTTP 连接池也一样。入口服务调用推荐服务,每个实例开 100 个连接,20 个实例就是 2000 个连接。推荐服务能不能承受?它自己的线程池、连接池和下游又会怎样?
连接池设计要看三件事:
- 上游会产生多少并发。
- 当前服务希望最多放多少请求进入下游。
- 下游真实容量是多少。
连接池不是“越大越不容易卡”。连接池太大,会把压力完整传给下游;连接池太小,会在本服务排队。排在哪里,是设计选择。
更稳的做法是让连接池成为保护下游的闸门:
入口服务每实例最多允许 20 个并发 DB 查询。
超过连接池能力的请求在本地排队,排队超过 50ms 直接失败或降级。
这比无脑调大连接池更可靠。
让 AI 配连接池时,可以要求:
不要只给单实例连接池大小。
请按实例数、进程数、worker 数计算全局连接数。
说明下游数据库或服务的最大可承受连接数。
给出连接等待超时和降级策略。
连接池配置不算全局乘法,就很容易线上事故。
批量能救系统,也能拖垮系统
高并发系统里,逐条处理很贵。批量可以显著降低数据库往返、网络开销和事务开销。
比如写日志或历史记录。逐条写:
for record in records:
await db.insert(record)
批量写:
for batch in chunks(records, 1000):
await db.insert_many(batch)
批量能把 1000 次网络往返变成 1 次。但批量不是越大越好。批太大,单次事务变长,锁时间变长,失败重试成本变高,内存占用也变高。
批量参数通常要从几个维度定:
| 参数 | 影响 |
|---|---|
| batch_size | 吞吐、事务时长、单次失败成本 |
| flush_interval | 延迟和吞吐之间的平衡 |
| queue_size | 内存占用和反压触发点 |
| max_retry | 下游抖动时的恢复能力 |
| timeout | 防止批处理长期卡住 |
一个常见短期缓解方案,是把批量从 100/1s 调到 1000-5000/5s。这会降低数据库写入频率,但会增加数据可见延迟。适不适合,要看这批数据是业务状态还是观测明细。
业务状态不能随便延迟;日志、遥测、历史明细可以接受几秒延迟。
让 AI 优化批处理时,要明确:
请区分业务状态写入和观测明细写入。
业务状态必须可靠、及时落库。
观测明细可以进入内存队列,由后台 task 按 1000-5000 条或 5 秒批量写入。
队列满时低优先级 DEBUG 记录可以采样或丢弃摘要。
批量的本质是用延迟换吞吐。这个交换必须说清楚。
限流是系统的保险丝
高并发系统不能假设所有请求都能被处理。
限流不是产品体验差,而是保护系统最基本的手段。没有限流,突发流量会继续进入服务、占满线程、打满连接、压垮数据库,最后所有用户都不可用。
常见限流维度:
- 全局限流:整个服务最大 QPS。
- 用户限流:单个用户最大请求频率。
- IP 限流:防止异常来源。
- 接口限流:重接口单独控制。
- 下游限流:保护数据库、第三方服务、模型服务。
- 任务限流:每个用户最多 pending/running 多少任务。
一个简单 token bucket 可以这样理解:
class TokenBucket:
def __init__(self, capacity: int, refill_per_second: int):
self.capacity = capacity
self.tokens = capacity
self.refill_per_second = refill_per_second
self.last_refill = time.monotonic()
def allow(self) -> bool:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.refill_per_second,
)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
这是本地版本。多实例需要集中式计数、分片计数或网关限流。精度、性能和一致性之间要取舍。
限流响应也要是契约:
{
"error": {
"code": "RATE_LIMITED",
"message": "请求过于频繁,请稍后再试",
"retryable": true
},
"retry_after_seconds": 3
}
让 AI 写限流时,可以要求:
请给出限流维度:全局、用户、接口、下游。
说明本地限流和分布式限流的取舍。
429 响应必须包含 retry_after_seconds。
限流命中要打指标,避免只在日志里出现。
限流不是代码片段,是服务承诺的一部分。
降级不是失败,是保核心
降级的目标不是“少做点功能”,而是保住核心路径。
比如一个商品详情页包含:
- 商品基本信息。
- 价格和库存。
- 推荐商品。
- 评论摘要。
- 浏览历史。
- 广告位。
高峰期或下游故障时,商品基本信息、价格、库存是核心;推荐、评论摘要、浏览历史、广告可以降级。
降级策略可以很具体:
| 模块 | 降级方式 |
|---|---|
| 推荐商品 | 返回空列表或缓存旧结果 |
| 评论摘要 | 返回旧缓存,标记可能延迟 |
| 浏览历史 | 跳过写入,进入低优队列 |
| 广告位 | 返回默认广告或空 |
| 库存 | 不能降级为假数据,只能短超时失败 |
AI 生成接口时,常常把所有下游调用都放在一个 gather 里,任何一个失败就整体失败。更好的写法是区分核心和非核心:
product = await get_product(product_id)
price = await get_price(product_id)
inventory = await get_inventory(product_id)
recommendations = await best_effort(
get_recommendations(product_id),
fallback=[],
timeout=0.05,
)
best_effort 调用失败不能影响主响应,但必须记录指标。降级不是悄悄吞错误。
让 AI 做降级设计时,可以要求:
请把下游依赖分成核心依赖和可降级依赖。
核心依赖失败时接口失败;可降级依赖失败时返回 fallback。
每个降级都必须记录指标和 trace,不允许静默吞掉。
降级设计的关键,是提前决定什么能丢,什么不能丢。
热点比平均流量危险
平均 QPS 很容易骗人。
全站 2000 QPS 不一定危险;如果其中 1500 QPS 都打到同一个商品、同一个用户、同一个缓存 key、同一行数据库,那就是热点。
热点常见形式:
- 热门商品详情。
- 大 V 用户主页。
- 秒杀库存。
- 单个配置 key。
- 同一个搜索词。
- 同一个租户的大批任务。
热点问题不能只靠扩容,因为热点可能集中在同一个 key 或同一行。
几种常见处理:
| 热点类型 | 处理方式 |
|---|---|
| 热点读 | 多级缓存、本地缓存、提前刷新、随机 TTL |
| 热点写 | 分片计数、异步聚合、队列串行化 |
| 热点库存 | 原子扣减、令牌预占、分段库存 |
| 热点用户 | 用户维度限流、隔离队列 |
| 热点租户 | 租户级配额、独立 worker 池 |
比如点赞计数。每次点赞都直接更新同一行:
update posts
set like_count = like_count + 1
where id = :post_id;
热门文章下,这一行会变成写热点。可以改成分片计数:
create table post_like_counters (
post_id uuid not null,
shard_id integer not null,
count bigint not null,
primary key (post_id, shard_id)
);
写入时按用户或随机 shard 更新:
update post_like_counters
set count = count + 1
where post_id = :post_id
and shard_id = :shard_id;
读取时聚合多个 shard,或者后台定时汇总到主表。
让 AI 设计热点方案时,要明确:
请不要只按平均 QPS 分析。
列出可能的热点 key、热点行、热点用户和热点租户。
针对热点读和热点写分别给出隔离方案。
热点才是高并发里最容易把系统打穿的地方。
雪崩通常是连锁反应
服务雪崩很少是一个点突然坏掉。更多是连锁反应。
一个典型链条:
缓存过期
-> 大量请求打到数据库
-> 数据库变慢
-> 请求线程堆积
-> 连接池耗尽
-> 上游超时重试
-> 流量翻倍
-> 更多服务被拖慢
这里真正危险的是重试和排队。下游已经慢了,上游继续重试,相当于往火上加流量。
防雪崩要有几层保护:
- 缓存 TTL 随机抖动,避免同时过期。
- 热点 key 提前刷新。
- 下游调用设置短超时。
- 重试次数少,并加退避。
- 连接池等待也要有超时。
- 限流和熔断保护下游。
- 非核心依赖可降级。
- 队列长度超过阈值触发反压。
熔断可以理解为:如果某个下游短时间内连续失败,就暂时不再调用它,直接走 fallback 或快速失败,给下游恢复时间。
让 AI 写重试逻辑时,不要只说“失败重试”:
重试只针对临时错误。
最多重试 2 次,使用指数退避和随机抖动。
总耗时不能超过接口 timeout budget。
如果下游连续失败率超过阈值,进入熔断,短时间快速失败或降级。
重试必须有预算。没有预算的重试,就是雪崩放大器。
Timeout budget 要分配
很多接口只设置一个总超时,比如 3 秒。但它内部可能调用多个下游,每个下游又有自己的超时。如果每个下游都设 3 秒,总请求就不可控。
更好的做法是分配 timeout budget。
假设接口目标 P95 200ms,可以这样分:
| 阶段 | 预算 |
|---|---|
| 鉴权和参数校验 | 10ms |
| 主数据库查询 | 50ms |
| 库存服务 | 40ms |
| 推荐服务 | 30ms,可降级 |
| 序列化和返回 | 20ms |
| 预留 | 50ms |
推荐服务如果 30ms 没返回,就降级,而不是等到 200ms 全部耗完。
在代码里要把超时传下去:
async def get_detail(product_id: str, deadline: Deadline):
product = await with_timeout(
get_product(product_id),
deadline.remaining(max_ms=50),
)
recommendations = await best_effort(
get_recommendations(product_id),
timeout_ms=min(30, deadline.remaining_ms()),
fallback=[],
)
return build_response(product, recommendations)
这是示意代码,重点是每个下游都有预算,不允许无限等待。
让 AI 写高并发接口时,可以要求:
请给出 timeout budget。
每个下游调用必须有独立超时。
所有重试总耗时不能超过总预算。
可降级依赖超过预算直接 fallback。
延迟不是最后测出来才管,应该在设计时分配。
容量评估要有压测闭环
高并发设计不能只停在纸面上。必须压测。
压测不是只看 QPS。至少要看:
- P50/P95/P99 延迟。
- 错误率。
- CPU 使用率。
- 内存和 GC。
- 数据库连接数。
- 数据库慢查询。
- Redis 命中率和延迟。
- 队列长度和消费延迟。
- 下游调用耗时。
- 限流、降级、熔断次数。
压测也不能只跑 happy path。要压:
- 正常流量。
- 峰值流量。
- 突刺流量。
- 热点 key。
- 下游慢。
- 缓存失效。
- 部分实例重启。
- 队列堆积。
一个压测结论应该长这样:
目标:2000 QPS,P95 < 200ms,错误率 < 0.1%
结果:
- 1500 QPS 时 P95=130ms,DB CPU=55%
- 2000 QPS 时 P95=210ms,DB 连接等待开始升高
- 热点 key 场景下 Redis 命中率 99%,本地缓存生效
- 推荐服务慢 500ms 时,详情接口降级率 18%,整体 P95=170ms
瓶颈:数据库连接等待和订单查询索引
处理:连接池从 50 降到 30,订单查询增加复合索引,推荐服务 50ms 超时降级
这才是可执行的容量结论。
让 AI 帮你做压测方案时,可以要求:
请给出压测计划,不只写工具命令。
包含目标、场景、流量模型、观测指标、瓶颈判断标准和回滚策略。
工具能打流量,方案决定你能不能读懂结果。
一份给 AI 的高并发提示词
让 AI 设计高并发方案时,可以先用这段:
先不要写代码。
请为这个服务做高并发设计:
1. 给出目标 QPS、P95/P99 延迟、错误率目标和关键业务路径。
2. 估算入口并发数、实例数、每实例 QPS、数据库总连接数、下游调用 QPS。
3. 列出所有连接池、线程池、队列和 worker,并计算全局容量。
4. 分析哪些下游是核心依赖,哪些可以降级。
5. 给出限流维度:全局、用户、接口、下游、任务。
6. 分析热点 key、热点行、热点用户、热点租户,并给出隔离方案。
7. 设计超时、重试、熔断和 timeout budget。
8. 说明缓存失效、下游慢、队列堆积、部分实例重启时如何保护系统。
9. 给出压测场景、指标、瓶颈判断和容量结论格式。
10. 最后再给代码和配置修改建议。
这段 prompt 会迫使 AI 先算容量、画边界、找瓶颈,再写实现。
卷二收个尾
卷二讲的是“把功能做成能扛流量的服务”。接口契约、数据库约束、缓存队列、高并发设计,其实都在讲同一件事:系统不是单个函数,系统是一组有边界的承诺。
写代码前,问自己几句话:
- 目标 QPS 和延迟是多少?
- 入口并发会变成多少下游调用?
- 连接池总数有没有算全局乘法?
- 哪些请求必须成功,哪些可以降级?
- 热点 key 和热点行在哪里?
- 重试会不会放大流量?
- 队列满了谁负责拒绝?
- 下游慢了,压力会停在哪里?
- 压测能不能复现这些场景?
AI 可以写出很快的局部代码,但高并发系统靠的不是局部聪明,而是容量、边界和失败路径都被提前想过。
机器可以加,边界不能省。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。