面试官:“聊一下,如果让你设计一个秒杀系统,你会怎么考虑?”
这个问题在架构师面试中出现的频率极高,堪称"面试必修课"。今天我们就来系统性地拆解它,不仅讲清架构思想,还会附上关键代码示例,让你在面试中能够对答如流。
一、核心难点:为什么秒杀系统难?
秒杀业务场景的核心特点就是 “瞬时高并发” 和 “资源竞争” ,这带来了三大技术难点:
1.并发极高:瞬间涌入的海量请求,足以冲垮任何没有防护的应用服务器和数据库。想象一下,10万台服务器同时来抢购100件商品,绝大部分请求注定是失败的,但系统必须能正确处理这些“失败”。
2.库存超卖:这是一个经典的“读-写-写”并发问题。在库存仅为1的情况下,多个请求同时查询到库存为1,然后都成功下单,导致库存被扣成负数,这显然是业务无法接受的。
3.系统稳定性:高流量不仅可能打垮秒杀服务,还可能因为占用大量CPU、连接、带宽等资源,挤占正常业务的资源,导致整个电商系统瘫痪。
二、架构设计核心思想:层层削峰,分而治之
解决上述难点的核心方法论是:将请求尽量拦截在系统上游,减少对后端核心系统的冲击。 就像一个瀑布,我们在每一段都设置一个缓冲池,让湍急的水流变得平缓。
1. 前端与网关层拦截
这是流量冲击的第一道防线,目标是尽量减少到达后端服务器的无效请求。
- 理论要点:
- 页面静态化 + CDN:将秒杀活动的商品详情页、活动页等提前生成静态HTML/JS/CSS文件,部署到CDN上。用户请求直接由离他最近的CDN节点返回,流量不会回源到服务器。
- 按钮防重复提交:用户点击“立即秒杀”后,通过JavaScript立即将按钮置灰(禁用),避免用户因焦急而疯狂点击,产生重复请求。
- 答题/验证码:在秒杀开始时,增加一个简单的答题或拖动验证码环节。这不仅能有效过滤掉秒杀脚本,更能将秒杀请求在时间上拉平,实现人工“削峰”。
- 请求频率限制(限流):在API网关层(如Spring Cloud Gateway, Nginx),对同一个UID(用户ID)或IP在短时间内的大量请求进行限流,直接返回“请求过于频繁”等提示。
2. 读多写少,缓存为王
系统的绝大部分压力来自于“查询”,我们要用缓存扛住。这里的核心是将数据库的热点数据前置到缓存。
-
理论要点:
- 缓存热点数据:
- 静态数据:如商品标题、描述、图片等,在秒杀开始前全量推送到所有秒杀服务器的本地缓存中(如Ehcache),直到活动结束。读请求根本不会走到网络层面。
- 动态数据:如库存。采用Redis等分布式缓存进行存储。读请求直接查询缓存。为了平衡一致性和性能,可以设置一个短暂的本地缓存失效时间(如几秒)。
- 解决缓存经典问题:
- 缓存穿透:恶意查询一个不存在的商品ID。解决方案:对不存在的key也缓存一个空值(如
"NULL"),并设置一个短的过期时间。 - 缓存雪崩:大量缓存key在同一时间失效,请求全部打到DB。解决方案:给key的过期时间加上一个随机值,打散失效时间。
- 缓存击穿:某个热点key失效的瞬间,大量请求击穿缓存直接访问DB。解决方案:使用互斥锁(Redis
SETNX),只让一个请求去构建缓存,其他请求等待。
- 缓存穿透:恶意查询一个不存在的商品ID。解决方案:对不存在的key也缓存一个空值(如
- 缓存热点数据:
-
核心代码示例一:Redis原子扣减库存,防止超卖
// 初始化库存到Redis。秒杀活动开始前执行。
// redis-cli> SET sk:101:stock 100
@PostConstruct
public void initStock() {
redisTemplate.opsForValue().set("sk:" + skuid + ":stock", 100);
}
// 秒杀核心服务 - 原子扣减库存
@Service
public class SecKillService {
@Autowired
private StringRedisTemplate redisTemplate;
public boolean secKill(long skuid, long userId) {
// 1. 校验用户资格、活动时间等...(此处省略)
// 2. 【关键代码】在Redis中原子性地扣减库存
Long remainingStock = redisTemplate.execute(
// Lua脚本,保证原子性
new DefaultRedisScript<>(
"if tonumber(redis.call('get', KEYS[1])) > 0 then " +
" return redis.call('decr', KEYS[1]) " +
"else " +
" return -1 " +
"end",
Long.class
),
Collections.singletonList("sk:" + skuid + ":stock") // KEYS[1]
);
// 3. 判断扣减结果
if (remainingStock == null) {
throw new RuntimeException("系统异常");
}
if (remainingStock < 0) {
// 库存不足,秒杀失败
logger.info("库存不足,秒杀失败");
return false;
}
// 4. 库存扣减成功,发送MQ消息,进入异步下单流程
sendOrderMessage(skuid, userId);
return true;
}
}
代码解释:
- 为什么用Lua脚本? 因为
GET和DECR是两个命令,如果不是原子操作,在并发下多个请求同时GET到库存为1,然后都DECR,依然会超卖。Lua脚本在Redis中执行是原子的,完美解决。 DECR命令:如果key不存在,它会先初始化为0再减一,所以用> 0的判断来避免负数库存。
3. 库存扣减与数据库保护
这是防止超卖和保证数据库不被冲垮的关键。核心思想是同步转异步。
-
理论要点:
- 扣减库存放在缓存:如上所示,下单时在Redis中完成,利用其单线程和原子操作特性。
- 异步化与消息队列:
- 流程变为:前端请求 -> 网关限流 -> 校验答题 -> Redis原子扣减库存 -> 扣减成功则发送MQ消息 -> 立即返回用户“秒杀排队中” -> 消费者异步处理后续的数据库落单、支付等逻辑。
- 这样,最耗时的写数据库操作被异步化,系统响应速度极快,数据库压力得到平滑。秒杀服务的核心职责在“裁决”谁抢到了资格,而不是完成整个订单流程。
-
核心代码示例二:异步下单,通过MQ削峰填谷
// 承接上面的secKill方法
private void sendOrderMessage(long skuid, long userId) {
// 构造订单消息
OrderMessage message = new OrderMessage(skuid, userId);
String msgJson = JSON.toJSONString(message);
// 发送到消息队列(以RocketMQ为例)
Message mqMsg = new Message("SECKILL_ORDER_TOPIC", msgJson.getBytes());
try {
rocketMQTemplate.getProducer().send(mqMsg);
} catch (Exception e) {
logger.error("消息发送失败,需要回滚库存!", e);
// 【重要】发送失败,需要将Redis库存加回去,防止少卖
redisTemplate.opsForValue().increment("sk:" + skuid + ":stock");
throw new RuntimeException("系统繁忙,请重试");
}
}
// 消息消费者,异步创建数据库订单
@Component
public class OrderConsumer {
@Autowired
private OrderService orderService;
@RocketMQMessageListener(topic = "SECKILL_ORDER_TOPIC", consumerGroup = "seckill_order_group")
public void handleOrderMessage(String message) {
// 1. 解析消息
OrderMessage orderMessage = JSON.parseObject(message, OrderMessage.class);
// 2. 检查是否已下单(防重复消费)
if (orderService.isOrderExists(orderMessage)) {
return; // 已经处理过,直接返回
}
// 3. 【异步写入数据库】创建正式订单
orderService.createRealOrder(orderMessage);
// 4. 这里还可以进行其他耗时操作,如更新用户画像、发送短信通知等
}
}
代码解释:
- 削峰:瞬间成千上万的请求在
sendOrderMessage这里就迅速返回了,真正的数据库压力被平滑地分摊到多个消息消费者上。 - 解耦:秒杀核心逻辑与复杂的订单、支付逻辑分离开,系统更健壮。
4. 业务与系统隔离
不要让你的秒杀系统影响到主站!这是保障整体稳定性的底线思维。
- 理论要点:
- 业务隔离:将秒杀作为一种营销活动,卖家需单独报名。这样技术团队就能提前知晓热点,做好准备,称之为“已知热点”。
- 系统隔离:
- 分组部署:秒杀系统单独一个集群,与常规业务服务器物理隔离。
- 独立域名:为秒杀申请独立域名(如
seckill.xxx.com),通过DNS直接解析到秒杀集群。
- 数据隔离:为秒杀业务使用独立的缓存集群和数据库(或至少是独立数据库实例)。避免秒杀的热点数据占满公共缓存的容量,或秒杀的写请求拖垮主库,真正做到故障隔离。
三、面试加分项:聊聊短链接
秒杀场景下,为了在短信、社交媒体中传播和提升用户体验,常常需要生成短链接。
-
理论要点: 主流方案:
- 分布式ID生成器(如Snowflake算法、美团Leaf)生成一个全局唯一的ID。
- 将这个ID转换为更短的62进制字符串(由0-9, a-z, A-Z组成)。
- 将这个字符串作为短链码,与原始长链接的映射关系存入数据库,并设置合理的过期时间。
核心难点在于分布式ID的生成,由于短链无需严格递增,可以采用“预分发号段”的方式,性能极高。
-
核心代码示例三:分布式ID生成与62进制转换
@Service
public class ShortUrlService {
// 假设我们有一个分布式ID生成器
@Autowired
private DistributedIdGenerator idGenerator;
public String generateShortUrl(String longUrl) {
// 1. 生成分布式ID
long id = idGenerator.nextId();
// 2. 转换为62进制
String shortCode = toBase62(id);
// 3. 存储映射关系 (shortCode -> longUrl)
redisTemplate.opsForValue().set("short_url:" + shortCode, longUrl, 7, TimeUnit.DAYS);
return "https://s.cn/" + shortCode;
}
private String toBase62(long id) {
String chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
StringBuilder sb = new StringBuilder();
do {
int remainder = (int) (id % 62);
sb.append(chars.charAt(remainder));
id = id / 62;
} while (id > 0);
return sb.reverse().toString();
}
}
总结回顾
现在,我们脑海中的防御图变得清晰而具体:
用户请求
➡️ 第一层(前端/网关): (静态化/CDN) | (按钮置灰/答题) | (网关限流)
➡️ 第二层(应用/缓存): [Lua脚本原子扣库存]
➡️ 第三层(异步化): [发送MQ消息] → 立即返回用户结果
➡️ 第四层(数据持久化): [消费者异步落库]
➡️ 底层支撑(隔离): (独立部署与数据源)
通过这种 “层层削峰、分而治之” 的架构,并结合 缓存、异步、隔离 三大法宝,我们就能构建出一个能够应对瞬时海量并发的高可用秒杀系统。
记住,面试时不仅要讲出这张图,更要能阐述清楚每个环节的 “为什么” (理论难点)和 “怎么做” (代码实现),这样才能真正打动面试官。
本文由微信公众号“程序员小胖”整理发布,转载请注明出处。
每日一道面试题,助你斩获心仪Offer!欢迎关注!