Skip to content

好的!结合你简历中 “演出节目购票系统” 这个高并发项目,我将为你逐条拆解这些问题,并提供深刻、有细节、能体现技术深度的回答框架。这些回答不仅能展示你的技术能力,还能体现出你在真实业务场景下的权衡和思考


1. 缓存相关问题

Q1: 如何设计缓存策略?...哪些数据适合放入缓存?

回答思路:业务维度出发,结合数据特征(访问频率、一致性要求、大小)来谈。

“在我们的购票系统中,缓存策略的设计是分层的。

1. 存储结构选择:

  • 热点节目信息(如名称、票价、座位图):我们使用 Redis 的 Hash 结构存储。因为 Hash 可以将一个节目的多个字段组织在一起,读取时只需一次网络请求就能拿到所有信息,非常高效。
  • 库存/余票数量:这是一个高并发、强一致性要求的场景。我们没有直接用 Redis 的 INCR/DECR,而是采用了无锁化扣减策略(后面会详述),底层依然是基于 Redis 的 String 或 Hash 来存储。

2. 存储维度:
我们以 节目ID 作为一级 Key,对于座位图这种复杂结构,会进一步拆分为 座位ID 作为二级 Key。例如 show:1001:seat:203。这样做的好处是,在用户选座时,可以只操作单个座位的 Key,避免了大 Key 带来的网络瓶颈和锁竞争。

3. 数据筛选原则:

  • 适合缓存: 读多写少数据量不大允许短暂不一致的数据。比如节目详情、场次列表、静态座位图。这些数据一旦加载到缓存,可以支撑数万甚至数十万的 QPS。
  • 不适合缓存: 强一致性要求频繁更新数据量巨大的数据。比如用户的订单历史、支付流水。这类数据我们直接查数据库,或者通过 MQ 异步同步到专门的查询服务。”

Q2: 如何设计多级缓存?如何解决/预防缓存雪崩?

回答思路: 展示你对极端场景的应对能力和纵深防御思想。

“面对百万级流量冲击,单靠 Redis 是不够的。我们构建了 ‘本地缓存 + 分布式缓存’ 的两级缓存架构。

1. 多级缓存设计:

  • L1 - 本地缓存(Caffeine): 在每个应用实例的 JVM 内存中。缓存最热的 Top N 节目信息(如前10个热门节目)。访问速度极快(纳秒级),且不产生任何网络开销。但要注意内存占用和一致性问题。
  • L2 - 分布式缓存(Redis): 作为统一的缓存层,存储全量的节目和座位数据。
  • 读取流程: 先查本地缓存 → 未命中再查 Redis → 都未命中才回源数据库,并将结果回填到两级缓存中。
  • 一致性保障: 我们利用 Redis Stream 作为消息总线。当后台管理更新了节目信息,会向 Stream 发布一条变更消息。所有应用实例订阅这个 Stream,收到消息后主动失效本地缓存中对应的数据,保证最终一致性。

2. 缓存雪崩的预防与解决:

  • 预防(核心):
    • 随机过期时间: 给缓存设置基础过期时间(如 30 分钟)的基础上,再加上一个随机值(如 0-5 分钟)。这样可以避免大量 Key 同时失效。
    • 永不过期(逻辑过期): 对于极其核心的数据(如首页 Banner),我们可以不设物理过期时间。而是由后台任务定期刷新,或者通过上面提到的 Redis Stream 主动失效机制来更新。
  • 兜底(解决):
    • 熔断降级: 如果发现数据库压力过大,可以通过配置中心动态开启熔断开关。此时,即使缓存和数据库都不可用,也可以返回一个兜底的静态页面或错误码,保证系统整体不崩溃。
    • 互斥锁重建: 当缓存失效时,只允许一个线程去数据库加载数据并重建缓存,其他线程等待或返回旧数据(如果允许)。”

Q3 & Q4: 库存扣减、数据一致性(这是重点!)

回答思路: 这是你简历中的亮点,必须讲清楚无锁化异步解耦的设计。

