高并发系统设计:一次真实项目的经验分享
引言:为什么会写这篇文章?
我是一名后端开发工程师,工作了七八年,带过几个项目组。在这几年的实战过程中,最让我印象深刻的一次经历是去年参与的一个电商平台秒杀活动的重构项目。
当时我们团队负责的是商品秒杀模块,目标是要支撑每秒上万次请求。这个系统的稳定性、响应速度和容错能力直接关系到用户体验和公司营收。面对这样一个挑战性任务,我们从零开始重新梳理架构、调整数据库、优化接口,也踩了不少坑。今天我想把这些经验整理出来,分享给正在从事高并发系统开发或者准备迈入这个领域的你。
希望你能从中看到一点实战的影子,而不是一堆理论堆砌出来的“空中楼阁”。
项目背景与挑战描述
我们的平台是一个中型电商网站,用户量在百万级左右。每年“双十一大促”期间,商品秒杀页面都会成为流量集中地,尤其是一些爆款商品,抢购时QPS(每秒查询数)能达到5000以上。
原系统的架构如下:
- 前端使用Vue,部署在CDN
- 后端是Spring Boot + MySQL + Redis的基本组合
- 秒杀逻辑直接写在Controller里,流程简单粗暴:
public ResponseDTO seckill(String userId, Long productId) {
// 查询库存
int stock = productMapper.getStock(productId);
if (stock <= 0) {
return fail("库存不足");
}
// 减库存
productMapper.reduceStock(productId);
// 创建订单
orderService.createOrder(userId, productId);
return success();
}
看起来没毛病,但实际运行中出现很多问题:
- 超卖问题:多个线程同时进入判断库存逻辑,导致减库存失败。
- 数据库压力大:大量的请求直接打到MySQL,事务频繁锁表。
- 接口响应慢:没有异步处理机制,每次请求都要等库存操作+下单完成才返回。
- Redis穿透/击穿:热点数据未做缓存保护,导致大量请求穿透到DB。
有一次预热活动时,因为一个爆款商品库存被瞬间抢光,结果导致整个服务不可用,最终影响了几十个其他商品的正常售卖。我们痛定思痛,决定来一次彻底的重构。
解决思路与技术方案
我们采用的策略是“分层防御 + 降级处理 + 异步化 + 缓存保护”,具体包括以下几个方面:
1. 架构升级:引入消息队列解耦核心业务
我们将库存减少和订单创建这两个步骤拆分为生产者和消费者两个角色。前端请求进来后立即放入消息队列(Kafka),由消费端异步处理减库存和下单逻辑,避免阻塞主线程。
效果非常明显:接口响应时间从平均800ms降到了120ms以内。
关键代码示例(Kafka发送):
@GetMapping("/seckill")
public ResponseDTO handleSeckill(String userId, Long productId) {
// 校验是否已抢购过
if (redisTemplate.opsForSet().isMember("user:seckill:" + productId, userId)) {
return ResponseDTO.fail("您已抢购过该商品");
}

// 发送MQ消息,异步处理下单逻辑
String message = String.format("{\"userId\":\"%s\",\"productId\":%d}", userId, productId);
kafkaTemplate.send("seckill-queue", message);
// 立即返回成功
return ResponseDTO.success("排队中,请耐心等待...");
}
2. 数据库优化:库存字段加版本号控制
为了解决超卖问题,我们在数据库中增加了version字段,使用乐观锁控制并发更新:
UPDATE products SET stock = stock - 1, version = version + 1
WHERE id = ? AND stock > 0 AND version = ?
在代码中读取当前版本号后进行更新,如果影响行数为0,说明库存已被其他人减完或版本不匹配,直接返回失败。
这样即使在高并发下也能保证安全。
3. 缓存预热 + 穿透保护
为了避免Redis缓存穿透,我们做了以下几件事:
- 使用布隆过滤器拦截非法请求(如不存在的商品ID)
- 对每个秒杀商品提前缓存库存信息,设定过期时间
- 使用互斥锁防止缓存击穿,仅允许一个线程回源加载数据
伪代码示例:
Integer stock = (Integer) redisTemplate.opsForValue().get("product:stock:" + productId);
if (stock == null) {
synchronized(this) {
stock = getStockFromDB(productId);
redisTemplate.opsForValue().set("product:stock:" + productId, stock, 60, TimeUnit.SECONDS);
}
}
4. 接口限流与熔断机制
为了防止单点雪崩,我们对秒杀接口进行了多层限流:
- 使用Guava的RateLimiter本地限流(应对突发流量)
- Nginx层面配置令牌桶限流
- 结合Sentinel做分布式限流和熔断降级
例如,使用Sentinel规则:
rules:
flow:
- resource: /seckill
count: 5000
grade: 1
limitApp: default
当QPS超过5000时自动拒绝请求,防止后端被打死。
5. 分布式唯一用户校验
如何避免同一用户重复秒杀?我们采用了两种方式:
- 每个用户+商品组合存到Redis Set中,确保唯一性
- 消费者端再查一遍数据库订单记录,双重保险
Redis操作片段:
Boolean isExist = redisTemplate.opsForSet().isMember("seckill:users:" + productId, userId);
if (Boolean.TRUE.equals(isExist)) {
log.warn("用户已秒杀过该商品,userId: {}, productId: {}", userId, productId);
return;
}
redisTemplate.opsForSet().add("seckill:users:" + productId, userId);
踩过的坑与解决方案
坑1:MQ消费积压严重
初期我们设置的Kafka消费者数量不够,导致大量消息堆积,延迟高达几分钟。后来我们根据监控指标动态扩容消费者实例,并增加分区数,逐步缓解了问题。
建议:提前压测模拟负载,观察MQ堆积情况,合理配置topic分区和消费者数
坑2:Redis内存爆掉
秒杀高峰期,我们缓存了上万个商品的库存和用户集合,Redis占用内存飙升。后来我们做了两件事:
- 给Redis缓存设置TTL(生存时间),避免永久存储
- 将部分冷门商品剔除出缓存池,只保留热门
坑3:版本号更新失效
有个阶段我们发现乐观锁更新总是失败,后来发现是JPA实体类未及时刷新version字段。解决方法是开启二级缓存+手动调用refresh方法:
product = productRepository.findById(id).orElseThrow(...);
entityManager.refresh(product); // 手动刷新版本号
成果总结与收益
经过这次重构,系统在双十一当天扛住了峰值10000 QPS的冲击,主要指标如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 平均响应时间 | 700ms | 90ms |
| 秒杀成功率 | 75% | 98% |
| 超卖发生次数 | 10+次/天 | 0次 |
| 数据库连接数 | >500 | <100 |
| Redis命中率 | 80% | 95% |
更关键的是,系统变得更健壮了,在后续运营活动中几乎没出过重大故障。
我的几点建议与体会
如果你也在做类似的高并发系统,这些建议或许对你有帮助:
- 提前规划比事后补救更重要:上线前做压测,模拟各种异常场景。
- 缓存不是万能的,要配合持久层一致性机制:别把所有数据都放到Redis,一定要考虑回源机制。
- 不要小看一行SQL的威力:像乐观锁这种轻量级方案很多时候比分布式锁更高效。
- 日志和监控是排查问题的救命稻草:建议集成SkyWalking/Prometheus这类工具。
- 异步化是提高吞吐的核心思想之一:适当引入MQ解耦,会大大提升系统伸缩性。
- 永远要有备用方案:比如紧急熔断、人工干预通道、快速回滚机制。

结语
高并发系统的设计从来都不是一个“一次性”的工程,而是随着业务发展不断演进的过程。在我眼里,它更像是一场持续的“修行”。每一次踩坑和修复,都是对系统理解更深的契机。
这篇文章只是我亲身经历过的一部分内容,希望能给你带来一些启发。如果你觉得有收获,欢迎留言交流或提出不同意见,也欢迎分享你自己的高并发实战故事。
毕竟,只有真正经历过线上战场的程序员,才能写出接地气的系统设计。

评论 0