设计秒杀系统的前端
问题
如何设计一个高并发秒杀/抢购系统的前端架构?从页面静态化、倒计时精准控制、请求优化、安全防护到降级容错,请详细说明核心模块的设计思路与关键技术实现。
答案
秒杀系统是前端面临的最极端的高并发场景之一——在一个精确的时间点,海量用户同时发起请求争抢有限库存。前端在这个过程中扮演着至关重要的角色:精准控制用户操作时机、过滤无效请求减轻服务端压力、保障用户体验的流畅性,同时还要防范各种作弊手段。
一个设计良好的秒杀前端架构,能够在瞬时高并发下依然保持页面可用、操作流畅,同时将无效请求挡在后端之外,是前端工程能力的综合体现。
一、秒杀系统前端挑战
核心挑战
| 挑战维度 | 具体问题 | 难度 |
|---|---|---|
| 瞬时高并发 | 万级甚至十万级用户在同一秒点击抢购按钮,QPS 可达万级 | 极高 |
| 时间精准性 | 倒计时必须精准,不能依赖客户端时间,误差需控制在毫秒级 | 高 |
| 用户体验 | 响应即时、状态明确、结果及时反馈,不能出现"卡死"或"无响应" | 高 |
| 防重复提交 | 同一用户多次点击只能产生一次有效请求 | 中 |
| 安全性 | 防脚本刷单、防接口暴露、防机器人自动化抢购 | 高 |
| 降级容错 | 服务不可用时有兜底方案,不能出现白屏或错误页面 | 中 |
秒杀系统的核心矛盾是:有限的库存 vs 海量的请求。前端的首要职责不是"让请求更快送达后端",而是尽可能拦截无效请求,减少打到后端的流量。面试中能体现这个认知非常重要。
前端 vs 后端职责边界
| 职责 | 前端 | 后端 |
|---|---|---|
| 时间控制 | 展示倒计时、控制按钮状态 | 提供服务端时间、校验请求时间 |
| 流量控制 | 防重复提交、请求节流、前端排队 | 网关限流、令牌桶、队列削峰 |
| 库存展示 | 实时展示库存状态 | 库存扣减、一致性保证 |
| 安全防护 | 验证码、接口签名、混淆 | Token 校验、风控系统、IP 限制 |
| 降级兜底 | CDN 降级页、错误边界、本地缓存 | 熔断降级、限流拒绝、排队机制 |
二、整体架构设计
前端核心模块
三、页面静态化与 CDN
秒杀页面的第一个优化策略就是静态化——将秒杀页面做成纯静态页面部署到 CDN,动态数据通过 Ajax 异步加载。这样即使后端服务崩溃,页面本身依然可以正常展示。
静态化策略
// 核心原则:秒杀页面与主站隔离,独立部署到 CDN
interface SeckillPageConfig {
/** 秒杀活动 ID */
activityId: string;
/** CDN 域名,与主站不同 */
cdnDomain: string;
/** 动态接口域名 */
apiDomain: string;
/** 降级页面 URL */
fallbackUrl: string;
}
// 页面加载策略
const loadSeckillPage = async (config: SeckillPageConfig): Promise<void> => {
// 1. 静态 HTML + CSS + JS 从 CDN 加载(已预渲染)
// 2. 异步加载动态数据
try {
const [serverTime, activityInfo, userInfo] = await Promise.allSettled([
fetchServerTime(config.apiDomain),
fetchActivityInfo(config.activityId),
fetchUserInfo(),
]);
// 3. 初始化倒计时(基于服务端时间)
if (serverTime.status === 'fulfilled') {
initCountdown(serverTime.value);
}
// 4. 渲染活动信息
if (activityInfo.status === 'fulfilled') {
renderActivityInfo(activityInfo.value);
}
} catch {
// 5. 降级:展示骨架屏 + 重试按钮
showFallbackUI();
}
};
静态化部署架构
| 层级 | 内容 | 缓存策略 | 说明 |
|---|---|---|---|
| HTML | 秒杀页面骨架 | s-maxage=300 CDN 缓存 5 分钟 | 包含骨架屏,不含动态数据 |
| JS/CSS | 业务逻辑和样式 | max-age=31536000 长期缓存 + hash | 资源指纹命名,可长期缓存 |
| 图片 | 商品图、背景图 | CDN 缓存 + WebP 自适应 | 提前预加载关键图片 |
| 动态数据 | 库存、倒计时、用户态 | 不缓存或短缓存 | Ajax 异步获取 |
秒杀页面使用独立域名(如 seckill.example.com),好处是:
- 流量隔离:秒杀流量不影响主站
- 独立扩容:CDN 可针对秒杀域名单独扩容
- 快速回滚:出问题时可快速切换到降级页面
- 缓存控制:缓存策略可独立配置,不受主站影响
更多缓存策略参考:浏览器缓存机制
四、倒计时设计
倒计时是秒杀前端最核心的模块之一,直接决定用户能否在正确的时间点发起请求。倒计时的精准度和可靠性至关重要。
4.1 为什么不能依赖客户端时间
// 客户端时间不可信的三个原因:
// 1. 用户可以手动修改系统时间
// 2. 不同设备时钟有偏差(可达数秒甚至数分钟)
// 3. 时区设置可能错误
// 错误示例:使用客户端时间
const badCountdown = (): void => {
const now = Date.now(); // 不可信!
const remaining = targetTime - now;
// 用户修改系统时间就能提前抢购
};
// 正确方案:使用服务端时间 + 本地补偿
const getServerTimeDiff = async (): Promise<number> => {
const beforeRequest = Date.now();
const response = await fetch('/api/time');
const afterRequest = Date.now();
const serverTime = (await response.json()).timestamp;
// 网络延迟补偿:假设请求耗时的一半是单程延迟
const networkDelay = (afterRequest - beforeRequest) / 2;
const correctedServerTime = serverTime + networkDelay;
// 返回服务端时间与本地时间的差值
return correctedServerTime - afterRequest;
};
4.2 高精度倒计时实现
使用 requestAnimationFrame 替代 setInterval,实现更精准的倒计时渲染。定期与服务端时间同步校正误差。
interface CountdownState {
/** 服务端与本地的时间差(ms) */
timeDiff: number;
/** 秒杀开始时间戳(ms) */
targetTime: number;
/** 是否正在运行 */
isRunning: boolean;
/** rAF ID */
rafId: number | null;
/** 上次同步时间 */
lastSyncTime: number;
/** 同步间隔(ms),默认 30 秒 */
syncInterval: number;
}
type CountdownCallback = (remaining: number) => void;
class PrecisionCountdown {
private state: CountdownState;
private onTick: CountdownCallback;
private onEnd: () => void;
constructor(
targetTime: number,
onTick: CountdownCallback,
onEnd: () => void
) {
this.state = {
timeDiff: 0,
targetTime,
isRunning: false,
rafId: null,
lastSyncTime: 0,
syncInterval: 30_000, // 每 30 秒与服务端同步一次
};
this.onTick = onTick;
this.onEnd = onEnd;
}
/** 初始化:同步服务端时间 */
async init(): Promise<void> {
await this.syncServerTime();
this.start();
}
/** 与服务端同步时间 */
private async syncServerTime(): Promise<void> {
try {
// 多次采样取中位数,减少网络波动影响
const diffs: number[] = [];
for (let i = 0; i < 3; i++) {
const before = performance.now();
const res = await fetch('/api/seckill/time');
const after = performance.now();
const { timestamp } = await res.json();
const rtt = after - before;
const serverNow = timestamp + rtt / 2;
const localNow = Date.now();
diffs.push(serverNow - localNow);
}
// 取中位数作为时间差
diffs.sort((a, b) => a - b);
this.state.timeDiff = diffs[Math.floor(diffs.length / 2)];
this.state.lastSyncTime = Date.now();
} catch {
console.warn('时间同步失败,使用上次同步结果');
}
}
/** 获取校正后的当前时间 */
private getCorrectedNow(): number {
return Date.now() + this.state.timeDiff;
}
/** 启动倒计时 */
private start(): void {
this.state.isRunning = true;
const tick = (): void => {
if (!this.state.isRunning) return;
const now = this.getCorrectedNow();
const remaining = this.state.targetTime - now;
if (remaining <= 0) {
this.onTick(0);
this.onEnd(); // 倒计时结束,触发抢购逻辑
this.stop();
return;
}
this.onTick(remaining);
// 定期重新同步服务端时间
if (now - this.state.lastSyncTime > this.state.syncInterval) {
this.syncServerTime();
}
this.state.rafId = requestAnimationFrame(tick);
};
this.state.rafId = requestAnimationFrame(tick);
}
/** 停止倒计时 */
stop(): void {
this.state.isRunning = false;
if (this.state.rafId !== null) {
cancelAnimationFrame(this.state.rafId);
this.state.rafId = null;
}
}
/** 格式化剩余时间 */
static format(remaining: number): string {
const hours = Math.floor(remaining / 3_600_000);
const minutes = Math.floor((remaining % 3_600_000) / 60_000);
const seconds = Math.floor((remaining % 60_000) / 1_000);
const ms = Math.floor((remaining % 1_000) / 10);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
}
}
// 使用示例
const countdown = new PrecisionCountdown(
1735689600000, // 秒杀开始时间
(remaining) => {
document.getElementById('countdown')!.textContent =
PrecisionCountdown.format(remaining);
},
() => {
// 倒计时结束,启用抢购按钮
enableSeckillButton();
}
);
countdown.init();
setInterval(fn, 1000)的实际执行间隔受主线程阻塞影响,可能出现 1000ms+ 的误差累积requestAnimationFrame与屏幕刷新率同步(通常 16.67ms),精度更高- 使用
performance.now()进行差值计算,比Date.now()精度高出数个数量级 - 更多关于 rAF 的细节参考:requestAnimationFrame
五、按钮状态管理与防重复提交
5.1 按钮状态机
秒杀按钮不是简单的"可点击"和"不可点击"两种状态,而是一个完整的状态机:
5.2 状态机实现
/** 按钮状态枚举 */
enum SeckillButtonState {
/** 未开始:倒计时中 */
NOT_STARTED = 'NOT_STARTED',
/** 就绪:可以点击 */
READY = 'READY',
/** 提交中:请求发送中 */
SUBMITTING = 'SUBMITTING',
/** 排队中:等待后端处理结果 */
QUEUING = 'QUEUING',
/** 成功 */
SUCCESS = 'SUCCESS',
/** 失败:可重试 */
FAILED = 'FAILED',
/** 已售罄 */
SOLD_OUT = 'SOLD_OUT',
}
/** 按钮 UI 配置 */
interface ButtonUIConfig {
text: string;
disabled: boolean;
className: string;
}
const BUTTON_CONFIG: Record<SeckillButtonState, ButtonUIConfig> = {
[SeckillButtonState.NOT_STARTED]: {
text: '即将开始',
disabled: true,
className: 'btn-disabled',
},
[SeckillButtonState.READY]: {
text: '立即抢购',
disabled: false,
className: 'btn-primary btn-pulse',
},
[SeckillButtonState.SUBMITTING]: {
text: '提交中...',
disabled: true,
className: 'btn-loading',
},
[SeckillButtonState.QUEUING]: {
text: '排队中...',
disabled: true,
className: 'btn-queuing',
},
[SeckillButtonState.SUCCESS]: {
text: '抢购成功!去付款',
disabled: false,
className: 'btn-success',
},
[SeckillButtonState.FAILED]: {
text: '再试一次',
disabled: false,
className: 'btn-retry',
},
[SeckillButtonState.SOLD_OUT]: {
text: '已售罄',
disabled: true,
className: 'btn-sold-out',
},
};
/** 合法的状态转换定义 */
const VALID_TRANSITIONS: Record<SeckillButtonState, SeckillButtonState[]> = {
[SeckillButtonState.NOT_STARTED]: [SeckillButtonState.READY, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.READY]: [SeckillButtonState.SUBMITTING, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.SUBMITTING]: [SeckillButtonState.QUEUING, SeckillButtonState.SUCCESS, SeckillButtonState.FAILED, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.QUEUING]: [SeckillButtonState.SUCCESS, SeckillButtonState.FAILED, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.SUCCESS]: [],
[SeckillButtonState.FAILED]: [SeckillButtonState.READY, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.SOLD_OUT]: [],
};
class SeckillButtonController {
private state: SeckillButtonState = SeckillButtonState.NOT_STARTED;
private listeners: Set<(state: SeckillButtonState) => void> = new Set();
getState(): SeckillButtonState {
return this.state;
}
getUIConfig(): ButtonUIConfig {
return BUTTON_CONFIG[this.state];
}
transition(newState: SeckillButtonState): boolean {
const allowed = VALID_TRANSITIONS[this.state];
if (!allowed.includes(newState)) {
console.warn(`非法状态转换: ${this.state} → ${newState}`);
return false;
}
this.state = newState;
this.listeners.forEach((listener) => listener(newState));
return true;
}
onStateChange(listener: (state: SeckillButtonState) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
5.3 防重复提交
// 三重防护:按钮禁用 + 请求节流 + Token 校验
class SeckillRequestGuard {
private isSubmitting = false;
private submitToken: string | null = null;
/** 1. 提交前获取一次性 Token */
async prepareToken(activityId: string): Promise<void> {
const res = await fetch(`/api/seckill/token?activityId=${activityId}`);
const data = await res.json();
this.submitToken = data.token; // 一次性 Token,用后即失效
}
/** 2. 提交秒杀请求 */
async submit(activityId: string, skuId: string): Promise<SeckillResult> {
// 第一重:内存锁防止并发提交
if (this.isSubmitting) {
return { success: false, message: '请勿重复提交' };
}
// 第二重:Token 校验
if (!this.submitToken) {
return { success: false, message: 'Token 无效,请刷新页面' };
}
this.isSubmitting = true;
try {
const res = await fetch('/api/seckill/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Seckill-Token': this.submitToken, // 一次性 Token
},
body: JSON.stringify({ activityId, skuId }),
});
// Token 用后置空,防止重复使用
this.submitToken = null;
return await res.json();
} finally {
this.isSubmitting = false;
}
}
}
interface SeckillResult {
success: boolean;
message: string;
orderId?: string;
queuePosition?: number;
}
前端节流和防抖是防重复提交的基础手段,更多实现细节参考:限流调度器
六、请求优化
6.1 前端请求限流
在秒杀场景中,前端需要主动限流,避免无效请求打到后端。
/** 秒杀请求优化器 */
class SeckillRequestOptimizer {
private requestQueue: Array<() => Promise<void>> = [];
private maxConcurrent: number;
private running = 0;
constructor(maxConcurrent = 1) {
this.maxConcurrent = maxConcurrent;
}
/** 添加请求到队列 */
async enqueue<T>(requestFn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const task = async (): Promise<void> => {
this.running++;
try {
const result = await requestFn();
resolve(result);
} catch (err) {
reject(err);
} finally {
this.running--;
this.runNext();
}
};
if (this.running < this.maxConcurrent) {
task();
} else {
this.requestQueue.push(task);
}
});
}
private runNext(): void {
if (this.requestQueue.length > 0 && this.running < this.maxConcurrent) {
const next = this.requestQueue.shift();
next?.();
}
}
}
/**
* 秒杀请求:精简请求体
* 秒杀接口只传必要参数,减少请求体积
*/
interface SeckillRequest {
/** 活动 ID */
a: string;
/** SKU ID */
s: string;
/** Token */
t: string;
/** 时间戳 */
ts: number;
/** 签名 */
sign: string;
}
const buildSeckillRequest = (
activityId: string,
skuId: string,
token: string
): SeckillRequest => {
const ts = Date.now();
return {
a: activityId,
s: skuId,
t: token,
ts,
sign: generateSign(activityId, skuId, token, ts), // 接口签名
};
};
6.2 WebSocket 实时推送 vs 轮询
/** 库存实时更新策略 */
// 方案一:WebSocket(推荐,延迟低、服务端主动推送)
class StockWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnects = 5;
connect(activityId: string): void {
this.ws = new WebSocket(`wss://seckill.example.com/ws/stock/${activityId}`);
this.ws.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data) as StockUpdate;
this.handleStockUpdate(data);
};
this.ws.onclose = () => {
if (this.reconnectAttempts < this.maxReconnects) {
// 指数退避重连
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10_000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect(activityId);
}, delay);
}
};
}
private handleStockUpdate(data: StockUpdate): void {
if (data.stock === 0) {
// 库存为零,立即更新按钮状态为"已售罄"
seckillButton.transition(SeckillButtonState.SOLD_OUT);
}
updateStockDisplay(data.stock);
}
disconnect(): void {
this.ws?.close();
this.ws = null;
}
}
interface StockUpdate {
activityId: string;
stock: number;
timestamp: number;
}
// 方案二:短轮询(WebSocket 不可用时的降级方案)
class StockPolling {
private timer: ReturnType<typeof setInterval> | null = null;
start(activityId: string, interval = 3000): void {
this.timer = setInterval(async () => {
try {
const res = await fetch(`/api/seckill/stock?id=${activityId}`);
const data: StockUpdate = await res.json();
updateStockDisplay(data.stock);
} catch {
// 静默失败,下次轮询再试
}
}, interval);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}
6.3 WebSocket vs 轮询对比
| 特性 | WebSocket | 短轮询 |
|---|---|---|
| 延迟 | 实时推送,几乎零延迟 | 取决于轮询间隔(秒级) |
| 服务端压力 | 维持长连接,内存占用高 | 频繁建立/断开连接 |
| 带宽消耗 | 低(只在有更新时发数据) | 高(空轮询也有开销) |
| 实现复杂度 | 高(需要心跳、重连、集群) | 低(普通 HTTP 请求) |
| 适用场景 | 秒杀进行中的高频库存更新 | 秒杀前的低频状态轮询 |
| 降级方案 | 降级为轮询 | 无需降级 |
实际项目中推荐混合策略:秒杀开始前使用轮询(频率低、实现简单),秒杀开始时切换为 WebSocket(实时性强)。如果 WebSocket 连接失败,自动降级为短轮询。更多实时通信方案参考:WebSocket 与 SSE
七、安全防护
7.1 安全防护体系
7.2 接口签名与动态 Token
/**
* 安全模块:接口签名 + 动态 Token + 时间戳校验
*/
class SeckillSecurity {
private secretKey: string;
constructor(secretKey: string) {
this.secretKey = secretKey;
}
/**
* 生成请求签名
* 将关键参数拼接后进行 HMAC 签名,防止参数篡改
*/
async generateSign(params: Record<string, string | number>): Promise<string> {
// 1. 参数按 key 排序
const sortedKeys = Object.keys(params).sort();
const signStr = sortedKeys.map((k) => `${k}=${params[k]}`).join('&');
// 2. HMAC-SHA256 签名
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(this.secretKey),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(signStr)
);
// 3. 转为 hex 字符串
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* 请求频率检测(前端初步拦截)
*/
private requestTimestamps: number[] = [];
checkFrequency(maxRequests = 3, windowMs = 10_000): boolean {
const now = Date.now();
// 清理过期记录
this.requestTimestamps = this.requestTimestamps.filter(
(t) => now - t < windowMs
);
if (this.requestTimestamps.length >= maxRequests) {
return false; // 频率过高
}
this.requestTimestamps.push(now);
return true;
}
}
7.3 验证码与人机识别
/** 验证码集成方案 */
interface CaptchaProvider {
/** 初始化验证码 */
init(containerId: string): Promise<void>;
/** 验证并获取 token */
verify(): Promise<string>;
/** 重置 */
reset(): void;
}
// 秒杀前弹出滑块验证,通过后才能获取秒杀 Token
class SeckillCaptchaFlow {
private captcha: CaptchaProvider;
constructor(captcha: CaptchaProvider) {
this.captcha = captcha;
}
/**
* 倒计时最后 N 秒弹出验证码
* 好处:提前验证,不占用秒杀时间
*/
async preSeckillVerify(): Promise<string | null> {
try {
const captchaToken = await this.captcha.verify();
// 用验证码 token 换取秒杀 token
const res = await fetch('/api/seckill/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ captchaToken }),
});
const data = await res.json();
return data.seckillToken;
} catch {
return null;
}
}
}
前端安全措施只能提高攻击成本,不能完全防止作弊。核心防护必须在服务端完成:
- 接口签名密钥不能直接暴露在前端代码中,应通过服务端动态下发或使用临时密钥
- Token 校验、库存扣减、订单生成等核心逻辑必须在服务端执行
- 前端代码即使混淆也可以被反编译,不要在前端存放任何敏感逻辑
八、降级与容错
8.1 多级降级策略
8.2 降级实现
/** 降级策略管理器 */
class SeckillFallback {
/**
* 接口超时降级
* 超时后不直接展示失败,而是轮询结果
*/
async submitWithFallback(
submitFn: () => Promise<SeckillResult>,
timeoutMs = 5000
): Promise<SeckillResult> {
try {
const result = await Promise.race([
submitFn(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), timeoutMs)
),
]);
return result;
} catch (err) {
if (err instanceof Error && err.message === 'timeout') {
// 超时不代表失败,可能在排队中
return {
success: false,
message: '系统繁忙,正在排队处理中...',
queuePosition: -1, // 未知排队位置
};
}
return { success: false, message: '网络异常,请稍后重试' };
}
}
/**
* 轮询订单结果
* 秒杀提交后,异步轮询订单是否生成成功
*/
async pollOrderResult(
orderId: string,
maxAttempts = 10,
interval = 2000
): Promise<OrderResult> {
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await fetch(`/api/seckill/result?orderId=${orderId}`);
const data: OrderResult = await res.json();
if (data.status !== 'pending') {
return data;
}
} catch {
// 网络错误,继续轮询
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
return {
status: 'unknown',
message: '结果查询超时,请到"我的订单"中查看',
};
}
/**
* React ErrorBoundary 降级
*/
static renderFallbackUI(): string {
return `
<div class="seckill-error">
<h2>页面加载异常</h2>
<p>秒杀页面遇到问题,请刷新重试</p>
<button onclick="location.reload()">刷新页面</button>
</div>
`;
}
}
interface OrderResult {
status: 'success' | 'failed' | 'pending' | 'unknown';
message: string;
orderId?: string;
payUrl?: string;
}
8.3 CDN 降级页面
/**
* CDN 层降级配置
* 在 Nginx / CDN 配置中,当源站返回 5xx 时自动返回降级页面
*/
// 降级页面是纯静态 HTML,提前部署到 CDN
// 不依赖任何 API 接口,确保在服务完全不可用时也能展示
const FALLBACK_HTML = `
<!DOCTYPE html>
<html>
<head><title>秒杀活动</title></head>
<body>
<div class="fallback">
<h1>系统繁忙</h1>
<p>当前参与人数过多,请稍后刷新再试</p>
<p>您也可以到"我的订单"中查看是否抢购成功</p>
<button onclick="location.reload()">刷新页面</button>
</div>
</body>
</html>
`;
// Nginx 降级配置示例(伪代码)
// location /seckill/ {
// proxy_pass http://seckill-backend;
// proxy_intercept_errors on;
// error_page 502 503 504 /fallback/seckill.html;
// }
九、性能优化
9.1 关键路径优化
/**
* 首屏只加载秒杀核心模块
* 非核心模块(商品详情、评论、推荐)延迟加载
*/
// 核心模块:倒计时 + 按钮 + 请求(内联或预加载)
// 非核心模块:商品详情、评论、推荐(懒加载)
// React 代码分割示例
const SeckillCore = React.lazy(() => import('./SeckillCore'));
const ProductDetail = React.lazy(() => import('./ProductDetail'));
const Comments = React.lazy(() => import('./Comments'));
const Recommendations = React.lazy(() => import('./Recommendations'));
const SeckillPage: React.FC = () => {
return (
<>
{/* 核心模块:不用 Suspense 包裹,直接内联 */}
<CountdownTimer />
<SeckillButton />
{/* 非核心模块:懒加载 */}
<React.Suspense fallback={<Skeleton />}>
<ProductDetail />
</React.Suspense>
<React.Suspense fallback={null}>
<Comments />
<Recommendations />
</React.Suspense>
</>
);
};
9.2 资源预加载策略
<!-- 关键资源预加载 -->
<link rel="preconnect" href="https://seckill-api.example.com" />
<link rel="dns-prefetch" href="https://seckill-api.example.com" />
<!-- 秒杀核心 JS 预加载 -->
<link rel="preload" href="/js/seckill-core.js" as="script" />
<!-- 商品图片预加载 -->
<link rel="preload" href="/img/product-main.webp" as="image" />
<!-- 秒杀结果页预取(用户抢购后大概率跳转) -->
<link rel="prefetch" href="/seckill/result.html" />
9.3 Web Worker 处理计算密集任务
// 将加密签名、倒计时校正等 CPU 密集型任务放到 Worker 中
// worker.ts
self.addEventListener('message', async (event: MessageEvent) => {
const { type, payload } = event.data;
switch (type) {
case 'GENERATE_SIGN': {
// 在 Worker 中进行签名计算,不阻塞主线程
const sign = await computeHMAC(payload.params, payload.key);
self.postMessage({ type: 'SIGN_RESULT', sign });
break;
}
case 'CORRECT_TIME': {
// 多次采样计算时间差
const diffs = await measureTimeDiffs(payload.url, payload.samples);
const median = getMedian(diffs);
self.postMessage({ type: 'TIME_DIFF', diff: median });
break;
}
}
});
// 主线程
const seckillWorker = new Worker('/workers/seckill-worker.js');
seckillWorker.postMessage({
type: 'GENERATE_SIGN',
payload: { params: requestParams, key: tempKey },
});
seckillWorker.addEventListener('message', (event: MessageEvent) => {
if (event.data.type === 'SIGN_RESULT') {
sendSeckillRequest(event.data.sign);
}
});
- 首屏优化策略:首屏优化
- Web Worker 详细用法:Web Workers
- React 性能优化:React 性能优化
十、实际案例分析
10.1 电商秒杀方案(淘宝双 11、京东秒杀)
| 策略 | 淘宝双 11 | 京东秒杀 |
|---|---|---|
| 页面方案 | 预渲染 + CDN 静态化 | 独立秒杀域名 + CDN |
| 倒计时 | 服务端时间同步 + 毫秒级精度 | NTP 校时 + rAF 渲染 |
| 流量控制 | 答题验证码削峰 | 预约制 + 排队机制 |
| 库存展示 | 模糊库存("库存紧张") | 实时库存倒数 |
| 降级方案 | 多级降级 + 限流排队页 | CDN 降级 + 异步出结果 |
| 安全防护 | 风控系统 + 行为验证 | 滑块验证 + 设备指纹 |
10.2 抢票系统(12306)
12306 的前端策略在高并发场景下具有参考价值:
- 排队机制:用户提交后进入排队队列,前端轮询排队进度
- 候补购票:抢票失败后自动进入候补队列,减少用户反复刷新
- 验证码前置:在关键操作前弹出验证码,过滤机器人
- 分时段放票:错峰放票减少同时并发
/** 模拟 12306 排队轮询机制 */
class QueuePolling {
async waitInQueue(queueId: string): Promise<QueueResult> {
const maxWaitTime = 60_000; // 最长等待 60 秒
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
const res = await fetch(`/api/seckill/queue/${queueId}`);
const data: QueueStatus = await res.json();
// 更新排队 UI
updateQueueUI(data);
if (data.status === 'completed') {
return { success: true, orderId: data.orderId! };
}
if (data.status === 'failed') {
return { success: false, reason: data.reason! };
}
// 根据排队位置动态调整轮询间隔
const pollInterval = data.position > 100 ? 5000 : 2000;
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
return { success: false, reason: '排队超时,请查看订单列表' };
}
}
interface QueueStatus {
status: 'waiting' | 'processing' | 'completed' | 'failed';
position: number;
estimatedWaitTime: number;
orderId?: string;
reason?: string;
}
interface QueueResult {
success: boolean;
orderId?: string;
reason?: string;
}
const updateQueueUI = (data: QueueStatus): void => {
const el = document.getElementById('queue-info')!;
el.innerHTML = `
<p>当前排队位置:第 ${data.position} 位</p>
<p>预计等待时间:${Math.ceil(data.estimatedWaitTime / 1000)} 秒</p>
`;
};
常见面试问题
Q1: 秒杀系统的前端架构应该如何设计?
答案:
秒杀系统前端架构需要围绕"减少无效请求、保障用户体验、多级降级容错"三个核心目标展开:
1. 分层架构:
| 层级 | 职责 | 关键技术 |
|---|---|---|
| CDN 层 | 静态资源加速、降级兜底 | CDN + 预渲染 + 独立域名 |
| 前端应用层 | 倒计时、按钮控制、请求管理 | rAF + 状态机 + 请求队列 |
| 安全层 | 防刷、签名、人机验证 | HMAC 签名 + 验证码 + Token |
| 通信层 | 实时库存、排队状态 | WebSocket + 轮询降级 |
2. 核心模块:
// 秒杀前端核心模块
interface SeckillFrontendArchitecture {
/** 页面静态化:CDN + 预渲染,动态数据异步加载 */
staticPage: CDNConfig;
/** 倒计时引擎:服务端时间同步 + rAF 高精度渲染 */
countdown: PrecisionCountdown;
/** 按钮状态机:6 种状态,严格的转换规则 */
buttonFSM: SeckillButtonController;
/** 请求控制:节流 + 队列 + 签名 + Token */
requestGuard: SeckillRequestGuard;
/** 降级策略:超时降级 + CDN 降级 + 本地缓存 */
fallback: SeckillFallback;
}
3. 与后端协作方式:
- 秒杀开始前:前端通过 HTTP 接口获取活动信息和服务端时间
- 秒杀进行中:通过 WebSocket 实时接收库存更新
- 秒杀提交后:前端提交请求后,后端返回排队 ID,前端轮询结果
Q2: 如何实现一个精准的秒杀倒计时?
答案:
精准倒计时的三个关键点:服务端时间同步、requestAnimationFrame 渲染、定期误差校正。
// 1. 服务端时间同步:多次采样取中位数
const syncTime = async (): Promise<number> => {
const diffs: number[] = [];
for (let i = 0; i < 3; i++) {
const t1 = Date.now();
const res = await fetch('/api/time');
const t2 = Date.now();
const serverTime = (await res.json()).timestamp;
// RTT 补偿:假设去程和回程各占一半
diffs.push(serverTime + (t2 - t1) / 2 - t2);
}
diffs.sort((a, b) => a - b);
return diffs[1]; // 中位数
};
// 2. rAF 渲染:精度远高于 setInterval
const startCountdown = (targetTime: number, timeDiff: number): void => {
const tick = (): void => {
const correctedNow = Date.now() + timeDiff;
const remaining = targetTime - correctedNow;
if (remaining <= 0) {
enableButton();
return;
}
renderTime(remaining);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
};
// 3. 定期校正:每 30 秒重新同步一次
// 防止长时间运行后误差累积
为什么不用 setInterval?
| 对比项 | setInterval(fn, 1000) | requestAnimationFrame |
|---|---|---|
| 精度 | 可能延迟数十毫秒 | 与屏幕刷新率同步(~16.67ms) |
| 主线程阻塞 | 被阻塞时跳帧 | 浏览器自动调度 |
| 页面不可见时 | 继续执行(浪费资源) | 自动暂停 |
| 误差累积 | 长时间运行误差明显 | 每帧重新计算差值,无累积 |
Q3: 如何防止用户重复提交秒杀请求?
答案:
采用三重防护策略:
class TripleProtection {
private isSubmitting = false;
private token: string | null = null;
private lastClickTime = 0;
private readonly THROTTLE_MS = 1000; // 1 秒内只允许点击一次
async handleClick(activityId: string): Promise<SeckillResult | null> {
// 第一重:前端节流
const now = Date.now();
if (now - this.lastClickTime < this.THROTTLE_MS) {
return null;
}
this.lastClickTime = now;
// 第二重:内存锁(防并发)
if (this.isSubmitting) {
return null;
}
this.isSubmitting = true;
try {
// 第三重:一次性 Token
if (!this.token) {
return { success: false, message: 'Token 已过期' };
}
const result = await submitSeckill(activityId, this.token);
this.token = null; // Token 用后即废
return result;
} finally {
this.isSubmitting = false;
}
}
}
补充手段:
- 按钮状态机:提交后立即将按钮切换为"提交中"状态(disabled)
- 后端幂等性:同一用户 + 同一活动只能生成一个订单
- 请求去重:网关层根据用户 ID + 活动 ID 去重
Q4: 秒杀页面的性能优化有哪些策略?
答案:
| 优化方向 | 具体策略 | 效果 |
|---|---|---|
| 静态化 | 页面预渲染 + CDN 部署 | 首屏秒开,不依赖后端 |
| 资源优化 | 关键 CSS 内联 + JS 最小化 | 减少请求数和体积 |
| 代码分割 | 核心模块内联、非核心懒加载 | 减少首屏 JS 体积 60%+ |
| 预加载 | preconnect + preload + prefetch | 提前建连、预取资源 |
| 图片优化 | WebP/AVIF + 懒加载 + CDN 裁剪 | 图片体积减少 50%+ |
| Web Worker | 签名计算、时间校正在 Worker 中执行 | 不阻塞主线程 UI 渲染 |
| Service Worker | 缓存静态资源和降级页面 | 离线可用,秒开体验 |
| 接口精简 | 请求/响应体最小化、字段压缩 | 减少网络传输时间 |
// Service Worker 缓存秒杀静态资源
const SECKILL_CACHE = 'seckill-v1';
const CACHE_URLS = [
'/seckill/',
'/seckill/index.html',
'/seckill/core.js',
'/seckill/style.css',
'/seckill/fallback.html', // 降级页面也要缓存
];
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(SECKILL_CACHE).then((cache) => cache.addAll(CACHE_URLS))
);
});
// Network First:优先网络,失败时使用缓存
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.url.includes('/seckill/')) {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request) as Promise<Response>;
})
);
}
});
Q5: 如何防止脚本刷单?前端能做哪些安全防护?
答案:
前端安全防护是提高攻击成本的第一道防线,核心思路是让脚本难以模拟正常用户行为。
1. 人机验证(最有效):
// 倒计时最后 10 秒弹出滑块验证
// 好处:通过验证后才能获取秒杀 Token,脚本无法绕过
// 验证码时机:秒杀前 10 秒,不占用抢购时间
const handleCountdownNearEnd = async (remaining: number): Promise<void> => {
if (remaining <= 10_000 && !hasVerified) {
const captchaToken = await showCaptchaModal();
seckillToken = await exchangeForSeckillToken(captchaToken);
hasVerified = true;
}
};
2. 接口签名:
- 请求参数 + 时间戳 + 随机数 → HMAC 签名
- 服务端校验签名有效性和时间戳时效性
- 密钥通过服务端动态下发,定期轮换
3. 行为检测:
- 记录鼠标轨迹、点击间隔、页面停留时间
- 异常行为(如精确到毫秒的点击、无鼠标移动轨迹)触发风控
4. 代码混淆:
- 秒杀相关 JS 代码额外混淆
- 接口 URL 动态生成(不硬编码)
- 关键变量名随机化
前端安全只是"围栏",不是"城墙"。所有关键校验必须在服务端完成,前端防护只是增加攻击成本。
Q6: 秒杀接口请求失败了怎么办?如何设计降级方案?
答案:
降级方案需要按照严重程度分级处理:
enum FallbackLevel {
/** 级别 1:接口超时 → 展示排队中 */
TIMEOUT = 'TIMEOUT',
/** 级别 2:接口报错 → 允许重试 */
API_ERROR = 'API_ERROR',
/** 级别 3:服务不可用 → CDN 降级页 */
SERVICE_DOWN = 'SERVICE_DOWN',
/** 级别 4:CDN 也挂了 → Service Worker 缓存 */
CDN_DOWN = 'CDN_DOWN',
}
const handleFallback = async (
level: FallbackLevel
): Promise<void> => {
switch (level) {
case FallbackLevel.TIMEOUT:
// 不直接展示失败,改为轮询结果
showQueueingUI();
await pollOrderResult(orderId);
break;
case FallbackLevel.API_ERROR:
// 展示重试按钮,限制最多 3 次
if (retryCount < 3) {
showRetryButton();
} else {
showMessage('系统繁忙,请稍后到订单列表查看');
}
break;
case FallbackLevel.SERVICE_DOWN:
// 整个服务不可用,展示 CDN 静态降级页
window.location.href = '/seckill/fallback.html';
break;
case FallbackLevel.CDN_DOWN:
// Service Worker 兜底
showOfflineFallback();
break;
}
};
关键原则:
- 超时不等于失败:秒杀场景下超时很常见,应展示"排队中"而非"失败"
- 结果最终一致:即使前端未收到明确结果,也要引导用户去订单页确认
- 降级是预案:降级页面必须提前部署、提前测试,不能临时写
- 用户感知:每次降级都要给用户明确的提示和下一步操作指引
Q7: WebSocket 和轮询在秒杀场景中如何选择?
答案:
推荐混合策略,不同阶段使用不同方案:
| 阶段 | 推荐方案 | 原因 |
|---|---|---|
| 秒杀前(预热期) | 短轮询(5-10s 间隔) | 数据变化慢,轮询开销小 |
| 秒杀倒计时(最后 1 分钟) | WebSocket | 需要精准时间同步 |
| 秒杀进行中 | WebSocket | 库存实时变化,需要即时推送 |
| 秒杀结束后(等结果) | 短轮询(2-3s 间隔) | 查询个人订单结果,无需广播 |
class HybridRealtime {
private ws: StockWebSocket;
private polling: StockPolling;
private currentMode: 'polling' | 'websocket' = 'polling';
/** 根据阶段自动切换通信方式 */
switchMode(phase: 'preheat' | 'countdown' | 'active' | 'ended'): void {
switch (phase) {
case 'preheat':
this.usePolling(10_000); // 10 秒轮询
break;
case 'countdown':
case 'active':
this.useWebSocket(); // 切换到 WebSocket
break;
case 'ended':
this.usePolling(3_000); // 3 秒轮询查结果
break;
}
}
private useWebSocket(): void {
if (this.currentMode === 'websocket') return;
this.polling.stop();
this.ws.connect(this.activityId);
this.currentMode = 'websocket';
// WebSocket 连接失败时自动降级为轮询
this.ws.onError(() => {
console.warn('WebSocket 连接失败,降级为轮询');
this.usePolling(2_000);
});
}
private usePolling(interval: number): void {
if (this.currentMode === 'polling') return;
this.ws.disconnect();
this.polling.start(this.activityId, interval);
this.currentMode = 'polling';
}
}
WebSocket 注意事项:
- 秒杀场景下 WebSocket 连接数巨大,服务端需要专门的连接管理集群
- 必须有轮询降级方案,不能只依赖 WebSocket
- 消息格式尽量精简(考虑 Protobuf 序列化)
Q8: 如何设计秒杀页面的按钮状态机?
答案:
秒杀按钮状态机包含 7 种状态和严格的转换规则:
设计关键点:
- 状态转换必须有严格规则:不能从
SUCCESS跳回READY,不能从SOLD_OUT跳回任何状态 - 每个状态有唯一的 UI 表现:文案、颜色、是否可点击都明确定义
- 状态变化驱动 UI 更新:采用发布-订阅模式,状态变化自动触发 UI 渲染
// React Hook 封装
const useSeckillButton = (activityId: string) => {
const [state, setState] = useState<SeckillButtonState>(
SeckillButtonState.NOT_STARTED
);
const controller = useMemo(() => {
const ctrl = new SeckillButtonController();
ctrl.onStateChange(setState);
return ctrl;
}, []);
const handleClick = useCallback(async () => {
// 只有 READY 和 FAILED 状态可以点击
if (
state !== SeckillButtonState.READY &&
state !== SeckillButtonState.FAILED
) {
return;
}
controller.transition(SeckillButtonState.SUBMITTING);
try {
const result = await submitSeckill(activityId);
if (result.success) {
controller.transition(SeckillButtonState.SUCCESS);
} else if (result.message === 'sold_out') {
controller.transition(SeckillButtonState.SOLD_OUT);
} else {
controller.transition(SeckillButtonState.FAILED);
}
} catch {
controller.transition(SeckillButtonState.FAILED);
}
}, [state, activityId, controller]);
const config = BUTTON_CONFIG[state];
return { state, config, handleClick };
};
面试加分点:
- 提到有限状态机(FSM) 的概念,说明为什么用状态机而不是多个 boolean 变量
- 状态转换规则用白名单模式:只定义合法转换,未定义的一律拒绝
- 结合后端推送(WebSocket)和用户操作(点击)两种触发源
相关链接
内部文档
- 浏览器缓存机制 - CDN 缓存与强缓存策略
- React 性能优化 - React.memo、useMemo、代码分割
- Web Workers - Worker 线程、计算密集任务处理
- 首屏优化 - 关键渲染路径、预加载策略
- WebSocket 与 SSE - 实时通信、长连接管理
- 限流调度器 - 并发控制、请求节流
外部参考
- MDN - requestAnimationFrame - rAF 高精度动画
- MDN - Web Crypto API - 浏览器端加密签名
- MDN - Service Worker API - 离线缓存与降级
- MDN - Performance API - 高精度时间测量