Abner的博客

场景总结

· 1022 words · 5 minutes to read
Categories: 面试

目录


项目里秒杀商品的详情页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锁。但代码侵入性极强,开发成本高,且有空回滚、悬挂等坑。
    • 适用性: 适合核心资产操作(如库存扣减)。
  • 本地消息表模式:

    1. 本地DB事务:
      • 扣库存
      • 更新拼单状态
      • 写消息表(SUCCESS)
    2. MQ 异步:
      • 推送通知
      • 发放权益
    3. 消息表重试:
      • DB 保证一致性
      • MQ 只负责“最终通知”
      • 可补偿、可回放

用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:通过请求速率和队列把突刺抹平,保障稳定出流
    • 场景:
      • 网关入口平滑流量:避免瞬时突刺把后端打挂
      • 下游保护:对数据库/搜索/外部依赖维持稳定吞吐
      • 大促秒杀入口:允许小幅 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. 各节点清本地缓存