项目里秒杀商品的详情页QPS能到多少?如果突然飙升到50万QPS,会出现什么瓶颈?怎么优化? 🔗
场景 🔗
百亿补贴详情页通常包含静态信息(图文、参数)和动态信息(价格、库存、优惠)。平时QPS可能在几千,但大促时可能瞬间飙升至50万QPS。
50万 QPS 下的瓶颈点 🔗
- 网络带宽瓶颈: 详情页HTML很大(几百KB),50万QPS * 200KB = 100Gbps的带宽出口,瞬间打满交换机和网卡。
- CPU/计算瓶颈: 如果动态计算价格或拼接HTML,CPU会成为瓶颈,尤其是大量的序列化/反序列化和JSON渲染。
- Redis/DB瓶颈: 即使是Redis,单机QPS也就10万左右,且50万连接会耗尽文件句柄,导致连接建立失败。
- 服务端连接数: 默认的内核参数(如ulimit)和全连接队列可能被占满,导致SYN丢弃。
优化方案(分层架构) 🔗
按调用链拆解:CDN → Nginx → 商品服务 → Redis → MySQL / ES
- 静态资源全面CDN加速(静态化):
- 思路: 将商品详情页的HTML、图片、CSS、JS全部静态化,推送到CDN边缘节点。
- 效果: 90%以上的流量直接在CDN边缘节点响应,根本不会打到你的源站服务器。
- 动态内容分离(动静分离):
- 思路: 将动态内容(价格、库存、优惠)从HTML中分离出来,通过API异步获取。
- 效果: 动态内容的QPS可以单独调整,而不会影响静态资源的访问。
- 多级缓存架构:
- 浏览器缓存: 对静态资源设置强缓存。
- 本地缓存: 应用服务器部署L1缓存(如Guava/Caffeine或Golang的bigcache)。价格变更不频繁时,直接读内存,QPS可达百万级。设置10~50msTTL,抵挡 Redis 抖动。
- Redis集群: 做分片,避开单点热点。如 sku_stock:123 → sku_stock:{123}_{0..9},通过 hash 取一个写时写多个 key
- 降级兜底:
- 思路: Redis 超时 → 返回“库存紧张”。
- 效果: ES 超时 → 返回默认排序
拼单成功后需同时扣减商品库存、更新拼单状态、推送成功通知,如何保证这三个操作的原子性?比较SeataAT、SeataTCC、本地消息表的优劣。 🔗
场景 🔗
扣减库存、更新拼单状态、推送通知(短信/IM),保证原子性(一致性)的思路:
这三个操作中,库存和状态属于强一致性范畴,通知属于最终一致性范畴。不要试图让这三者全部强一致,否则性能会极度低下。
- 库存 & 状态: 必须在一个事务内(本地事务或TCC)。
- 通知: 必须异步,允许失败重试。
方案对比 🔗
| 方案 | 优点 | 缺点 |
|---|---|---|
| Seata AT | 接入简单 | 高并发下性能差 |
| Seata TCC | 强一致 | 侵入性强 |
| 本地消息表(推荐) | 最稳、可扩展 | 实现复杂 |
-
Seata AT模式:
- 原理: 基于本地事务 + Undo Log(回滚日志)。二阶段提交时,如果全局失败,回滚时利用Undo Log反向补偿。
- 劣势:
- 锁竞争严重: 全局事务期间,数据库行锁被锁定直到事务结束,在高并发拼单场景下,数据库吞吐量极低。
- 性能损耗: 需要解析SQL,生成前后镜像,额外开销大。
- 适用性: 不适合高并发的秒杀/拼单核心链路,适合低并发的后台管理链路。
-
Seata TCC模式:
- 原理: Try-Confirm-Cancel。业务代码需要写三个接口。
- 优劣: 性能比AT好,锁由业务控制,不依赖DB锁。但代码侵入性极强,开发成本高,且有空回滚、悬挂等坑。
- 适用性: 适合核心资产操作(如库存扣减)。
-
本地消息表模式:
- 本地DB事务:
- 扣库存
- 更新拼单状态
- 写消息表(SUCCESS)
- MQ 异步:
- 推送通知
- 发放权益
- 消息表重试:
- DB 保证一致性
- MQ 只负责“最终通知”
- 可补偿、可回放
- 本地DB事务:
用Redis做拼单分布式锁时,怎么防止锁被误删和死锁?如果拼单业务执行时间超过锁超时,会导致重复拼单吗?怎么避免? 🔗
问题1:防止误删锁 🔗
- 现象: 线程A拿到锁,业务执行慢,锁超时自动释放。线程B拿到锁,此时线程A执行完,误删了线程B的锁。
- 解决: Value标识法。SET key value时,value设为UUID+线程ID。删除前,先用Lua脚本(保证原子性)判断Redis中的Value是否等于自己持有的标识,相等才DEL。
问题2:防止死锁 🔗
- 现象: 拿到锁的服务宕机,锁永远无法释放。
- 解决: 设置过期时间(TTL)。SETNX EX 10。即使宕机,10秒后锁自动释放。
问题3:业务执行超过锁超时(导致重复下单) 🔗
- 现象: 锁TTL=10s,业务跑了15s。第10s锁释放,第二个线程进来,导致两个线程同时拼单。
- 解决(看门狗机制 / Watchdog):
- 自动续期: 开启一个后台守护线程(Watchdog)。如果业务未执行完,在TTL过期前的1/3时间(如3s时),主动将Redis中的Key重新设置过期时间为10s。
- 实现: Redisson(Java)或类似的Golang库都有此机制。自实现时,需确保续期逻辑与业务逻辑同生命周期,业务停则续期停。
- 避免重复拼单: 即使锁续期失败,数据库层面必须兜底。拼单SQL带上条件 UPDATE order SET status = ‘SUCCESS’ WHERE id = ? AND status = ‘状态值’(乐观锁),利用数据库行锁或唯一索引防止并发修改。
商品库存分仓存储(A/B/C仓库),跨仓调拨时数据同步延迟,导致用户下单后发现目标仓库无货,怎么解决? 🔗
核心矛盾: CAP定理下,为了保证高可用(A)和分区容错(P),牺牲了强一致性(C),导致用户看到的“有货”是旧数据。
解决方案 🔗
- 写入前校验(强约束):
- 思路: 下单时,强制路由到主仓的Master数据库进行库存扣减。不允许读取Slave库存作为扣减依据,Slave仅用于查询展示。
- 实现: 数据库路由层配置读写分离规则,强制主从同步。
- 库存预占/预留机制:
- 库存预占:在跨仓调拨发起时,系统生成“锁定库存”。目标仓库的库存 = 实物库存 + 锁定库存。
- 库存预留:数据同步时,不仅要同步实物,还要同步“调拨单”的状态。这样即使实物没到,只要系统里调拨单是“在途”,用户也能下单。
- 设置“安全库存水位”
- 思路: 为了避免用户下单后发现目标仓库无货,设置一个“安全库存水位”。
- 实现: 如果同步延迟最大5秒,根据平均销售速度,计算5秒内可能卖出的数量(如100件)。系统展示库存 = 实际库存 - 缓冲库存(100件)。只有当调拨货物确认入库后,才释放这100件的缓冲。
- 最终一致性修复
- 思路: 当用户下单后,发现目标仓库无货,系统会自动回滚订单。用户稍后再查询,会发现目标仓库有货了。
- 实现: 订单表添加状态字段(如status=‘CANCELLED’),当回滚订单时,更新为“已取消”。逆向流程:主动取消订单、退款并发送优惠券补偿,不要阻塞用户下单流程太久。
秒杀活动中,用户每秒发起10次请求,如何设计限流策略?具体怎么实现和选择? 🔗
限流的算法 🔗
-
计数器:
- 思路:在一段时间间隔内(时间窗/时间区间),处理请求的最大数量固定,超过部分不做处理
- 实现:
- Redis 固定窗:INCR + EXPIRE 实现“每用户/每IP 每秒/每分钟最多 N 次”
- Redis 滑动窗:ZSET 记录时间戳,移除过窗外数据后按窗口内计数
- 应用内实现:Spring/Go/Node 服务用 Redis/本地内存做计数器,Lua 保证原子性
- 场景:
- 登录/短信验证码频控:每手机号每分钟 ≤ 1 次
- 秒杀活动:每个用户/IP 每秒最多 N 次
-
漏桶:
- 思路:漏桶大小固定,处理速度固定,但请求进入速度不固定(在突发情况请求过多时,会丢弃过多的请求)。
- 实现:
- Nginx limit_req:平均速率 + burst,
limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s - HAProxy:通过请求速率和队列把突刺抹平,保障稳定出流
- Nginx limit_req:平均速率 + burst,
- 场景:
- 网关入口平滑流量:避免瞬时突刺把后端打挂
- 下游保护:对数据库/搜索/外部依赖维持稳定吞吐
- 大促秒杀入口:允许小幅 burst,但整体以稳定速率过网关
-
令牌桶:
- 思路:令牌桶大小固定,令牌的产生速度固定,但是消耗令牌(即请求)速度不固定(可以应对一些某些时间请求过多的情况);每个请求都会从令牌桶中取出令牌,如果没有令牌则丢弃该次请求。
- 实现:
- 使用Google Guava的RateLimiter或Redis+Lua实现令牌桶
方案选择 🔗
- 网关入口:优先用 Nginx 的漏桶或令牌桶,结合 burst 与队列
- 服务内部:本地令牌桶(Guava/Resilience 生态)+ Redis 分布式计数滑窗
- 限流维度:同时按用户、IP、路径、租户、实例维度分层配置
- 观察与回退:配合限流日志、指标报警、灰度参数和降级页,避免误伤真实用户
用Redis存储1亿用户的砍价进度数据,选什么数据结构?内存占用大概多少?怎么优化内存? 🔗
数据结构选择:Hash 🔗
Key:activity:progress:{userID}
Field:{activityID}
Value:{current_price} (整数或浮点数字符串)
理由: Hash结构可以在Field较少时使用ziplist(压缩列表)编码,极其节省内存。
内存占用估算(粗略): 🔗
- 如果不优化:1亿个Key,每个Key本身的开销 + Redis对象头开销很大,可能需要几十GB。
- 使用Hash优化:可以将Key的数量减少。例如,将用户ID取模分片,每个Hash存1000个用户。
- 每个Entry假设10字节(Field 4字节 + Value 4字节 + 开销)。
- 1亿用户 * 10字节 ≈ 1GB (纯数据量)。加上Key开销和Redis对象头,可能在 2GB~3GB 左右。
内存优化策略: 🔗
- Hash ZipMap优化: 配置hash-max-ziplist-entries和hash-max-ziplist-value,确保Hash内部使用ziplist编码,而不是hashtable,能节省50%以上内存。
- Key精简: 去掉不必要的业务前缀,使用短ID或Hash映射ID。
- 数值编码: 如果砍价金额都是整数,Redis内部会使用int编码存储Value,非常省内存。
- 数据分片: 不要让单个Redis节点存1亿数据。按userID % 1000分片到不同Redis实例上。
优惠券核销时,用户同时使用“满100减30”“拼单价折5%”“新人立减15”,如何精准计算实付金额?规则引擎怎么设计? 🔗
计算顺序与逻辑:电商通用顺序:单品价 -> 会员价/限时价 -> 类目满减 -> 平台满减 -> 拼单价 -> 新人券。注意:有些互斥,有些叠加。
规则引擎设计(策略模式 + 责任链模式): 🔗
- 策略模式(模型抽象): 每个优惠券是一个
Rule对象,包含:Condition(规则,如满100元)和Action(动作,减30元),Condition支持逻辑组合:AND / OR。 - 责任链模式:
- 将多个优惠券规则串成一条链。
- 上下文Context在链中传递,包含:当前金额、商品列表、用户信息。
- 每个Rule节点:检查Context是否满足Condition -> 若满足,修改Context金额,标记已使用优惠。
电商平台订单ID用什么分布式ID方案?比较雪花算法变种(含业务编码)、Leaf、Redis自增的优劣 🔗
雪花算法变种: 🔗
- 原理: 1位符号位 + 41位时间毫秒 + 10位机器ID + 12位序列号。
- 变种: 将机器ID部分替换为 业务编码(如机房ID+业务线ID) 或 分片ID。
- 优劣: 性能最高(本地生成),不依赖网络。但依赖机器时钟(时钟回拨会重复),且ID过长(19位),不具备严格的业务可读性。
Leaf(美团) 🔗
- Segment模式: 从DB批量获取ID号段(如[1000, 2000]),缓存在本地内存。
- 优劣: 性能高,DB压力小。双Buffer优化保障平滑。重启可能丢失部分ID(不连续),但唯一性没问题。
- Snowflake模式: 基于Zookeeper解决时钟回拨问题。
- 适用性: 稳定性极高,适合中大型互联网公司。
Redis自增(INCR): 🔗
- 优劣: 实现最简单。但强依赖Redis,集群扩缩容麻烦(需要考虑Lua脚本或Redis Cluster Slot),且网络IO有延迟。高并发下成为单点瓶颈。
秒杀优惠券如何防止超发?对比Redis+Lua脚本和分布式数据库锁的方案优劣。 🔗
方案一:Redis + Lua 脚本(主流、秒杀首选) 🔗
- 数据结构设计
key设计:
coupon:stock:{couponId}: 字符串,剩余库存数。
coupon:user:{couponId}: Set 或 Hash,记录已领取用户(Set 做“是否已领”,Hash 可记录领了几张)。
初始化时(活动开始前)预热到 Redis:
SET coupon:stock:1001 1000
SADD coupon:user:1001(空集合即可,后续通过 SADD 添加 userId)
- lua脚本
-- KEYS[1]: coupon:stock:{couponId}
-- KEYS[2]: coupon:user:{couponId}
-- ARGV[1]: userId
-- ARGV[2]: maxPerUser(同一用户最多可领几张,可为 1)
local stockKey = KEYS[1]
local userSetKey = KEYS[2]
local userId = ARGV[1]
local maxPerUser = tonumber(ARGV[2])
-- 1) 用户是否已达到领取上限
local count = tonumber(redis.call("HGET", userSetKey, userId) or "0")
if count >= maxPerUser then
return -1 -- 超过个人限额
end
-- 2) 检查库存
local stock = tonumber(redis.call("GET", stockKey) or "0")
if stock <= 0 then
return 0 -- 库存不足
end
-- 3) 扣库存
redis.call("DECR", stockKey)
-- 4) 记录用户已领
if count == 0 then
redis.call("HSET", userSetKey, userId, 1, "ts", ARGV[3]) -- 记录领取时间
else
redis.call("HINCRBY", userSetKey, userId, 1)
end
-- 5) 返回剩余库存(可选)
return tonumber(redis.call("GET", stockKey))
PHP集成与MQ集成,落库
/**
* 用户领取优惠券(秒杀接口)
*
* @param int $couponId 优惠券ID
* @param int $userId 用户ID
* @param int $maxPerUser 每人最大可领数量(一般 1)
*
* @return array ['code'=>int, 'msg'=>string, 'leftStock'=>int|null]
*/
public function grabCoupon(int $couponId, int$userId, int $maxPerUser = 1): array
{
$stockKey = "coupon:stock:{$couponId}";
$userKey = "coupon:user:{$couponId}";
// Lua 脚本
$script = <<<'LUA'
local stockKey = KEYS[1]
local userSetKey = KEYS[2]
local userId = ARGV[1]
local maxPerUser = tonumber(ARGV[2])
local nowTs = ARGV[3]
local ttlSeconds = tonumber(ARGV[4]) // 过期时间(秒)
local count = tonumber(redis.call("HGET", userSetKey, userId) or "0")
if count >= maxPerUser then
return -1
end
local stock = tonumber(redis.call("GET", stockKey) or "0")
if stock <= 0 then
return 0
end
redis.call("DECR", stockKey)
if count == 0 then
redis.call("HSET", userSetKey, userId, 1, "ts", nowTs)
if ttlSeconds and ttlSeconds > 0 then
local ttl = redis.call("TTL", userSetKey)
if ttl == -1 then
redis.call("EXPIRE", userSetKey, ttlSeconds)
end
end
else
redis.call("HINCRBY", userSetKey, userId, 1)
end
return tonumber(redis.call("GET", stockKey))
LUA;
// 按照官方文档,eval 的第二个参数是包含所有 KEYS 和 ARGV 的数组
// 第三个参数是 KEYS 的个数,Redis 会把前 N 个元素当作 KEYS,后面的当作 ARGV
$args = [
$stockKey, // KEYS[1]
$userKey, // KEYS[2]
$userId, // ARGV[1]
$maxPerUser, // ARGV[2]
(string)time(), // ARGV[3] 当前时间戳
3 * 24 * 3600, // ARGV[4] 过期时间
];
$numKeys = 2; // KEYS 的个数
$result =$this->redis->eval($script,$args, $numKeys);
if ($result === false) {
// Redis 错误
return [
'code' => 500,
'msg' => '系统繁忙,请稍后重试',
'leftStock' => null,
];
}
$result = (int)$result;
if ($result > 0) {
// 领取成功
return [
'code' => 0,
'msg' => '领取成功',
'leftStock' => $result,
];
}
if ($result === 0) {
// 库存不足
return [
'code' => 1,
'msg' => '优惠券已被抢光',
'leftStock' => 0,
];
}
// $result === -1
// 重复领取
return [
'code' => 2,
'msg' => '你已经领取过该优惠券',
'leftStock' => null,
];
}
$service = new SeckillCouponService();
$couponId = 1001;
// 活动开启前,在后台管理或脚本里初始化库存(只做一次)
$service->initStock($couponId, 1000);
// 用户请求领取
$userId = 12345;
$ret =$service->grabCoupon($couponId,$userId);
switch ($ret['code']) {
case 0:
echo "领取成功,剩余库存:" . $ret['leftStock'] . PHP_EOL;
// 假设你有一个简单的队列封装(Redis List 或更专业的 MQ)
$event = json_encode([
'couponId' => $couponId,
'userId' => $userId,
'ts' => time(),
]);
$redis->lPush('coupon:grab:queue',$event);
break;
case 1:
echo "已被抢光,库存不足:" . $ret['leftStock'] . PHP_EOL;
break;
case 2:
echo $ret['msg'] . PHP_EOL; // 你已经领取过该优惠券
break;
default:
echo $ret['msg'] . PHP_EOL; // 系统错误
break;
}
方案二:分布式数据库锁(MySQL 行锁 / 乐观锁等) 🔗
update coupon
set stock = stock - 1
where id = ? and stock > 0;
缺点 🔗
| 维度 | Redis + Lua | 数据库锁 |
|---|---|---|
| 防超发能力 | ✅ 强 | ✅ 强 |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐ |
| 并发能力 | 10万~百万 | 几千 |
| 架构复杂度 | 中 | 低 |
| 数据一致性 | 最终一致 | 强一致 |
| 秒杀适配度 | ⭐⭐⭐⭐⭐ | ❌ |
多级缓存架构本地缓存(Caffeine)和Redis如何协同保证数据一致性? 🔗
多级缓存“一定会不一致”,只能保证最终一致性
最核心的协同策略 🔗
- 写操作:先 DB,后删缓存(两级):
1. 更新 DB(事务提交)
2. 删除 Redis
3. 删除 Caffeine
不是更新缓存,是删除缓存 , 更新缓存更容易脏写,删除让读请求回源重建
- 防止并发写导致脏数据:延迟双删:
1. 删 Redis + Caffeine
2. sleep 500ms
3. 再删一次 Redis
- 节点间本地缓存一致性:消息通知
DB 更新
→ 删除 Redis
→ 发送 MQ / Redis PubSub
→ 各节点清理本地 Caffeine
- TTL + 版本兜底(防“永远不一致”)
本地缓存:TTL = 30 ~ 60 秒
Redis缓存:TTL = 5 ~ 10 分钟
完整读写流程(真实生产) 🔗
- 读流程
1. 查 Caffeine
2. 未命中 → 查 Redis
3. Redis 命中 → 回填 Caffeine
4. Redis 未命中 → 查 DB
5. 回填 Redis + Caffeine
- 写流程
1. 后台内容操作
2. 更新 DB(事务)
3. 删除 Redis
4. 删除本地 Caffeine
5. 发 MQ 通知
6. 各节点清本地缓存