“库存扣减是整个系统的核心难点,我们通过 ‘无锁化扣减 + 异步订单创建’ 的组合拳来解决。

1. 库存设计:

  • 我们将座位图在 Redis 中拆分成独立的 Key,例如 seat:show_1001:row_A:col_10,其 Value 是一个状态(如 available, locked, sold)。
  • 这样,用户选座时,只需要对单个座位进行原子操作,而不是操作一个包含几千个座位的大对象。

2. 扣减流程(无锁化):

  • 预占(锁定): 用户提交选座后,我们不是直接扣减库存,而是尝试将座位状态从 available 原子性地修改为 locked。这一步使用 Redis 的 SET key value NX EX 命令实现,天然具备原子性和过期自动释放(防死锁)的能力。
  • 异步创建订单: 预占成功后,立即向 Kafka 发送一条创建订单的消息,并给用户返回“抢购成功,请支付”的提示。此时并没有真正扣减数据库库存。
  • 支付与最终确认: 用户在规定时间内完成支付后,订单服务消费 Kafka 消息,将座位状态从 locked 改为 sold,并同步更新数据库中的库存
  • 超时释放: 如果用户未在规定时间内支付(比如10分钟),座位的 locked 状态会因 Redis Key 过期而自动变回 available,供其他用户抢购。

3. 一致性与回滚:

  • 异常回滚: 整个过程是最终一致性。如果 Kafka 消息发送失败,我们会捕获异常,然后主动将 Redis 中的座位状态从 locked 改回 available,相当于手动回滚。
  • 兜底对账: 我们有一个定时对账任务,每天凌晨会核对 Redis 中的座位状态和数据库中的订单状态。如果发现不一致(比如 Redis 是 locked 但数据库没有对应的待支付订单),就以数据库为准,修正 Redis 状态。

4. 关于经典一致性方案的看法:

  • 直接删除缓存: 在我们这种高并发写场景下风险很大。假设 A 线程刚删完缓存,B 线程就读到了旧的 DB 数据并回填了缓存,接着 A 线程才更新 DB,就会导致缓存和 DB 不一致。
  • 延迟双删: 理论上更可靠,但引入了复杂的延时逻辑,且在第二次删除期间依然存在不一致窗口。在我们追求极致性能的场景下,这种方案的复杂度和不确定性是我们不愿意接受的。
  • 我们的选择: 因此,我们选择了彻底解耦读写的方式。读请求(查询余票)永远走缓存写请求(扣减库存)走异步消息队列。通过业务流程的设计,从根本上规避了缓存与数据库的实时一致性难题,用最终一致性换取了系统的高性能和高可用。”

2. 分布式锁相关问题

Q1: 分布式锁的细节?加一行注解就够了吗?

回答思路: 揭露“简单注解”背后的复杂性,展示你对可靠性的理解。

“绝对不是加一行注解就够了。看似简单的 @DistributedLock 注解背后,隐藏着很多魔鬼细节:

1. 锁的自动续期(Watchdog): 如果业务执行时间超过了锁的过期时间,锁会自动释放,可能导致别的线程进来,造成并发安全问题。我们的组件内部集成了 Watchdog 机制,只要业务还在执行,就会在后台定期(比如每10秒)自动延长锁的过期时间。

2. 锁的可重入性: 同一个线程能否多次获取同一把锁?我们的实现支持可重入,通过在 Value 中存储 UUID + ThreadId 以及一个计数器来实现。

3. 解锁的原子性: 释放锁时,必须确保是自己加的锁才能释放。我们通过 Lua 脚本保证了 ‘判断持有者 + 删除 Key’ 这个操作的原子性,防止误删别人的锁。

4. 安全的降级策略: 如果 Redis 集群出现故障,锁服务不可用怎么办?我们的组件支持配置降级策略,比如直接跳过加锁(适用于非核心业务),或者切换到本地锁(适用于单机部署的测试环境)。”

Q2: 高并发下如何优化分布式锁?

回答思路: 结合你简历中的 “本地实例锁+分布式锁” 策略。

“针对高并发场景,我们做了两层优化:

1. 锁粒度细化: 锁的 Key 必须足够细。比如,不是对整个‘节目’加锁,而是对‘节目+场次’甚至‘座位’加锁。粒度越细,并发度越高。

2. 本地锁前置(分层加锁): 这是我们最重要的优化。在尝试获取 Redis 分布式锁之前,先在本地获取一个 synchronizedReentrantLock

  • 作用: 在单个 JVM 实例内部,将并发请求串行化。假设一个实例每秒有 1000 个请求要抢同一个座位,如果不加本地锁,这 1000 个请求都会去请求 Redis,给 Redis 带来巨大压力。加上本地锁后,只有 1 个请求能去争抢 Redis 锁,其余 999 个在本地排队。这极大地减少了对 Redis 的无效网络请求,提升了整体吞吐量。
  • 效果: 正如我在简历中提到的,这套组合策略帮助我们提升了 26% 的系统吞吐量。”

3. 高可用问题

Q1: 防止大量注册请求导致崩溃?

回答思路: 展示多层次的防护体系

“我们构建了一个从外到内的防护体系:

1. 接入层(Nginx): 配置 IP 黑名单、速率限制(limit_req),直接拦截掉明显的恶意流量。
2. 网关层(Spring Cloud Gateway): 集成 Sentinel,实现更细粒度的限流(比如按用户 ID、手机号限流),并支持动态规则调整。
3. 业务层:

  • 验证码前置: 所有注册请求必须先通过图形验证码或滑块验证,这能有效过滤掉 90% 以上的机器脚本。
  • 异步化: 注册成功后的欢迎邮件、积分发放等非核心操作,都通过 RocketMQ 异步处理,避免阻塞主注册流程。
    4. 资源隔离: 为注册服务分配独立的线程池和数据库连接池,即使注册服务被打满,也不会影响登录、购票等核心服务。”

Q2: 幂等性如何实现?

回答思路: 结合具体业务场景(如支付、创建订单)。

“幂等性的实现需要根据业务场景选择合适的方式:

1. Token 机制(适用于表单提交): 用户进入支付页面时,后端生成一个唯一的 pay_token 并返回给前端。前端在支付请求中带上这个 Token。后端收到请求后,先尝试在 Redis 中 SET pay_token EX 5m NX,如果成功(说明是第一次请求),则执行支付逻辑;如果失败(Token 已存在),则直接返回‘重复请求’。

2. 唯一索引(适用于数据库插入): 对于创建订单,我们在数据库的订单表上建立 (user_id, show_id, create_time) 的唯一索引。即使前端重复提交,数据库层面也能保证只会插入一条记录,后续的插入会直接报错,我们在代码中捕获这个异常并返回幂等结果。

3. 状态机(适用于流程型业务): 订单有明确的状态流转(待支付 -> 已支付 -> 已完成)。在执行‘支付’操作时,会先检查当前状态是否为‘待支付’,如果不是,则直接拒绝操作。这天然保证了支付接口的幂等性。”

Q3: 如何执行灵活的限流规则?

回答思路: 提到具体的中间件和实现方式。

“我们主要依靠 Sentinel 来实现灵活的限流。

1. 规则维度: Sentinel 支持基于 QPS并发线程数调用关系 等多种维度进行限流。
2. 灵活性:

  • 时间段: 可以通过配置中心(如 Nacos)动态推送规则,实现在特定时间段(如秒杀开始前5分钟)自动提升限流阈值。
  • 请求级别: 可以针对特定的 API 接口(如 /api/v1/buy)设置独立的限流规则。
    3. 异常行为记录: Sentinel 本身提供了实时的监控数据(通过 Dashboard)。更重要的是,我们可以自定义 BlockHandler(限流后的处理逻辑),在里面将被限流的请求信息(如 IP、用户 ID、请求参数)异步写入 Kafka。后续可以通过 Flink 或 ELK 对这些日志进行分析,识别出潜在的攻击者或异常用户。”

希望这份详细的回答指南能帮助你在面试中脱颖而出!记住,面试官想听到的不仅是“是什么”,更是“为什么”和“怎么做”。祝你成功!