高并发系统设计:从踩坑到从容,我用实战告诉你怎么走

前端散步者
2025-06-16 21:52
阅读 597

背景介绍:为什么我要写这篇文章

背景介绍:为什么我要写这篇文章

2019年的时候,我在一家做在线教育的公司负责后端系统的架构优化。当时我们正准备上线一个“名师直播课”项目,宣传力度不小,用户数量短时间内暴涨,系统也迎来了第一次真正意义上的大考。

这个项目的核心是直播课堂,老师直播时支持万人同时在线互动,包括提问、打赏、答题等操作。听起来很炫酷,但现实远比预期残酷——上线第一天晚上就因为高并发导致服务器雪崩,数据库直接挂掉,几十个用户投诉,运营团队连夜开会……

那次事故让我深刻意识到:高并发不仅仅是技术问题,更是对系统设计、协作流程甚至心理承受能力的一次全方位考验。从那之后,我开始系统地学习高并发系统的构建和优化方法,不断踩坑、总结,在多个项目中反复打磨这套经验。

今天,我就结合自己的经历,分享一下如何设计高并发系统,特别是在真实项目中的挑战和解决思路。


问题描述:一次崩溃引发的深度思考

问题描述:一次崩溃引发的深度思考

上面提到的那个直播课项目,最开始我们并没有预料到它会成为爆款。初期的设计方案比较常规:

  • 使用 Spring Boot + MySQL 架构
  • Redis 做缓存
  • 所有接口部署在单台 Nginx 反向代理下面
  • 消息队列使用 RabbitMQ 做异步处理

一切看着都没问题,直到第一场大型直播开播当天,用户量瞬间突破5000人,接着:

  • 接口响应时间飙升,大量请求超时
  • 数据库 CPU 占用率接近100%
  • Nginx 出现 connection reset 错误
  • 整体服务不可用超过10分钟

那次故障暴露了几个核心问题:

  1. 连接池配置不合理:HikariCP 默认最大连接数太小,数据库被打爆
  2. 同步调用过多:大量的日志写入、消息广播都在主线程里完成,拖慢主线程
  3. Redis 缓存击穿:未设置热点穿透保护,导致数据库压力剧增
  4. 缺乏限流降级机制:系统不具备自我保护能力,一压就瘫

这些问题背后,其实都指向一个更本质的问题:我们在设计系统时,并没有真正理解“高并发”意味着什么


解决方案:一步步构建可扛住流量洪峰的系统架构

为了从根本上解决问题,我们用了近两个月的时间进行系统重构。以下是关键改造点和选型决策,以及我在其中踩过的坑。

1. 架构分层与模块解耦

我们重新梳理了业务逻辑,将系统拆分为以下几个模块:

  • 网关层(Gateway):负责鉴权、限流、路由
  • 业务逻辑层(Service Layer):处理具体业务逻辑,如送礼物、答题记录等
  • 数据聚合层(Data Layer):整合 DB、缓存、ES 等数据源
  • 任务中心(Job Center):处理后台任务,如统计、日志归档等
  • 消息推送层(Message Push):负责 WebSocket 和实时消息通知

通过模块化拆分,我们可以单独扩展某些模块,而不会影响其他部分。

2. 引入限流与熔断机制

我们采用了 Sentinel 作为限流组件,结合 OpenFeign 做服务间调用的熔断控制。这部分配置简单,但真正难点在于阈值的设置。

比如:某个查询接口 QPS 设为多少合适?

答案是:需要压测!我们后来做了基准测试,根据平均响应时间和线程池容量反推 QPS 限制。最终设定规则如下:

resource: get_course_info
threshold: 800
grade: 1 # 基于QPS限流
controlBehavior: 2 # 控制行为为匀速排队

这一步看似简单,但在实际部署时容易忽视线程阻塞造成的连锁反应。一定要做好链路压测!

3. Redis 缓存策略升级

原来的缓存只是加了一层 key-value,结果遇到大量相同请求访问同一个课程信息时,仍会穿透缓存打到数据库。

我们增加了以下措施:

  • 空值缓存(Null Caching)防止恶意攻击
  • 热点标记 + 自动降级机制
  • 多级缓存设计(LocalCache + Redis)
  • 熔断策略下自动切换读DB通道

举个例子,我们在 Java 中加入了一个本地缓存实现:

// 使用 Caffeine 做本地缓存
Cache<String, Object> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build();

Object data = localCache.getIfPresent(key);
if (data == null) {
    data = redisTemplate.opsForValue().get(key); // 远程获取
    if (data != null) {
        localCache.put(key, data);
    }
}

这一改动显著减少了对 Redis 的高频访问压力。

4. 数据库优化与水平扩展

MySQL 在并发高时很容易成为瓶颈,我们做了以下调整:

  • 拆分主库与从库,读写分离
  • 查询走只读副本,更新走主库
  • 分表策略引入(按用户 ID 哈希分片)

另外,我们还引入了 MyCat 中间件做透明分库分表,虽然 MyCat 后来被 ShardingSphere 替代,但在当时的场景下是合适的选型。

有个插曲:分表后我们遇到了一些诡异的 Join 查询错误,最后发现是中间件不支持跨库 Join 导致的。因此建议:能不做复杂 Join 就不要做,能提前预处理就在业务层搞定

5. WebSockets 与长连接管理

直播间的聊天、弹幕、礼物都需要实时推送,我们一开始用了普通的 WebSocket,但随着人数增长,内存占用越来越高,服务器频繁 Full GC。

