高并发系统设计:我在传统企业踩过的坑与填过的雷

GoRoutine散步
2026-01-03 09:20
阅读 1046

上周五晚上十点半,我坐在公司工位上,耳机里放着Lo-fi Hip Hop(写代码的标配BGM),盯着屏幕上不断飙升的CPU使用率,心里默默问候产品经理全家。事情是这样的——我们公司去年开始搞“数字化转型”,领导拍板要做一个在线秒杀活动,目标是支撑每秒3000+请求。听起来不多?但别忘了,我们是个做工业设备的传统制造企业,后端系统还是十年前那套单体架构,数据库连读写分离都没做。

我当时就懵了:这不就是让我用拖拉机去跑F1赛道吗?

但没办法,老板说“这是战略级项目”,还特意点名让我这个“懂点高并发”的Java开发牵头。其实我哪懂什么高并发,只是之前在GitHub上看了几篇Redis和Nginx的文章而已。不过既然上了贼船,那就硬着头皮干吧。今天这篇博客,就是想聊聊这段时间从理论到实践的血泪史,希望能帮到同样在传统企业挣扎的兄弟们。

为什么传统企业搞高并发这么难?

先说说我们的现状。公司主业务系统是用Spring Boot 2.3写的(没错,到现在还没升级到3.x),部署在三台物理机上,MySQL 5.7主从架构(但只有一主一从,而且从库基本不用),缓存?不存在的。接口响应时间动辄800ms+,QPS超过200就开始报警。

更离谱的是,运维大哥说:“你们开发能不能少写点SQL?每次大促数据库就挂。” —— 我心想,你倒是给我加个Redis啊!

在这种环境下谈高并发,简直是“裸奔上战场”。但现实逼着我们得上。双11、618这些电商节日,现在连卖螺丝钉的都要搞促销,我们这种To B企业也得跟风。于是,我拉着两个实习生,开始了这场“不可能完成的任务”。

架构重构:从单体到服务化

第一步,必须拆!原来的系统是典型的“上帝类”设计,一个Service里塞了订单、库存、用户、支付所有逻辑。我花了两周时间,用DDD的思想做了初步拆分,搞出了三个微服务:

  • order-service:处理下单逻辑
  • stock-service:管理库存扣减
  • user-service:用户鉴权和信息查询

技术栈还是Spring Boot,毕竟团队熟悉,学习成本低。但这里我埋了个伏笔——关键路径必须异步化

比如下单流程,原来是一条线走到底:校验用户 → 扣库存 → 创建订单 → 发短信。现在改成:

  1. 校验用户(同步)
  2. 发送“创建订单”消息到Kafka(异步)
  3. 立即返回“下单成功,请等待确认”

前端配合做了状态轮询,用户体验稍差,但扛住了流量洪峰。实测QPS从200飙到1500+,数据库压力直接降了70%。

// Spring Boot中发送Kafka消息示例
@Service
public class OrderServiceImpl {
    
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
    
    public String createOrder(OrderRequest request) {
        // 1. 用户校验 & 库存预占(这里用Redis分布式锁)
        if (!validateAndPreoccupyStock(request)) {
            throw new BizException("库存不足");
        }
        
        // 2. 发送异步消息
        String orderId = generateOrderId();
        OrderMessage msg = new OrderMessage(orderId, request.getUserId(), ...);
        kafkaTemplate.send("order-create-topic", JSON.toJSONString(msg));
        
        // 3. 立即返回
        return orderId; // 前端根据orderId轮询状态
    }
}

这里有个坑:消息可靠性。第一次上线时,Kafka集群配置错了,消息丢了,导致用户付了钱没生成订单。被财务部追着骂了三天。后来加上了消息重试 + 死信队列 + 人工补偿后台,才算稳住。

缓存策略:Redis不是万能药

光靠异步还不够。库存查询这种高频读操作,必须上缓存。我们选了Redis Cluster(3主3从),用Spring Data Redis集成。

但传统企业的数据一致性要求高,不能容忍超卖。所以库存扣减必须先扣DB再更新缓存,而不是反过来。为什么?因为如果先改缓存,DB写失败,缓存就脏了。

// 库存扣减伪代码(带分布式锁)
public boolean deductStock(Long skuId, Integer num) {
    String lockKey = "stock_lock:" + skuId;
    try (Jedis jedis = pool.getResource()) {
        // 1. 获取分布式锁(Lua脚本保证原子性)
        if (acquireLock(jedis, lockKey)) {
            try {
                // 2. 先查DB真实库存
                Stock stock = stockMapper.selectById(skuId);
                if (stock.getAvailable() >= num) {
                    // 3. 扣减DB
                    stockMapper.deduct(skuId, num);
                    // 4. 更新缓存(设为新值,不是incr/decr)
                    jedis.setex("stock:" + skuId, 3600, 
                               String.valueOf(stock.getAvailable() - num));
                    return true;
                }
            } finally {
                releaseLock(jedis, lockKey);
            }
        }
    }
    return false;
}

