高并发系统设计:从理论到实践 —— 一位后端工程师的实战经验分享

技术森林
2025-06-12 17:05
阅读 361

引言:一次双十一压测让我意识到高并发不是玄学

引言:一次双十一压测让我意识到高并发不是玄学

记得刚成为项目负责人那年,公司准备在双十一期间上线一个限时秒杀活动。作为核心接口之一的“下单”服务,承载着整个活动中最核心的交易逻辑。我原本信心满满地部署了一套基于Spring Boot + MyBatis的微服务架构,然而,在预演压力测试中,系统竟然在200并发时就开始出现大面积超时和线程阻塞。

那次失败让我彻底清醒:高并发系统远不只是简单地加几个线程或者堆几台服务器。它涉及到方方面面的设计与权衡,是技术深度与业务理解的综合考验。

后来我花了几个月时间从架构调整、缓存策略、数据库优化等多个层面重构了这个系统,最终在实际双十一当天扛住了3500+ QPS的瞬时峰值,成功率保持在99.6%以上。这篇文章,我会以那次项目为背景,聊聊我在高并发系统设计中的真实经历与思考。


项目背景:一场限时秒杀背后的挑战

项目背景:一场限时秒杀背后的挑战

我们这次项目的背景是一个电商限时秒杀平台的下单服务模块,核心功能包括:

  • 商品库存预扣减
  • 用户限购控制
  • 下单流水生成
  • 支付状态更新(异步处理)

关键业务指标:

指标 目标值
峰值QPS ≥3000
平均响应时间 ≤100ms
接口成功率 ≥99.5%

当时我们的服务部署结构如下:

Nginx + Keepalived (软负载)
├── Gateway -> Spring Cloud Gateway
│   └── 认证 & 权限校验 & 熔断降级
└── 下单服务集群 (4台 ECS, Spring Boot 2.x)
    ├── MyBatis + MySQL(主从)
    └── Redis(集群)

整体看起来还算标准的微服务架构,但在压测环节暴露了多个致命问题。


我们遇到的真实挑战

我们遇到的真实挑战

1. MySQL连接池被打爆,DB成为瓶颈

在JMeter模拟300并发用户请求下单时,数据库连接数迅速飙升至最大值(max_connections默认150),后续所有SQL都进入等待状态,进而引发线程池打满。

2. 热点商品锁竞争严重,大量事务回滚

由于使用的是UPDATE stock SET count = count - 1 WHERE goods_id = ? AND count > 0的经典方式,导致同一商品ID在并发情况下产生大量行锁争用。

3. Redis分布式锁性能不够理想,增加延迟

我们最初采用了Redlock算法实现的分布式锁,用来防止超卖。但实际场景下发现Redlock在网络不稳定或节点故障时,不仅性能不佳,还容易导致重复扣减。

4. 线程阻塞严重,TPS上不去

服务层没有做异步化处理,所有操作都是同步执行,主线程长时间等待DB/Redis响应,无法充分利用CPU资源。

5. 缺乏熔断与降级机制

当某个依赖服务(如库存服务)异常时,整个下单流程都会阻塞,用户体验极差,甚至导致雪崩效应。


解决方案:逐步打磨出一套轻量稳定的高并发系统

解决方案:逐步打磨出一套轻量稳定的高并发系统

面对这些问题,我们并没有盲目换架构或引入新组件,而是从最根本的问题出发,逐个击破。

一、流量前置拦截:在接入层做好分流和限流

我们在Nginx层做了两件事情:

  1. IP限流:防止刷单或恶意攻击

    http {
        limit_req_zone $binary_remote_addr zone=one:10m rate=50r/s;
        server {
            location /order/create {
                limit_req zone=one burst=10 nodelay;
                proxy_pass http://gateway;
            }
        }
    }
    
  2. 根据商品ID hash分发请求:将同一个商品的请求始终分配到同一台后端实例,减少跨实例竞争。

    upstream backend {
        hash $request_header_goodsid consistent;
        server instance1 weight=1;
        server instance2 weight=1;
        keepalive 32;
    }
    

这样做不仅提高了命中本地缓存的概率,还能降低Redis分布式锁的复杂度。


二、缓存与异步:把耗时动作尽可能挪出主路径

使用多级缓存体系

我们将库存数据从数据库提前加载进Redis,并通过定时任务做预热:

// 定时任务每日凌晨加载库存到Redis
@Scheduled(cron = "0 0 3 * * ?")
public void preheatStock() {
    List<Goods> goodsList = goodsMapper.selectAll();
    for (Goods goods : goodsList) {
        String key = "stock:" + goods.getId();
        redisTemplate.opsForValue().setIfAbsent(key, goods.getStock(), 1, TimeUnit.DAYS);
    }
}

下单时优先扣减Redis,仅在库存充足时再提交数据库更新。

异步落库 + 消费补偿

我们将真正写订单的动作异步化,通过消息队列解耦:

// 异步下单流程
public String createOrder(OrderDTO dto) {
    // 1. 扣减Redis库存
    Long decrResult = redisService.decr("stock:" + dto.getGoodsId());
    if (decrResult < 0) {
        throw new StockNotEnoughException();
    }

    // 2. 异步写入MQ
    orderProducer.sendAsyncOrder(dto);

    // 返回预下单ID供前端查询
    return UUID.randomUUID().toString();
}

然后消费者监听订单消息并持久化到数据库:

