高并发系统设计:那些年我们一起踩过的坑

何桂英
2025-06-14 12:10
阅读 674

引子:为什么我决定写这篇文章?

引子:为什么我决定写这篇文章?

说实话,写这篇文章一开始并不是为了“分享经验”,更多是一种“自省”。作为一名经历过几个大型项目的后端开发者,我在实际工作中多次面对高并发系统的挑战。有些是突如其来的访问高峰,有些是数据库瓶颈,还有些是架构不合理导致的雪崩效应。

今天想聊的,是我们团队在为一个电商项目做双十一流量支撑时的真实经历。那时候我们第一次真正意义上面对百万级 QPS 的冲击——那真是一次从理论到实践、再到崩溃边缘的完整洗礼。

希望这篇基于真实工作场景的文章,能给正在或即将走上这条“不归路”的兄弟姐妹们一点启发和共鸣。


项目背景:一场关于“秒杀”的战役

项目背景:一场关于“秒杀”的战役

事情得从一个叫做 “闪购频道” 的项目说起。公司要在双十一期间上线一个限时限量秒杀活动,预计单场活动流量可达每分钟上百万请求,并且要求下单响应时间控制在500ms以内。

当时的后端架构是传统的三层架构:

Nginx + Spring Boot + MySQL

缓存用的是Redis,但只用了本地缓存和少量热点数据缓存。整体部署结构也很简单,几台ECS实例加上RDS。

系统初期压力测试暴露出的问题

我们在压测环境进行了一轮基准测试,使用JMeter模拟1000个并发用户持续请求秒杀接口,结果如下:

  • 接口平均响应时间超过2秒
  • 数据库CPU直接飙红
  • Redis连接超时现象严重
  • 出现大量重复下单(库存扣减不同步)

更可怕的是,在QPS达到800左右时,整个服务开始出现连锁反应,下游系统也跟着被拖垮。这已经不是简单的性能问题,而是系统架构设计层面的根本性缺陷。


第一波优化:分而治之,拆解瓶颈

技术选型对比与决策

我们迅速拉通了技术负责人开会讨论,大家一致认为必须从以下几个方面入手:

问题点 解决方案 替代方案 最终选择理由
请求压力集中 前置缓存+队列削峰 单纯扩容服务器 成本低,见效快
数据库瓶颈 读写分离+异步持久化 直接切换MongoDB 已有MySQL体系,改造成本小
库存超卖 Redis原子操作+预扣机制 分布式锁 实现更简单,Redis本身支持
失败重试风暴 限流+熔断 消息补偿 控制面更大

下面我会重点讲其中几个核心点是如何实现的。


解决方案一:前置缓存与队列削峰

我们将原有的直接处理秒杀逻辑调整为两个阶段:

  1. 排队阶段:用户点击“抢购”进入排队,将请求加入消息队列。
  2. 执行阶段:由后台worker从队列中消费并执行真正的秒杀操作。

前端页面增加排队提示,降低用户刷新频率,减少无效请求。

// 示例代码:使用RabbitMQ发送秒杀请求
public void sendSeckillRequest(Long userId, Long productId) {
    try {
        String message = JSON.toJSONString(new SeckillMessage(userId, productId));
        rabbitTemplate.convertAndSend("seckill.queue", message);
    } catch (Exception e) {
        log.error("发送秒杀消息失败", e);
        // 失败回调或者降级处理
    }
}

同时我们将商品信息缓存在Redis中,包括库存、状态等,并通过定时任务同步数据库状态:

// 初始化库存示例
public void initStockCache(Long productId, int stockCount) {
    String key = "product:stock:" + productId;
    redisTemplate.opsForValue().set(key, String.valueOf(stockCount), 30, TimeUnit.MINUTES);
}

解决方案二:Redis分布式锁与库存预扣

为了避免并发超卖问题,最初我们尝试用Redis分布式锁:

public boolean lockProduct(String productId, String requestId, long expireTime) {
    Boolean success = redisTemplate.opsForValue().setIfAbsent("lock:" + productId, requestId, expireTime, TimeUnit.MILLISECONDS);
    return Boolean.TRUE.equals(success);
}

但后来发现,在大并发下锁的竞争太激烈,影响效率。于是我们改为使用Redis INCR 和 Lua脚本来保证原子性:

// 使用Lua脚本保障原子性
String script = "local stock = redis.call('GET', KEYS[1])\n" +
                "if tonumber(stock) > 0 then\n" +
                "    redis.call('DECR', KEYS[1])\n" +
                "    return 1\n" +
                "else\n" +
                "    return 0\n" +
                "end";

DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);

Long result = redisTemplate.execute(redisScript, Arrays.asList("product:stock:" + productId));
return result == 1;

解决方案三:读写分离与数据库优化

随着流量增大,主数据库压力飙升,我们立即引入了数据库读写分离方案:

  • 使用MyCat做中间件实现读写分离
  • 主库负责写入订单、库存变更
  • 从库处理非实时查询类请求

同时针对秒杀表做了以下优化:

  • 表结构瘦身,仅保留必要字段
  • 增加复合索引 (user_id, product_id)
  • 将下单事务简化为两段式提交

此外,我们还使用了延迟双删策略来缓解缓存与数据库一致性问题:

// 伪代码:更新数据库后删除缓存
public void updateOrderAndInvalidateCache(Order order) {
    // 1. 更新数据库
    orderRepository.save(order);
    
    // 2. 删除缓存
    deleteFromCache(order.getUserId());
    
    // 3. 延迟二次删除(应对可能的数据库延迟)
    taskQueue.addTask(() -> {
        Thread.sleep(500);
        deleteFromCache(order.getUserId());
    });
}

踩坑经验:那些你以为没问题却翻车的地方

坑点一:Redis连接池设置不合理

最开始我们使用默认配置的Jedis连接池,只有几十个连接,远远不够。后来换成了Lettuce + 连接池配置提升吞吐量:

spring:
  redis:
    lettuce:
      pool:
        max-active: 512   # 最大连接数
        max-idle: 256     # 最大空闲连接
        min-idle: 64      # 最小空闲连接
        max-wait: 2000ms  # 获取连接最大等待时间

坑点二:Kafka分区设置不当

消息队列我们用的是Kafka,初始只设置了3个分区,结果消费者无法并发处理。最终根据预期QPS划分了12个分区,提升了整体吞吐能力。

坑点三:线程池没隔离,导致线程阻塞

我们在处理下单逻辑的时候没有对线程池做隔离,所有任务共用一个线程池,导致某个接口慢了之后整个服务不可用。后来我们引入了Hystrix实现线程池隔离。

虽然Hystrix现在不维护了,但对于当时来说是个救命稻草。


效果总结:从崩溃边缘到稳定运行

经过一轮又一轮优化,最终效果如下:

指标 优化前 优化后
平均响应时间 >2s <300ms
秒级吞吐 ~800 QPS >8000 QPS
错误率 >20% <0.1%
系统稳定性 经常崩溃 持续稳定运行

更重要的是,这套架构在后续多个促销活动中都得到了验证,具备良好的可扩展性和复用性。


经验分享:写给正在路上的你

1. 提前规划永远比临时救火重要

不要等到大促前夕才想到做压测。日常就应该保持对系统承载力的了解,定期做容量评估。

2. 不要迷信单一技术方案

高并发场景下往往是多种手段结合使用。比如缓存、队列、异步、分布式、限流、熔断……缺一不可。

3. 监控是你的眼睛

一定要尽早接入APM系统(如SkyWalking、Pinpoint),以及基础指标采集(Prometheus + Grafana)。出了问题才能第一时间定位。

4. 日志是你的朋友

建议统一接入ELK,方便事后分析。日志级别要控制好,生产环境避免DEBUG输出。

5. 压测才是唯一真理

无论架构设计得多漂亮,不压测就是纸上谈兵。推荐使用Gatling或Locust做压测工具,模拟真实用户行为。


写在最后:技术之外的一些思考

说真的,做完那次大促,我整个人都瘦了一圈,头发也没少掉。

但最大的收获不是技术上的积累,而是意识到了一个优秀架构的重要性。它不仅仅是技术选型的问题,更是对业务的理解、对细节的关注、对突发状况的预判。

高并发系统设计从来都不是一项孤立的技术,它考验的是一个全栈工程师的综合能力。

愿你们在自己的项目里也能披荆斩棘,写出既优雅又能扛住流量的系统。如果哪天你也遇到了“十万并发进不来一条”的奇景,别慌,坐下来喝杯茶,想想我们这些前辈是如何挺过来的 😊


如有需要交流具体实现细节或相关源码,欢迎留言或私信,我们可以进一步探讨!

评论 0

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