这里用了Redis分布式锁,避免超卖。但锁也有性能损耗,所以只对热点商品(比如秒杀款)加锁,普通商品直接走DB+缓存。

限流熔断:别让系统被冲垮

高并发最怕雪崩。我们用Sentinel做了全链路限流:

  • Nginx层:限制单IP每秒请求数
  • 网关层(Spring Cloud Gateway):按用户ID限流
  • 服务层:核心接口QPS阈值设为2000,超了直接拒绝
# Sentinel规则配置(application.yml)
spring:
  cloud:
    sentinel:
      datasource:
        ds1:
          nacos:
            server-addr: ${nacos.addr}
            data-id: order-service-sentinel-rules
            group-id: DEFAULT_GROUP
            rule-type: flow

有一次压测,忘了开限流,结果stock-service把DB连接池打满了,整个系统瘫痪。运维大哥冲过来吼:“你们开发是不是又在搞事情?!” —— 自此以后,限流成了上线 checklist 的第一条。

为什么我还偷偷学了Go?

说到这里,不得不提一个敏感话题:Spring Boot真的适合超高并发场景吗?

我们的order-service在峰值时GC停顿高达800ms,虽然用了G1垃圾回收器,但对象分配太快,年轻代撑不住。我试着调优JVM参数,但收效甚微。

于是,我开始偷偷研究Go。原因很简单:Go的goroutine比Java线程轻量太多,而且没有GC停顿问题(虽然Go也有GC,但延迟低得多)。

我用Go重写了库存查询接口(纯读操作),对比测试结果:

指标 Spring Boot (Java 11) Go (1.20)
QPS 2800 12000
P99延迟 120ms 18ms
内存占用 1.2GB 80MB

简直降维打击!但我不敢在生产环境直接上Go——团队没人会维护,出了问题背锅的是我。所以目前只用在非核心的旁路服务,比如日志收集、监控上报。

不过说实话,如果从零开始设计,我会认真考虑Go。尤其是I/O密集型场景,Go的netpoll模型太香了。Spring Boot更适合业务复杂、需要大量生态支持的系统(比如我们现在的主业务),而高并发网关、消息处理这类,Go可能更合适。

数据库优化:最后的防线

即使做了这么多,DB还是瓶颈。我们做了三件事:

  1. 分库分表:用ShardingSphere-JDBC,按用户ID哈希分16库,订单表按时间分表。
  2. 读写分离:强制写主库,读走从库(通过Hint强制路由)。
  3. SQL瘦身:干掉所有SELECT *,只查必要字段;给user_id + status加复合索引。

最搞笑的是,之前有个接口为了“方便”,在循环里查用户信息:

for (Order order : orders) {
    User user = userMapper.selectById(order.getUserId()); // N+1查询!
}

线上直接慢查询爆炸。后来改成批量查询 + Map缓存,接口从2s降到80ms。

安全意识:高并发下的隐秘风险

很多人只关注性能,忽略了安全。高并发场景下,攻击面更大:

  • 缓存穿透:恶意查询不存在的ID,打穿到DB。解决方案:布隆过滤器 + 空值缓存。
  • 缓存击穿:热点key过期瞬间,大量请求打到DB。解决方案:逻辑过期 + 后台刷新。
  • DDoS伪装:攻击者用合法用户身份疯狂刷接口。解决方案:行为分析 + 动态验证码。

我们在网关层加了风控模块,基于用户行为打分。比如1秒内下单10次,直接弹验证码。虽然影响体验,但保住了系统。

效果如何?双11实战检验

今年双11,系统峰值QPS达到3500,平均延迟120ms,0故障。老板在复盘会上夸我:“小王啊,这次做得不错!” —— 其实我心里清楚,功劳是团队的,我只是那个熬夜调参的倒霉蛋。

更重要的是,这次项目让公司意识到:数字化转型不是买几台服务器就行,得从架构、流程、人员全方位升级。现在运维主动找我聊K8s部署,测试也开始写自动化压测脚本,连产品经理都学会说“这个需求会不会影响TPS?”了(笑)。

给同行的几点建议

  1. 别盲目追求新技术:Spring Boot + Redis + Kafka 这套组合拳,在大多数场景够用了。Go虽好,但要考虑团队能力。
  2. 压测要贴近真实:用JMeter模拟真实用户行为(包括思考时间、错误率),别只测“理想QPS”。
  3. 监控必须到位:Arthas看线程堆栈、Prometheus+Grafana看指标、ELK查日志,三位一体。
  4. 留好逃生通道:开关降级、功能回滚、数据补偿,一个都不能少。

写这篇文章的时候,窗外上海的雨下个不停。我刚改完一个线上Bug,准备下班。回头看看这段高并发之旅,虽然累,但值得。传统企业转型很难,但只要有人愿意迈出第一步,路就会越走越宽。

对了,如果你也在传统企业搞技术,欢迎留言交流。说不定下次团建,我们能在张江的烧烤摊碰上——我请客,只要你别再提“明天上线”就行 😅

评论 0

最热最新
暂无评论
GoRoutine散步Lv.1
0
影响力
0
文章
0
粉丝