最右后端一面:秒杀链路、Feed 推拉与评论可见性的计数设计
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 介绍一下你这个秒杀链路,比如说一次完整的秒杀流程。
- 刚说你用到的这个 Lua 保证了这个原子性,那这 Lua 都执行了哪些操作呢?
- 那具体的这每一步对应了 Redis 的哪些操作?对应哪些数据类型?
- Redis 扣减成功了,但是最终去往 MySQL 里边去做记录的时候服务器宕机了,这个时候你觉得应该怎么去处理呢?
- 那 Redis Stream 能不能保证这个消息一定不会丢失呢?
- Redis 库存跟你最终 MySQL 的库存不一致了,遇到了这种问题,你会怎么去查问题出在哪里呢?
- 这个社交 Feed 流采用推模式,为什么采用推模式而不是拉呢?
- 具体怎么去做这个分页来避免分页重复和遗漏?假如这个时候有一个笔记删除了呢?会不会影响你这个分页?
- 解释一下这个缓存穿透、缓存击穿、缓存雪崩分别是指什么?怎么解决?
- 场景题:用户对一个 blog 进行评论时可以设置当前评论为「所有人可见」「仅自己可见」「仅好友可见」这三种状态其中一种,那么你在后端或者缓存应该怎么存储评论数,保证其他用户能看到正确的数量?当用户将自己的父评论从「所有人可见」设置成「仅自己可见」时,此时对应的子评论数量应该同时减去,这个时候你应该怎么设计表或者缓存来实现呢?
- 手撕:已知两颗二叉树,将它们合并成一颗二叉树。合并规则:两个节点都存在时节点值相加;如果一个为空,就用另一个树的节点代替。
《参考解析》
秒杀链路与 Lua 原子扣减
一次完整的秒杀流程按顺序讲:活动开始前的准备(库存预热到 Redis、活动信息与限流规则下发、静态资源走 CDN)→ 请求进入(网关鉴权与限流、活动时间校验、同一用户防重)→ 库存扣减(Redis 里用 Lua 原子执行「读库存 → 判断是否大于 0 → 扣减 → 记录用户已购买」)→ 生成订单(先落库为「待支付」或先投递消息异步落库)→ 支付与超时释放(延时消息或定时扫描,超时未支付回补库存并关闭订单)→ 数据对账(Redis 与数据库库存、订单与支付流水定时核对)。
Lua 里执行的操作要逐步对应到 Redis 命令与数据类型:库存用 String(GET/DECR/DECRBY,或 INCR 反向计数),用户去重用 Set(SISMEMBER + SADD)或 Hash(HSETNX),购买记录可以用 Hash 存用户与订单号映射,活动开关与时间窗用 String 加 TTL。用 Lua 的意义是把「判断 + 扣减 + 去重」打包成一个原子操作,避免「判断之后被别人抢先扣走」的竞态;脚本要保持简短(Redis 单线程执行 Lua,长脚本会阻塞其他请求),并把结果(成功/库存不足/重复购买)作为返回值让应用层分流。
Redis 扣减成功但落库前宕机、以及库存不一致怎么查
这是「预扣 + 异步落库」链路的核心问题,答题要覆盖补偿与对账两条线。补偿:把「Redis 扣减成功」与「数据库写入」之间用可靠消息串起来——扣减成功后在同一个 Lua 里写入 Stream(或业务表里的待处理记录),由消费者落库并 ack;如果消费者处理失败,消息会留在 pending 列表里,配合重试与死信处理。要做到真正的「不丢」,要讲清 Stream 的机制与边界:消费者组 + XACK + XPENDING 能保证消息被确认前不删除,但仍有边界——Redis 本身可能丢数据(未开 AOF 或 appendfsync everysec 下宕机丢最近一秒、主从切换丢未同步部分)、pending 消息需要有人巡检与重投、消费端必须幂等(按订单号去重)才能安全重投。所以结论是:Stream 提供「至少一次」,不提供「一定不丢」,生产上要靠「Redis 开启合理持久化 + 落库幂等 + 定时对账」三层兜住。
库存不一致的排查要按「在哪一层不一致、什么时候开始不一致、哪一侧是权威」来推进。先量化差异(Redis 库存与数据库库存的差值、影响多少订单、是持续扩大还是固定差值),再按时间点对齐变更(是否与发布、活动开始、Redis 主从切换、批量回补脚本重合)。然后检查几个高频根因:回补重复或漏回补(超时释放的定时任务与手动脚本同时跑)、扣减与落库之间有失败但没有重试(看消费 lag 与死信)、并发下用户去重失效(Set 被清或过期)、以及人工改库或脚本改数据没同步 Redis(看审计日志)。最后是修复与预防:以数据库为权威重新计算并回写 Redis、补偿差异订单、把「库存变更只走一个入口」写死、并加一条定时对账任务(差异超阈值就告警并自动修正),让不一致在扩大之前被发现。
Feed 推拉模式与分页的重复遗漏
推模式(写扩散)在发布时把内容写入所有粉丝的收件箱,读时直接拉自己的收件箱,优点是读快、延迟低;拉模式(读扩散)发布时只写一份,读时聚合所有关注对象的内容再排序,优点是写入便宜、存储省,缺点是读放大、大 V 场景聚合慢。选择依据是读写比与粉丝规模:普通用户多、读远多于写、且粉丝量大户少的场景常用「推拉结合」——普通用户走推、大 V 走拉(读时合并),既控制写放大又保证读性能。还要提代价:推模式写放大(一个千万粉丝的账号发一条要写千万份)、存储成本高、删除与编辑要同步更新所有收件箱。
分页避免重复与遗漏的关键是「不要用 offset 分页」。游标分页(cursor 用上一页最后一条的排序键,比如时间戳加 ID 的复合游标)能在写入不断发生时保持稳定:每次查询都是「排序键小于游标的 N 条」,新内容插到头部也不会导致后移。用纯时间戳做游标要小心同一时刻多条数据的边界(用「时间戳 + ID」双字段比较),否则会重复或漏掉。删除笔记对游标分页的影响有限——已翻过的页不会因为删除而错位(这是相对 offset 的优势),但会出现「总数变化」与「某条在收件箱里但正文已删」的情况,需要在读取时过滤(收件箱里存的是 ID,回源时发现已删或不可见就跳过,并顺手清理收件箱记录)。如果是 offset 分页,删除会导致整体前移、后面内容被跳过,这就是必须换成游标的原因。
缓存穿透、击穿、雪崩
三者的区别要一句话说清:穿透是「查不存在的数据」,请求每次都绕过缓存打到数据库(缓存永远不会命中,比如查询一个不存在的 ID);击穿是「单个热点 key 过期」,大量并发同时回源重建;雪崩是「大量 key 同时失效或缓存整体不可用」,请求全部压到数据库。解法分别对应:穿透用空值缓存(短 TTL)+ 布隆过滤器(提前判断 key 是否存在)+ 入参合法性校验(拦住明显非法的 ID);击穿用互斥重建(分布式锁或单飞,保证只有一个请求回源)+ 逻辑过期(不设物理 TTL,异步续期,读时先返回旧值)+ 热点数据永不过期;雪崩用过期时间加随机抖动 + 多级缓存(本地 + Redis)+ 熔断限流降级(保护数据库)+ 缓存集群高可用(哨兵/集群、多副本)。要主动补一句容易混淆的第四种「缓存污染」(大量不常访问的数据挤掉热点),解法是合理设置容量与淘汰策略(LRU/LFU)。
评论可见性的计数设计
这题的核心是「评论数必须按查看者的身份计算,不能只有一个全局计数」。设计上建议分两层:一是存储层保留每条评论的可见性字段(visibility:所有人 / 仅自己 / 仅好友)与父评论 ID(parent_id),评论计数不要只存一个数字;二是缓存层按可见性维度分别计数,用 Redis Hash 按「目标对象 + 可见性」存多个计数,例如「所有人可见总数」「好友可见总数」,读取时按查看者身份组合(自己看全部、好友看「所有人可见 + 好友可见」、陌生人只看「所有人可见」)。这样避免了为每个查看者单独算一遍,也避免了一个全局计数在不同人眼里都不对的问题。
最难的是追问:父评论从「所有人可见」改成「仅自己可见」时,子评论数要同时减去。实现要点有三条。第一,确定「谁的数量要变」:父评论本身的可见性变化会让对外可见的父评论数减一;同时它下面所有子评论对新查看者也不可见了,因此对外可见的子评论总数要减掉这些子评论的数量(要按子评论自己的可见性区分,如果子评论本身是「仅自己」,那它本来就不在对外计数里,不能重复减)。第二,用事务或消息保证「改可见性 + 调整计数」的原子性与幂等:先改数据库(权威),再发一条带版本号的变更事件让缓存按增量调整(HINCRBY 带正负值),消费者按事件 ID 去重,避免重复扣减;如果在事务内直接改缓存,要处理缓存失败的回滚。第三,缓存失效优先于精确维护:对这类层级可见性,直接用「增量维护 + 定时全量重算」更稳——定时任务按可见性重新统计一次并覆盖缓存,把增量过程中的偏差收敛掉,同时监控差异,差得多就说明增量逻辑有 bug。
手撕:合并两棵二叉树
递归是标准写法:两棵树都为空返回空;只有一个为空返回另一个(注意这里是复用节点还是拷贝,要跟面试官确认是否允许修改原树);都不为空则把值相加放到新节点(或就地更新第一棵树的节点),然后递归合并左右子树。时间复杂度 O(min(n, m)) 到 O(n + m) 之间(取决于重合程度),空间复杂度是递归栈深度。边界与细节要主动说清:空树、只有一个节点、值溢出的可能(如果是整数相加且值很大,可以提一句用更大类型或按题意处理),以及是否要求「不修改输入」(如果要求不可变,就新建节点而不是复用)。迭代版可以用栈或队列做层序合并,面试里递归版足够,但能提一句「如果树很深,递归可能栈溢出,可以改成显式栈」会显得考虑周全。