一、自我介绍(1.5 分钟)
面试官您好,我叫李明,有 5年 Java 后端开发经验,之前是在一家日活百万的 B2C 电商平台担任核心开发工程师。
我主要负责订单中心、购物车服务、库存管理以及秒杀系统的架构设计与开发工作。技术栈以 Spring Boot + Spring Cloud+MyBatis-Plus 为主,数据库使用 MySQL 8.0,缓存层采用 Redis 集群,消息中间件是 RabbitMQ,服务治理使用 Nacos + Sentinel,分布式事务使用的是Seata, 链路追踪用 SkyWalking。
在过去两年中,我主导了多个高并发场景的重构,比如将秒杀系统的 QPS 从 800 提升到 6500+,并实现了“零超卖”;还优化了订单创建链路,平均响应时间从 800ms 降到 200ms 以内。
我对分布式系统的设计、性能调优和稳定性保障有较深的理解,今天很荣幸能和您交流,谢谢!
二、项目背景介绍(1 分钟)
我们平台是一个综合类电商平台,SKU 超过 50 万,日均订单量约 18 万,DAU 在 80 万左右。大促期间(如双11、618),瞬时流量可达日常的 20 倍以上,尤其是秒杀活动,经常出现 5000+ QPS 的峰值请求。
系统采用微服务架构,按业务域拆分为:商品服务、库存服务、订单服务、购物车服务、用户服务、支付服务等,通过 Dubbo 进行 RPC 调用,网关层使用 Spring Cloud Gateway 统一入口。
我重点参与并主导了以下几个核心模块的开发与优化:
| 模块 | 我的角色 | 核心挑战 |
|---|---|---|
| 秒杀系统 | 主导重构 | 高并发、防超卖、系统稳定性 |
| 订单中心 | 核心开发 | 状态机、超时关闭、幂等性 |
| 购物车服务 | 设计与实现 | 多端同步、性能优化 |
| 库存服务 | 深度参与 | 扣减准确性、分布式事务 |
接下来,我想以秒杀系统为例,详细说明我是如何设计和落地这个高并发场景的。
三、模块详解:秒杀系统设计与实现(4 分钟)
1. 业务背景与目标
我们每月都会做一次品牌秒杀活动,某次活动预热了 1 万台某型号手机,原价 2999,秒杀价 999,限量每人限购 1 台。
业务目标:
- 支持 5000+ 并发请求;
- 实现 0 超卖;
- 用户抢购成功后 30 分钟内完成支付,否则释放库存;
- 响应时间 < 500ms。
2. 架构设计原则
我们遵循“分层削峰、资源隔离、快速失败”三大原则:
- 分层削峰:在不同层级拦截无效流量;
- 资源隔离:秒杀独立部署,不干扰主站;
- 快速失败:尽早拒绝非法请求,减少后端压力。
3. 具体实现方案
(1)前端 & 网关层:前置拦截
- 按钮置灰:秒杀未开始时按钮不可点击,避免用户频繁刷新;
- 验证码机制:活动开始瞬间弹出滑动验证码,有效拦截脚本和机器人;
- Nginx 限流:基于 IP 限流,单 IP 每秒最多 3 次请求,使用
limit_req模块;- 接口隐藏:秒杀接口 URL 动态生成(带 Token),Token 通过登录态 + 商品 ID + 时间戳生成,防止被提前抓包。
(2)服务层:缓存预减 + 内存标记
Redis 预减库存:
活动开始前,通过定时任务将商品库存加载到 Redis,Key 为
seckill:stock:1001,Value 为库存数;扣减使用
DECR命令,原子操作,避免超卖;示例代码:
javaLong result = redisTemplate.opsForValue().decrement("seckill:stock:1001"); if (result >= 0) { // 扣减成功,进入下单流程 } else { // 库存不足,直接返回 }内存标记(本地缓存):
使用
ConcurrentHashMap<String, Boolean>记录商品是否已售罄,键位秒杀商品id,值为库存每次请求先查内存,若已售罄直接返回,避免频繁查 Redis;
示例:
javaif (stockEmptyMap.get("1001")) { return Result.fail("已售罄"); }
(3)异步化:消息队列削峰
- 秒杀成功后,不立即创建订单,而是发送消息到 RabbitMQ;
- 消息内容包含:用户 ID、商品 ID、Sku_id、购买数量(我们这里要考虑一人只能买一件的问题)、秒杀价格这些关键字段;
- 消费者服务异步处理要做的操作有:
- 创建订单(写数据库);
- 扣减真实库存(DB);
- 发送支付提醒短信;
- 更新用户秒杀记录;
- 这样前端响应时间控制在 200ms 内,用户体验好。
- 这里涉及到多个表的操作,我们使用分布式事务seata来实现,主要用的是AT模式(自行准备)
(4)数据库:读写分离 + 分库分表
- 因为订单量比较大,我们做了分库分表,使用的是
MyCat(也可以使用sharding-jdbc),订单表t_order按user_id模8分成 8 个库,每个库再按order_id % 8分成 8 个表,共 64 张表;- 主库负责写,3 个从库负责读,通过 MyCat 中间件路由;
- 索引优化:
user_id、order_status、create_time建联合索引,提升查询效率。
4. 性能压测与结果
在发布前,我们也做了性能和压力测试
- 我们使用 JMeter 模拟 1000 个用户并发请求,持续 5 分钟;
- 秒杀接口平均响应时间:180ms;
- 系统最大吞吐量:6500 QPS;
- 数据库 CPU 使用率 < 60%,Redis 命中率 98%;
- 实现 0 超卖,所有订单库存准确。
四、技术问题延伸(预判并主动回答)
刚才提到我们用 Redis 预减库存,但面试官可能会问:“如果 Redis 扣减成功,但 MQ 消息丢失,导致订单没创建,怎么办?”
回答:
这是一个典型的最终一致性问题。我们通过以下机制保障:
1. 消息可靠性投递
- RocketMQ 开启
sendSync同步发送,确保消息写入 Broker;- Broker 配置
同步刷盘 + 主从复制,防止宕机丢失;- 生产者收到
SEND_OK才认为成功。
2. 消费者幂等性
- 数据库层面:消费者使用
数据库唯一索引防重:订单表user_id + product_id建唯一键;- 使用 Redis
SETNX记录处理状态,避免重复下单。- 或者也可以使用token机制,第一次请求产生token返回给客户端,第二次请求时带上token去验证,如果有token就消费,没有就不能消费,消费完删除token
3. 对账补偿机制
- 每天凌晨定时跑对账任务:
- 统计 Redis 扣减总数;
- 统计数据库实际下单数;
- 若有差异,自动触发补偿流程或告警通知运维。
刚才提到我们每天凌晨跑对账任务,但面试官可能会问:“你们是怎么做定时任务的呢?定时任务的设计与实现是怎么做的呢?"
是的,我们每天凌晨 2 点会执行一个全量对账任务,用于校验秒杀活动中 Redis 预减库存和数据库实际下单数量是否一致,发现差异时自动触发补偿或告警通知。
针对这个需求,我们采用了 XXL-JOB 作为分布式任务调度平台,而不是简单的
@Scheduled或 Quartz,原因如下:
1. 为什么不用 @Scheduled?
@Scheduled是 Spring 提供的轻量级定时任务注解,但在分布式环境下存在重复执行问题;- 比如我们有 4 个订单服务实例,如果每个实例都配置了
@Scheduled(cron = "0 0 2 * * ?"),那么这个任务会被执行 4 次,导致数据重复处理;- 而且它缺乏可视化界面、失败重试、报警、执行日志追踪等企业级功能。
2. 为什么选择 XXL-JOB 而不是 Quartz?
- Quartz 虽然支持持久化任务到数据库,但原生 Quartz 配置复杂,没有统一控制台;
- XXL-JOB 是基于 Quartz 的二次封装,提供了:
- 可视化调度平台:可以查看任务状态、执行日志、触发时间;
- 动态调度:支持在线修改 cron 表达式,无需重启服务;
- 失败重试 + 告警通知:任务失败后可配置重试次数,并通过企业微信/邮件通知运维;
- 分片广播机制:支持大数据量任务的分布式处理(后面会提到);
- 任务依赖管理:比如“先跑商品同步,再跑库存校验”。
所以我们最终选择了 XXL-JOB + MySQL 存储任务信息 + 企业微信告警集成 的方案。
3. 对账任务的具体实现流程
我们的对账任务逻辑如下:
@XxlJob("seckillReconciliationJob")
public void execute() throws Exception {
// 1. 获取昨日秒杀活动列表
List<SeckillActivity> activities = activityService.getTodayActivities();
for (SeckillActivity activity : activities) {
Long productId = activity.getProductId();
// 2. 查询 Redis 中该商品的预扣总量(通过 counter 记录)
Long redisDeductCount = redisTemplate.opsForValue().get("seckill:deduct:total:" + productId);
// 3. 查询数据库中该商品的实际下单成功数量
Long dbOrderCount = orderMapper.countByProductAndStatus(productId, OrderStatus.PAID);
// 4. 比较差异
if (Math.abs(redisDeductCount - dbOrderCount) > 5) { // 容忍小误差
log.warn("对账异常:商品{},Redis扣减{},DB下单{}", productId, redisDeductCount, dbOrderCount);
// 5. 触发补偿机制 or 发送告警
alarmService.send("秒杀对账异常", String.format("商品ID:%d, 差异:%d", productId, Math.abs(redisDeductCount - dbOrderCount)));
}
}
}
- 我们在每次 Redis 扣减时,会同步累加一个
seckill:deduct:total:1001的计数器,便于对账;- 数据库查询走索引:
product_id + order_status联合索引,避免全表扫描。
4. 如何应对大数据量?——使用 分片广播机制
当我们的秒杀活动越来越多,单机查询所有商品对账数据压力很大。于是我们引入了 XXL-JOB 的 分片广播 功能。
- 在调度平台配置任务为“广播模式”,并设置 4 个分片(对应 4 个服务实例);
- 每个实例启动时知道自己是第几个分片(
XxlJobContext.getShardIndex())和总分片数(XxlJobContext.getShardTotal());- 查询时按
product_id % shardTotal == shardIndex分片处理:
int shardIndex = XxlJobContext.get().getShardIndex();
int shardTotal = XxlJobContext.get().getShardTotal();
List<Product> products = productMapper.selectByShard(shardIndex, shardTotal); // 分片查询这样原来 1 个任务处理 1000 个商品,现在变成 4 个实例各处理 ~250 个,并行执行,提升效率。
5. 稳定性保障措施
为了确保定时任务稳定运行,我们做了以下几点:
| 措施 | 说明 |
|---|---|
| 执行超时控制 | 设置任务超时时间为 30 分钟,防止卡死 |
| 失败重试 3 次 | 网络抖动或数据库慢查询时自动重试 |
| 企业微信告警 | 任务失败、对账异常都实时通知到运维群 |
| 执行日志持久化 | XXL-JOB 自动记录每次执行日志,便于排查 |
| 灰度发布机制 | 新任务先在测试环境验证,再上线 |
6. 当然也有一些替代方案对比(体现技术选型能力)
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
@Scheduled | 简单易用 | 分布式重复执行 | 单机小项目 |
| Quartz | 支持持久化 | 无控制台,配置复杂 | 已有系统集成 |
| XXL-JOB | 有控制台、分片、告警 | 需额外部署 | 中大型分布式系统 ✅ |
| Elastic-Job | 功能强大,当当开源 | 学习成本高 | 大厂复杂场景 |
| Kubernetes CronJob | 云原生友好 | 不适合复杂逻辑 | 容器化环境 |
所以我们选择 XXL-JOB 是在开发效率、维护成本、功能完整性之间的平衡。
另一个常见问题是:“如何防止用户重复秒杀?”
回答:
我们做了三重防护:
- 前端限制:按钮点击后置灰,30 秒内不可重复提交;
- Redis 标记:
seckill:uid:1001,用户抢过该商品后写入,有效期与活动一致;- 数据库唯一索引:订单表
user_id + product_id唯一,防止重复下单。