后来我们改用 Netty + Redis 发布订阅的方式处理消息推送。Netty 接管长连接管理,收到用户发送的消息后,通过 Redis Pub/Sub 广播到其他节点,其他节点再推送给对应的连接客户端。

代码示例:

@ServerEndpoint("/chat/{userId}")
public class ChatEndpoint {

    @OnOpen
    public void onOpen(Session session, @PathParam("userId") String userId) {
        SessionManager.addSession(userId, session);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        // 通过 Redis 发送到其他节点
        redisPublisher.publish("chat_channel", message);
    }
}

这样做的好处是:

  • 每个节点只需管理自己连接的用户
  • 消息广播由 Redis 统一处理,避免重复转发
  • 易于横向扩展,新增节点不影响已有服务

不过也有缺点:消息可能会重复消费,需要引入幂等性处理机制。


踩坑经验:那些你没踩过就不知道的事儿

讲完了整体的技术方案,这里再聊几个特别典型的“深坑”,希望你在开发过程中避过这些雷。

✅ 陷阱一:数据库事务死锁

我们曾经在送礼物逻辑中出现大量事务卡死的情况。分析发现是因为多个用户同时送礼给同一个主播,导致行锁竞争激烈。

解决方案是:

  • 尽可能缩小事务粒度
  • 加锁顺序一致(如先锁定主播ID再锁定用户ID)
  • 对热点资源加分布式锁(Redis Redlock 实现)

例如:

String lockKey = "gift_lock:" + anchorId;
try {
    Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 30, TimeUnit.SECONDS);
    if (isLocked != null && isLocked) {
        // 执行送礼逻辑
    }
} finally {
    redisTemplate.delete(lockKey);
}

别图省事裸写事务,特别是涉及多人协作修改的数据表,必须小心加锁!

✅ 陷阱二:线程池拒绝策略不当

我们的线程池原本使用 CallerRunsPolicy,即调用者执行任务。当高峰期请求量太大时,反而把主线程拖死,最终造成服务不可用。

后来我们换成 AbortPolicy,并配合 Sentinel 提前熔断请求:

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

记住一点:线程池拒绝策略要跟限流配合使用,不能孤立看待。

✅ 陷阱三:Nginx 配置失误导致连接耗尽

我们最初在前端加了一层 Nginx,但是连接数一直上不去,后来发现是因为 keepalive 设置不对:

upstream backend {
    least_conn;
    server 10.0.0.1:8080 weight=2;
    server 10.0.0.2:8080;

    keepalive 64;  # 关键参数
}

location /api/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection ''; # 记得清空连接头
}

加上这两项后,连接复用大幅提升,服务器负载明显下降。


效果总结:优化后的变化

经过一系列优化和重构,系统在后续的大规模活动(双十一、暑假招生季)中表现稳定:

指标 优化前 优化后
接口平均响应时间 500ms+ <100ms
最大并发支撑能力 2k~3k >1w
系统可用性 ~97% >99.5%
DB CPU 使用率 经常打满 最高60%左右
投诉量 数十起 个位数

最重要的是,我们终于有了信心去承接更大的流量,而不是提心吊胆地等待下一波用户涌入。


经验分享:写给正在战斗的你

如果你现在也在做类似的高并发项目,或者即将接手类似需求,我想给你几个真心建议:

缓存策略对比-2

🧱 1. 系统架构不是一成不变的

刚开始可以“简单但清晰”,比如分层设计+基础缓存+基本限流。关键是让结构清晰、边界明确,后期才有足够的扩展空间。

⚡ 2. 性能优化是一个持续过程

不要指望“一次性设计好”,而是要在不同阶段逐步迭代。性能测试、压测、监控、告警都是必备工具。

🛑 3. 不要迷信任何“万能方案”

Sentinel 好用,但不一定适合所有限流场景;Redis 很快,但并不是所有数据都要缓存;Netty 很牛,但也未必适合你的消息推送模型。

💬 4. 和产品、测试保持良好沟通很重要

很多时候,高并发的问题不是技术不行,而是需求不清、节奏混乱造成的。比如一个按钮点击多次,是不是可以前端防抖?比如一个功能非得强一致性?还是可以容忍一点点延迟?

🧪 5. 多做模拟演练,别等到线上出问题才想起预案

我现在的习惯是在每个新项目上线前都会做三个动作:

  • 压测(JMeter 或 Gatling)
  • 故障演练(Kill Pod、网络分区、CPU 占满)
  • 全链路追踪(SkyWalking 或 Zipkin)

这些准备,才是真正保障系统稳定的底气。


结语:高并发没有捷径,只有不断积累的经验

缓存策略对比-1

回首这几年,从最初的懵懂到现在能独立设计出一套完整的高并发方案,我深刻体会到一句话:“高并发不是拼凑技术栈,而是对系统设计、工程思维、协作能力的整体考验。”

每一个经历过“线上崩溃”的开发者,都有属于自己的成长故事。愿你在未来的工作中,少些焦虑,多些从容。

如果你也在高并发的路上摸爬滚打,欢迎一起交流,互相取暖。毕竟,这条路,从来都不是一个人的战斗。


文末彩蛋:本文中提及的很多代码片段和配置都可以在 GitHub 上找到完整 demo,关注我的账号 [YourGitHubName],回复【高并发】即可获取相关示例仓库地址。

评论 0

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