@KafkaListener(topics = "order_create")
public void processOrder(OrderDTO dto) {
    try {
        orderService.saveOrder(dto);
    } catch (Exception e) {
        log.error("订单入库失败,重试中...", e);
        retryQueue.offer(dto);  // 加入重试队列
    }
}

这样既保证了实时性,又避免了数据库瓶颈。


三、数据库设计与调优:稳住核心存储

合理分表 + 冷热分离

我们按照用户ID进行水平切分,每个用户的订单被均匀分布到不同的子表中。同时,对历史订单进行冷热分离,单独迁移至另外一张表用于统计分析。

分布式事务改用TCC模式

为了替代原生的分布式事务,我们引入了TCC(Try-Confirm-Cancel)模式:

  • Try阶段:冻结库存、锁定用户额度
  • Confirm阶段:正式扣减库存、生成订单
  • Cancel阶段:回滚冻结资源

这种方式虽然增加了代码复杂度,但极大地提升了系统的扩展性和容错能力。


四、真正的“分布式锁”,不需要复杂的实现

经过几次尝试,我们放弃了Redlock和Redisson的分布式锁实现,最终选择了基于Redis Lua脚本的原子扣减来实现库存控制:

public boolean deductStock(String goodsId) {
    String script = "if redis.call('get', KEYS[1]) >= tonumber(ARGV[1]) then return redis.call('decrby', KEYS[1], ARGV[1]) else return 0 end";
    Object result = redisTemplate.execute(script, Arrays.asList("stock:" + goodsId), "1");
    return ((Long) result) > 0;
}

这段Lua脚本保证了“检查+扣减”的原子性,比任何第三方封装都更高效、更安全。


五、熔断降级保障可用性:Hystrix?不,现在我们用Resilience4j

考虑到Spring Cloud Netflix Hystrix已经停止维护,我们切换到了社区活跃的Resilience4j,并结合Gateway实现了优雅的熔断和降级:

resilience4j.circuitbreaker:
  instances:
    orderService:
      failure-rate-threshold: 50
      wait-duration-in-open-state: 5s
      ring-buffer-size-in-closed-state: 10
      ring-buffer-size-in-open-state: 2

并在Feign Client中配置 fallback:

@FeignClient(name = "inventory-service", fallback = InventoryFallback.class)
public interface InventoryClient {
    @GetMapping("/check_stock/{goodsId}")
    Boolean checkStock(@PathVariable String goodsId);
}

踩坑经验:那些深夜debug的经历教会我的事

  1. Redis连接泄漏导致服务不可用 早期未正确关闭Redis连接,导致连接池资源被吃光。后来统一采用try-with-resources的方式,并设置合理的超时时间:

    try (Jedis jedis = pool.getResource()) {
        ...
    }
    
  2. Nginx长连接配置不当引发连接数过高 刚开始没加keepalive参数,每次请求都重新创建TCP连接,导致TIME_WAIT激增。添加 proxy_http_version 1.1; proxy_set_header Connection ""; 和 keepalive 设置后缓解。

  3. 日志打太多拖垮磁盘IO 日志级别一开始设成了DEBUG,每秒写几千条日志,结果导致磁盘满载。后面改成INFO为主,关键路径保留TRACE即可。

  4. 线程池配置不合理引发拒绝策略 最初Tomcat最大线程数设置太小,且未启用队列。后来合理配置线程池,并监控RejectedExecutionException。


实施后的效果与收益

指标 改造前 改造后
最大QPS 200 3800
平均RT ~600ms ~80ms
DB连接数 200+ 30~40
接口成功率 85% 99.7%
线程利用率 不足40% 提升至80%

而且整个系统在双十一当天表现稳定,即使个别节点挂掉也能自动恢复,几乎没有影响业务。


给后端开发者的几点建议

微服务架构示意图-1

  1. 别迷信架构,先看清业务本质 没有银弹,高并发一定是围绕你的业务特点去优化,而不是照搬别人的“秒杀架构”。

  2. 缓存不是万能的,但确实是最重要的手段之一 一级缓存命中率提上去,性能会提升一个数量级。多级缓存要组合使用,注意失效一致性。

  3. 异步思维贯穿始终 任何可以异步处理的流程都尽量异步,哪怕只是延迟几十毫秒。不要让主线程等你干活!

  4. 重视监控和报警机制 Prometheus + Grafana是我们的好朋友。一定要在生产环境搭建好监控大盘,早发现问题比事后救火重要得多。

  5. 做好容量评估和压测准备 上线前一定要进行压测,摸清楚各个组件的吞吐极限,预留扩容空间,尤其在做促销前。


结语:高并发不是终点,而是日常修炼的一部分

回顾整个过程,我觉得最大的收获不是技术本身,而是学会了如何在复杂系统中寻找平衡点:既要保证高可用,又要兼顾业务需求;既不能过早优化,也不能临阵磨枪。

如果你也正处在类似场景中,不妨从以下几个点着手:

  • 梳理核心链路的瓶颈位置
  • 先做压测,不要猜测性能瓶颈
  • 小步迭代,持续改进
  • 把错误当成学习的机会

高并发这条路不好走,但我们终将在风雨中成长。

作者是一位拥有五年一线后端开发经验的Java程序员,热爱架构设计与性能优化,目前专注于电商系统高并发方向。欢迎交流微信:XXXXXXX,一起成长!

评论 0

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