高并发系统设计:从理论到实践
作者:一个刚拿 offer、等入职的 CS 大四狗,坐标北京,通勤 1h,早起干活型选手
上周五晚上九点半,我还在公司盯着 Grafana 面板发呆。这不是因为我卷——好吧,可能有点卷——而是因为明天就要上线一个新功能,而我们这破系统在压测阶段直接崩了。QPS 一过 5k,数据库 CPU 直接飙到 100%,Redis 连接池耗尽,K8s Pod 被 OOMKilled 到怀疑人生。
那一刻,我真的想砸电脑。
但转念一想,不行,我可是那个靠“高并发”这个关键词骗到了大厂 offer 的人(别问,问就是面经刷得多)。于是深吸一口气,泡了杯速溶咖啡(是的,打工人已经穷到喝不起瑞幸了),开始排查问题。
这篇文章,就当是我对这段“血泪史”的复盘,也顺便给正在准备面试或者刚入行的兄弟们一点参考。毕竟,高并发不是玄学,但也不是背几个八股文就能搞定的玩意儿。
为啥突然要搞高并发?
事情得从去年秋招说起。当时投简历,发现几乎所有后端岗位 JD 都写着:“熟悉高并发、高可用系统设计”。我心想:不就是加缓存、削峰、分库分表吗?结果第一次模拟面试就被 HR 问懵了:
“假设你要设计一个秒杀系统,如何保证库存不超卖?用 Redis 分布式锁还是数据库乐观锁?如果 Redis 挂了怎么办?”
我当场愣住,支支吾吾说了句“用 Redis”,然后被挂了。
痛定思痛,我开始恶补高并发知识。后来有幸进了一家做区块链基础设施的 startup 实习(对,就是那种天天喊“Web3 改变世界”的公司),负责一个链上事件监听服务。这玩意儿听着高大上,其实就是个 Kafka Consumer + Java Spring Boot 应用,但 QPS 动不动就几万——因为以太坊主网每秒出 15 个区块,每个区块可能包含上千笔交易。
老板拍着我肩膀说:“小张啊,你这个服务要是崩了,我们的数据同步就断了,客户投诉邮件能把你邮箱塞爆。”
压力山大。
理论很丰满,现实很骨感
在学校里,高并发 = “多线程 + 缓存 + 消息队列”。但在生产环境,光有理论等于没穿裤子上战场。
我们最初的架构长这样:
- Spring Boot 单体应用
- MySQL 主从
- Redis 做缓存
- Nginx 做负载均衡(其实就两台机器)
结果上线第一天,就被 ETH 主网上的一次 NFT 空投活动干趴了。原因?数据库写入瓶颈 + 热点 Key 击穿。
痛点一:热点库存更新
我们有个接口叫 processTransaction(tx),里面会更新某个 token 的持有者列表。一开始用了最 naive 的方式:
@Transactional
public void updateHolder(String tokenId, String address) {
TokenHolder holder = tokenHolderRepo.findByTokenIdAndAddress(tokenId, address);
if (holder == null) {
holder = new TokenHolder(tokenId, address);
}
holder.setLastSeen(System.currentTimeMillis());
tokenHolderRepo.save(holder); // 啊!这里每次都是全量更新
}
问题在哪?同一个热门 NFT(比如 CryptoPunk #7804),可能一秒内被几百个交易引用。MySQL 的 InnoDB 行锁直接变成“伪表锁”,CPU 打满,其他请求全排队。
痛点二:缓存雪崩 + 击穿
我们用 Redis 缓存 token 元数据,TTL 统一设成 5 分钟。结果某天运维手滑重启了 Redis,所有缓存同时失效,瞬间几千个请求打到 DB,DB 直接熔断。
更惨的是,某个冷门 token 突然火了(比如某名人买了),大量请求同时查这个 key,而它刚好不在缓存里——缓存击穿,又是一波 DB 冲击。
实战:怎么一步步扛住 10k+ QPS
第一步:异步化 + 批处理
既然同步写 DB 是瓶颈,那就把写操作异步化。我们引入了 Disruptor(比 Kafka 更轻量,适合单机高吞吐)做本地队列,再配合批量插入。
// 使用 RingBuffer 异步收集写请求
private final RingBuffer<TokenWriteEvent> ringBuffer =
disruptor.getRingBuffer();
public void enqueueWrite(String tokenId, String address) {
long sequence = ringBuffer.next();
try {
TokenWriteEvent event = ringBuffer.get(sequence);
event.setTokenId(tokenId);
event.setAddress(address);
} finally {
ringBuffer.publish(sequence);
}
}
// 消费者批量写入
public void onEvent(TokenWriteEvent[] events, ...) {
List<TokenHolder> batch = convert(events);
tokenHolderRepo.batchUpdate(batch); // MyBatis 批量 UPDATE
}
效果立竿见影:DB 写 QPS 从 8k 降到 200,CPU 使用率从 95% 降到 30%。
第二步:缓存策略升级
为了解决雪崩和击穿,我们做了三件事:
- 随机 TTL:缓存过期时间 = 基础时间 + random(0, 300s)
- 互斥重建:用 Redis 的
SET key value NX EX 10实现分布式锁,只有一个线程去查 DB - 本地缓存兜底:Caffeine 做 L1 缓存,即使 Redis 挂了,也能撑几分钟
public TokenMetadata getMetadata(String tokenId) {
// 先查本地缓存(Caffeine)
TokenMetadata local = localCache.getIfPresent(tokenId);
if (local != null) return local;
// 再查 Redis
String json = redisTemplate.opsForValue().get("token:" + tokenId);
if (json != null) {
TokenMetadata meta = parse(json);
localCache.put(tokenId, meta); // 回填本地缓存
return meta;
}
// 双重检查 + 分布式锁重建
return buildWithLock(tokenId);
}
第三步:读写分离 + 分库分表(谨慎!)
一开始我们以为要分库分表,但 DBA 说:“先看看是不是索引没建好。” 果然,token_id + address 联合索引漏了。
加上索引后,查询快了 10 倍。很多时候,你不需要分库分表,你只需要一个正确的索引。
不过对于写入,我们还是按 token_id 哈希分了 16 个库,用 ShardingSphere 中间件透明路由。但说实话,分库分表是最后一招,维护成本太高,能不分就不分。
区块链场景下的特殊挑战
你可能会问:这跟区块链有啥关系?
还真有。区块链数据是 append-only 的,但业务逻辑往往需要“最新状态”。比如我们要实时展示某个地址的 NFT 持仓,就得把链上所有 Transfer 事件按时间排序、合并。
这就引出了两个问题:
- 事件乱序:以太坊叔块(uncle block)可能导致事件顺序错乱
- 状态重建成本高:从创世块重放?别闹了
我们的解法是:用版本号 + 幂等更新。
// 每个事件带 blockNumber 和 logIndex
public void processTransferEvent(TransferEvent event) {
long version = event.getBlockNumber() * 100000 + event.getLogIndex();
// 只处理比当前版本新的事件
if (version > currentVersion.get(tokenId)) {
updateHolderSafely(event, version);
currentVersion.put(tokenId, version);
}
}
这样即使事件重放或乱序,也能保证最终一致性。区块链 + 高并发,本质上是在解决“不可变数据源”与“可变业务状态”之间的矛盾。
Java 层面的性能陷阱
别以为用了 Spring Boot 就万事大吉。我们在压测时发现,GC 停顿经常超过 500ms,原因是大量临时对象(比如 JSON 解析产生的中间对象)。
解决方案:
- 对象复用:用 ThreadLocal 存 ObjectMapper
- 避免自动装箱:
Map<Long, Holder>比Map<String, Holder>快 - G1 GC 调优:设置
-XX:MaxGCPauseMillis=200
还有一次,我们用了 CompletableFuture 做并行查询,结果线程池没配置,默认 ForkJoinPool 被打爆,CPU 100%。后来改成自定义线程池:
private static final ExecutorService queryPool =
new ThreadPoolExecutor(
10, 50, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("query-pool-%d").build()
);
Java 不是银弹,不当使用反而会成为性能黑洞。
面试题挑战:你能答对几道?
现在回看那些面试题,其实都有迹可循:
| 面试题 | 我们的实战答案 |
|---|---|
| 如何防止超卖? | 异步队列 + 批量更新 + 版本号幂等 |
| Redis 挂了怎么办? | 本地缓存 Caffeine + 降级策略 |
| 数据库瓶颈怎么破? | 先查索引,再考虑读写分离,最后才分库 |
| 如何应对突发流量? | 限流(Sentinel)+ 自动扩缩容(K8s HPA) |
面试官要的不是标准答案,而是你踩坑后的思考路径。
K8s + 云原生:运维的救星
作为一枚 K8s 熟手(自封的),这次事故后我们立刻做了几件事:
- HPA(Horizontal Pod Autoscaler):基于 CPU 和自定义指标(如队列长度)自动扩缩容
- Pod Disruption Budget:保证至少 2 个实例在线,避免滚动更新时服务中断
- Sidecar 注入:用 Envoy 做服务网格,统一处理限流、熔断
# HPA 配置示例
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: tx-processor
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Pods
pods:
metric:
name: queue_length
target:
type: AverageValue
averageValue: "100"
云原生不是 buzzword,而是真能救命的工具集。
总结:高并发 = 工程 + 艺术
折腾了一个月,系统终于能稳稳扛住 12k QPS,P99 延迟 < 200ms。虽然过程痛苦,但收获巨大。
几点心得送给大家:
- 不要过度设计:先监控,再优化。90% 的问题靠索引和缓存就能解决。
- 缓存是双刃剑:用不好比不用还糟,一定要考虑失效、穿透、雪崩。
- 异步是高并发的基石:但要注意最终一致性。
- 面试题来源于实践:多参与真实项目,比刷 100 道题有用。
- 保持敬畏心:线上系统永远比你想象的脆弱。
现在我已经拿到 offer,下个月正式入职。虽然还是个菜鸟,但至少下次再被问“怎么设计高并发系统”,我能笑着说出:“兄弟,我刚从火线上下来,来,我给你讲个故事……”
高并发的路上,没有银弹,只有不断试错和迭代。共勉。
P.S. 如果你在看这篇文章时正在加班,请记住:你不是一个人在战斗。产品经理可能在改需求,测试可能在提 bug,运维可能在骂你,但至少,你的代码还在跑。 😅

评论 0