高并发系统设计:从理论到实践

@许建华
2025-12-13 20:15
阅读 490

作者:一个刚拿 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%

第二步:缓存策略升级

为了解决雪崩和击穿,我们做了三件事:

  1. 随机 TTL:缓存过期时间 = 基础时间 + random(0, 300s)
  2. 互斥重建:用 Redis 的 SET key value NX EX 10 实现分布式锁,只有一个线程去查 DB
  3. 本地缓存兜底: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 事件按时间排序、合并。

这就引出了两个问题:

  1. 事件乱序:以太坊叔块(uncle block)可能导致事件顺序错乱
  2. 状态重建成本高:从创世块重放?别闹了

我们的解法是:用版本号 + 幂等更新

// 每个事件带 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 熟手(自封的),这次事故后我们立刻做了几件事:

  1. HPA(Horizontal Pod Autoscaler):基于 CPU 和自定义指标(如队列长度)自动扩缩容
  2. Pod Disruption Budget:保证至少 2 个实例在线,避免滚动更新时服务中断
  3. 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