也许需要一套结构化话术以应对不同面试官拍脑袋的场景题。这套话术的核心并非提供完美的细节,而在于展示一种结构化的、由宏观到微观的思维方式,证明你理解复杂系统设计的核心矛盾与权衡。

DDD 架构设计

微服务与 DDD 架构设计的关系应当是密不可分的。DDD(领域驱动设计)通过限界上下文(Bounded Context)帮助我们划分微服务的边界,确保了服务的高内聚、低耦合。这是构建可独立演进、可独立扩缩容的微服务系统的基石。在高并发场景下,清晰的边界意味着我们可以针对性地优化某个核心服务,而不影响整个系统。

CQRS

CQRS (Command Query Responsibility Segregation,命令与查询职责分离) 是应对高并发场景的利器。其核心思想是将**改变系统状态的写操作(Command)读取系统状态的读操作(Query)**在逻辑和物理上分离。

  • Command Side (命令端): 专注于接收写请求,执行业务逻辑,并持久化数据。可以为性能做极致优化,例如,接收请求后立即放入消息队列,快速响应用户,实现异步处理。
  • Query Side (查询端): 专注于数据查询和展现。数据源可以是一个或多个为查询而优化的“读模型”,比如使用 Elasticsearch 做复杂搜索,或用 Redis 缓存热点数据。它通过订阅 Command 端的领域事件来更新自己的数据。

这种分离允许我们独立地扩展读、写两部分。对于“读多写少”的场景,可以大量增加查询服务的节点和缓存;对于“写多读少”的场景,可以优化写入链路的吞吐量。

分布式事务

在微服务和 CQRS 架构下,一个业务操作可能跨越多个服务,这就引入了分布式事务问题。传统的强一致性事务(如 XA/2PC)因其性能瓶颈和同步阻塞,在高并发场景下几乎是不可接受的。

因此,我们通常采用最终一致性的方案,核心是“可靠消息”。常见的模式有:

  • TCC (Try-Confirm-Cancel): 业务侵入性强,但回滚逻辑清晰。
  • SAGA: 通过一系列的本地事务和补偿操作来完成。如果中间一步失败,则反向执行之前所有已成功的补偿操作。这是目前最主流的模式。
  • 本地消息表/事务性发件箱: 将业务操作和发送消息放在同一个本地事务中,保证“业务成功,消息一定能发出去”,由消息中间件保证下游消费,实现最终一致。

面试话术总结: “在设计之初,我会采用 DDD 来划分服务边界。对于核心的高并发模块,我会考虑使用 CQRS 架构,将读写路径分离,从而可以独立优化和扩容。对于跨服务的操作,我会放弃强一致性的分布式事务,转而采用基于可靠消息的 SAGA 模式来保证数据的最终一致性。”

服务稳定性

稳定性是高并发系统设计的生命线。我们的目标不是系统永不失败,而是在部分组件失败时,核心功能依然可用。

服务降级

降级,即舍弃一部分非核心服务质量,全力保障核心业务可用性。这是一种有损服务。

  • 实现方式: 通过配置中心或开关,动态地关闭或简化某些功能。例如,在大促时,关闭商品推荐、用户评价等次要功能,只保留浏览、下单、支付等核心链路。
  • 自动降级: 结合熔断器(Circuit Breaker)实现。当某个依赖服务的错误率或响应时间超过阈值时,自动触发降级,返回一个兜底数据(如默认值、缓存或友好提示),避免连锁故障(雪崩)。

限流器

限流是为了防止瞬间流量冲垮系统,它是一种主动的自我保护机制。

  • 单机限流: 如 Guava RateLimiter,适用于单个服务实例。
  • 分布式限流器: 在微服务集群中,必须使用分布式限流器来控制整个服务的总流量。通常借助外部组件实现,如 Redis
    • 算法: 令牌桶(Token Bucket)和漏桶(Leaky Bucket)是常用算法。令牌桶允许一定程度的突发流量,更符合 Web 应用的实际情况。
    • 实现: 可以使用 Redis + Lua 脚本来保证原子性地获取令牌。

服务隔离

确保突发高流量服务不会因资源耗尽而导致其他服务不可用。

  • 物理/部署隔离: 将核心业务和非核心业务部署在不同的服务器集群、容器组中。例如,秒杀系统必须使用独立的服务器资源。
  • 线程池/信号量隔离: 在单个服务内部,对不同的外部依赖调用使用不同的线程池。例如,Hystrix/Sentinel 就采用了这种模式。如果对服务 A 的调用出现延迟,只会占满访问 A 的线程池,而不会影响对服务 B 的调用。

面试话术总结: “为了保证系统的韧性,我会构建三道防线。第一道是限流,在流量入口处就挡住超出系统处理能力的请求;第二道是隔离,通过物理隔离和线程池隔离,防止核心服务被非核心服务的故障拖垮;第三道是降级和熔断,作为最后保障,当系统压力过大或下游依赖出问题时,主动放弃部分功能,保证核心链路的畅通。”

高并发高流量场景

所有高并发场景题,其本质都是在处理“瞬时流量远大于系统常规处理能力”这个核心矛盾。因此,通用的设计思路可以总结为:异步化、削峰填谷、读写分离、数据分片

通用分析框架:

  1. 识别矛盾: 这是“读多写少”(如微博看帖)还是“瞬时写多”(如秒杀)?数据一致性要求是强一致还是最终一致?
  2. 分层过滤: 从客户端 -> CDN -> 负载均衡 -> 网关 -> 服务,流量是层层递减的。思考每一层能做什么优化(缓存、限流等)。
  3. 异步削峰: 对于写操作,不要同步处理,而是快速接收请求,放入消息队列(如 Kafka, RocketMQ),由后端消费者按自己的节奏处理。这是“削峰填谷”的核心。
  4. 读写分离/数据预热: 对于读操作,大量使用缓存(如 Redis)。对于可预见的读洪峰,提前将热点数据加载到缓存中(数据预热)。

评论

  • 论坛
  • 视频网站

论坛的热门贴吧发帖速度过快,如何做到自动限制某个贴吧的速度,并保证其他贴吧的发帖速度

  1. 分布式动态限流
  • 设计: 使用分布式限流器,但限流的 Key 不是全局的,而是与贴吧 ID 关联。例如,在 Redis 中使用一个键,如 ratelimit:tieba:{tieba_id}
  • 自动限制: 可以结合监控系统。当监控发现某个贴吧的发帖/回帖 QPS 超过预设阈值时,通过配置中心动态地调低该 tieba_id 对应的限流器速率。其他贴吧因为 tieba_id 不同,不受影响。

限流会导致部分贴吧用户直接发不出帖子,影响用户体验,慎用。

  1. 消息队列与弹性扩容
  • 设计: 将发帖/回帖请求先写入消息队列(如 Kafka, RabbitMQ),而不是直接写入数据库。前端在请求被推入队列后即可快速返回成功,给用户“发布成功”的即时体验。
  • 隔离与限速: 为不同的贴吧设置不同的 Topic,或者在同一 Topic 中使用贴吧 tieba_id 作为分区键(Partition Key)。这样,热门贴吧的请求会进入特定的 Topic 或分区,而后端消费程序可以根据每个 Topic/分区的消息积压情况,来决定消费速度。即使某个热门贴吧的消息大量涌入,也只是堆积在它自己的队列分区中,不会阻塞其他贴吧消息的处理和消费。

对于热门贴吧的 Partition,弹性扩容从两个方面来考虑:

  • Consumer Group 扩容,按 Kafka 设定,Topic 的 Partition 只能分配给 Consumer Group 中的一个 Consumer(但可以分给不同 Consumer Group 的多个 Consumer,常见于订阅 - 发布模式,跟这个场景无关)。因此假设有 NN 个 Partition 的 Topic 只有 M(M<<N)M (M<<N) 个 Consumer,显然可以增加 Consumer 数量让一个 Consumer 专门负责消费热门贴吧的消息。
  • Partition 扩容,Kafka 自己会做好负载均衡,所以要是有突发流量打到 Kafka 上,除非主动做限流,否则不影响其他贴吧的消息接收速率是不可能的,只能考虑往集群加 Pod 实例让 Kafka 自己做负载均衡。

贴吧、知乎帖子这类长文字与评论区的评论是否需要区别对待?为什么,不同的方案的优缺点?

从入口到落地,将帖子和评论彻底分开处理。

  • 具体实现:

    • API 层: 使用不同的 API Endpoint(如 /api/post/create/api/comment/create)。
    • 消息队列: 使用不同的 Topic(如 post-creation-topiccomment-creation-topic)。
    • 后端服务: 使用不同的微服务或消费者组来处理,可以为它们配置不同的资源(CPU/内存)和扩缩容策略。
    • 数据存储: 使用不同的数据库表,甚至不同的数据库实例(如帖子用文档数据库,评论用关系型数据库)。
  • 优点:

    • 性能隔离与稳定性: 评论系统的高并发和帖子的重处理逻辑互不干扰。评论可以做到毫秒级响应,而帖子的复杂处理不会拖垮整个系统。
    • 资源精细化配置: 可以为评论分配更多、更轻量的服务实例来应对高并发;为帖子分配计算能力更强、数量较少的服务实例来处理复杂逻辑。成本最优。
    • 高可扩展性和维护性: 两个系统可以独立演进、独立扩容、独立部署。为一个系统增加新功能完全不影响另一个。
  • 缺点:

    • 架构复杂度高: 需要设计和维护两套独立的处理链路,增加了开发和运维的初始成本。
    • 数据一致性问题: 在某些场景下(如删除帖子时需要删除其下所有评论),需要引入分布式事务处理跨服务的事务或数据一致性问题。

秒杀系统

  • 商品秒杀
  • 12306 抢票

共性分析:
典型的“瞬时写多”场景,核心矛盾是库存有限,但请求者远超库存量。目标是防止超卖系统宕机

设计思路:

  1. 流量分层过滤:
    • 前端: 点击“秒杀”按钮后,按钮置灰,防止重复提交。
    • CDN/Nginx: 缓存静态的商品页面,封禁恶意 IP。
    • 网关: 基于用户 ID 或设备 ID 做第一层限流。
  2. 库存预减与校验:
    • 核心: 将商品库存提前加载到 Redis 中。例如 stock:product_123 -> 100
    • 抢购: 所有请求到达服务层后,执行 Redis 的 DECR 命令。如果 DECR 后的值 >= 0,说明抢到了“资格”,可以继续下一步。如果 < 0,则直接告知用户已售罄。这个操作必须是原子的,Redis 单线程模型天然满足。
  3. 异步下单:
    • 获得资格的用户,其请求 (UserID, ProductID) 被放入 Kafka 消息队列。
    • 后端订单服务作为消费者,从队列中取出消息,慢慢创建订单、扣减数据库库存、调用支付等。

面试官反问:

  1. “Redis 扛不住怎么办?或者 Redis 挂了怎么办?”
    • 库存分桶:单个 Redis 实例确实有瓶颈。可以使用 Redis Cluster 分散压力。但对于单个热点商品的 Key,仍然是单点。可以考虑,将 100 个库存分散到 10 个 key 中 stock:product_123:1stock:product_123:10,每个 key 放 10 个库存,请求随机路由到一个 key 上 DECR。如果 Redis 挂了,这是单点故障,需要有主从切换、哨兵或集群机制来保证高可用。
    • 服务降级:在业务层面,可以直接返回“系统繁忙”。
  2. redis 并发度多少考虑上集群?
    • 我不会给一个固定的 QPS 数字,而是看两个关键点:
      1. 性能瓶颈:主要看 CPU。当监控到 Redis 单核 CPU 持续饱和,导致命令延迟明显增加时,就必须上集群来横向扩展了。
      2. 高可用(HA):即使性能足够,为了避免单点故障,只要业务不允许分钟级的服务中断,就应该上集群。
    • 另外,针对秒杀的热点商品,还需要配合库存分桶,将压力分散到集群的多个节点上,才能真正解决问题。
  3. “如何防止恶意脚本刷单?”
    • 深化: 需要综合手段。事前,秒杀链接动态生成,需要用户验证(如验证码、滑块)才能获取。事中,在网关层分析用户行为,如单个 IP/用户 ID 的请求频率、请求来源等。事后,在订单消费端,可以对“抢到资格”的用户进行风控校验,如检查其收货地址、历史购买行为等,发现异常则将订单置为无效并回补库存。
  4. 如果在 redis 判断完有秒杀资格后,在数据库上下单并扣库存失败了怎么办?
    • 这是一个数据一致性问题,我会通过两层机制来保证:
      1. 可靠的实时补偿:下单操作失败后,我们会先重试。如果依然失败,就将该消息投递到死信队列。由一个独立的补偿服务消费死信队列,对 Redis 库存进行回补(INCR),并记录告警。
      2. 定期的离线校准:作为最终兜底,我们会有定时对账脚本,在凌晨低峰期核对 Redis 和数据库的数据,发现不一致就进行修复,确保数据的最终一致性。
  5. “异步下单后,用户付款超时了怎么办?”
    • 跟第 4 个问题的区别:在于第 4 个问题通常是系统故障,需要进行系统容错设置,而第 5 个问题是业务流程问题,只需回收业务资源即可。
    • 延迟消息:订单服务在创建订单时,状态为“待支付”,并设置一个过期时间(如 15 分钟)。可以使用延迟队列(如 RabbitMQ 的 TTL+DLX,或 RocketMQ 的延迟消息)实现。15 分钟后,如果订单状态仍为“待支付”,一个定时任务或延迟消息消费者会来关闭此订单,并回补 Redis 和数据库中的库存

短视频/评论/帖子点赞与广告点击

共性分析:
这两个看似不同的业务场景,在系统设计层面展现出惊人的共性,都属于典型的海量写入、读写分离、异步处理的架构模式。

  1. 流量削峰填谷: 两个场景都面临瞬时写入流量远超后端常规处理能力的挑战(热点内容、广告活动)。消息队列(Kafka) 在此都扮演了至关重要的“蓄水池”角色,将前端的瞬时洪峰流量转化为后端平稳消费的数据流,保护了下游数据库和处理系统。
  2. 用户体验优先: 设计都遵循了“快速响应”原则。无论是点赞还是广告点击,前端都立即向用户反馈操作成功,而将真正的、耗时的处理(写数据库、复杂计算)放到后端异步完成。这极大地提升了用户体验。
  3. 读写路径分离: 写入路径(点赞/点击 -> Kafka -> 消费者)和读取路径(从 Redis/ClickHouse 读取)被清晰地分离开。读取操作命中的是为查询优化过的高性能缓存或数据仓库,避免了直接查询事务性数据库,保证了高可用和高性能的读取。
  4. 最终一致性: 两个场景都牺牲了数据的强一致性,换取了系统的高可用性和吞吐量。点赞数晚几秒更新、广告报表有分钟级延迟,在业务上都是可以接受的。

短视频/评论/帖子点赞

核心差异性:
此场景的核心是用户状态的变更聚合计数。它更关注个体用户与内容之间的关系(是否点赞),以及这个关系的聚合结果(总点赞数)。处理逻辑相对简单,主要是计数和状态标记。

面试官反问与深化剖析:

  1. “如何防止用户重复点赞?”

    • 问题本质: 考验对幂等性状态判断的设计。点赞操作是一个状态切换(未赞 -> 已赞),需要防止无效的重复操作。
    • 深化方案: 在写入 Kafka 之前,使用 Redis SADD(将用户 ID 添加到内容的点赞用户 Set 中)进行前置判断。这是一个原子操作,能高效地完成去重。这步是关键,它将一个非幂等的操作(直接累加计数)转换为了一个幂等的操作(重复 SADD 同一成员无效)。它保证了进入消息队列的都是有效的、首次的点赞/取消点赞事件。
  2. “如果更新点赞数的消费者挂了怎么办?”

    • 问题本质: 考验对消息队列可靠性消费者健壮性的理解。
    • 深化方案: 强调**“At-Least-Once”**(至少一次)语义的保障。
      • 消息不丢: Kafka 本身通过多副本和持久化保证消息不丢。
      • 消费不漏: 关键在于手动 ACK 机制。消费者在完成更新数据库更新 Redis 缓存这两个核心业务逻辑之后,才向 Kafka 提交消费位点(offset)。如果中途宕机,未提交 offset 的消息会在消费者重启后被重新消费,保证了逻辑的最终执行。
      • 处理失败: 对于因程序 bug 或数据问题导致反复消费失败的消息,引入死信队列(Dead Letter Queue)。当一条消息重试多次仍然失败后,将其投递到死信队列,并发出告警,由人工介入排查,避免“毒丸消息”阻塞整个消费队列。
  3. “热点贴的点赞/评论会导致数据倾斜,如何处理?”

    • 问题本质: 考验对分布式系统瓶颈的识别与应对能力,特别是热点 Key 问题。
    • 深化方案: 核心思想是 “分而治之”,将单个热点打散。
      • Kafka 侧: 不能简单用 ContentID 做分区键。采用 ContentID + a_random_number 作为分区键,将同一个热点内容的消息随机打散到 Kafka 的多个分区,由多个消费者并行处理,提高了写入吞吐量。
      • Redis 侧: 读取时也会有热点。可以采用多级缓存(服务本地缓存 Guava Cache + Redis 分布式缓存)来抗量。对于极热的 Key,可以做数据分片,例如将 like_count:{ContentID} 拆分为 like_count:{ContentID}:1, like_count:{ContentID}:2, … like_count:{ContentID}:N。写入时随机更新一个分片,读取时轮询所有分片或由一个后台任务异步聚合总数到 like_count:{ContentID}:total 中供前端读取。

广告点击与计费

核心差异性:
此场景的核心是不可变事件流的精确处理价值计算。它不关心“状态”,而是将每一次点击都视为一个独立的、需要被记录和分析的事件。其最终目标是准确计费,因此对数据的完整性和准确性要求远高于点赞场景。处理逻辑也复杂得多,涉及反作弊、多维度聚合等。

面试官反问与深化剖析:

  1. “如何保证计费的准确性?数据在处理过程中丢失了怎么办?”

    • 问题本质: 考验对端到端数据一致性,特别是金融级别**“Exactly-Once”**(精确一次)处理语义的理解。
    • 深化方案: 这是一个系统工程,需要全链路配合。
      • Source 端可重放: Kafka 是可重放的 Source,是实现 Exactly-Once 的基础。
      • Processing 端有状态与容错: Flink/Spark Streaming 的 Checkpoint(检查点)机制是核心。它会周期性地将所有计算任务的内部状态和消费 Kafka 的 offset 一起,原子性地快照到持久化存储(如 HDFS)中。当任务失败重启,可以从最近一次成功的 Checkpoint 恢复,确保数据不多(不重复处理)也不少(不丢失)。
      • Sink 端事务性: 输出端(Sink)必须支持事务。例如,Flink 提供了 Two-Phase Commit Sink (两阶段提交)。它先预提交(pre-commit)事务,当 Flink 确认该 Checkpoint 成功后,再正式提交(commit)事务。这样能保证,即使在写入的最后一步失败,数据也不会被部分写入,从而实现了从 Kafka 到最终数据库的端到端 Exactly-Once。
  2. “反作弊规则是动态变化的,如何实现?”

    • 问题本质: 考验系统设计的灵活性和可扩展性,避免硬编码。
    • 深化方案: 将规则与计算引擎解耦。
      • 规则外部化: 将反作弊规则(如“同一设备 ID 10 秒内点击超过 5 次视为作弊”)存储在外部系统,如数据库、Redis 或配置中心(如 Apollo, Nacos)。
      • 动态加载: 流处理任务(Flink)不能将规则写死在代码里。可以通过以下方式动态应用新规则:
        • 广播流(Broadcast Stream): 将规则流作为一个特殊的输入流广播给所有计算任务。当规则变更时,发送一条新的规则消息,所有任务都能接收到并更新自己的内部规则。
        • 定时拉取: 每个计算任务实例(Task Manager)可以内嵌一个定时器,周期性地从外部配置中心拉取最新的规则集。
      • 这种设计使得运营或算法同学可以随时更新反作弊策略,而无需重启流计算任务,实现了业务的敏捷响应。

社交信息系统

  • 朋友圈,出度很大的人(关注的朋友多)和入度很大的人(受关注的大 V)如何处理。
  • 群组,群发消息如何处理。

共性分析:

  1. 写扩散 (Fan-out): 这是最核心的共性。一条信息的产生(发朋友圈、发群聊消息),需要被分发、通知到成百上千甚至数百万的目标用户。如何高效、低成本地完成这一扩散过程,是系统设计的关键。
  2. 状态同步 (State Synchronization): 用户在系统中的行为会产生状态。朋友圈的“点赞”、“评论”,IM 的“已读”、“撤回”,都需要在多个用户、多个设备之间进行同步,保证数据的一致性。
  3. 海量存储与快速检索 (Massive Storage & Fast Retrieval): 社交系统会产生海量的用户生成内容(UGC)和消息数据。如何存储这些数据,并能让用户快速地拉取到自己的时间线或历史消息,对存储和索引的设计提出了很高要求。
  4. 实时性要求 (Real-time Requirement): 用户期望信息能被准实时地接收和展示,这对系统的消息推送链路和处理延迟有严格要求。

尽管存在共性,但不同场景的业务侧重点不同,导致其技术选型和设计细节有显著差异。Feed 流更关注最终一致性读取效率,而 IM 则将消息的可靠性、顺序性放在首位。

社交 Feed 流系统(朋友圈)

设计思路:
核心是解决大 V 和普通用户发布动态时,粉丝如何接收的问题。采用推拉结合的混合模式是业界的成熟方案。

  1. 推模式 (Push / Write-on-fan-out):普通用户(粉丝数少,如 < 1 万)采用。用户发动态时,通过消息队列异步地将动态 ID 推送到其所有粉丝的“收件箱”(Timeline Cache,如 Redis ZSET)。用户刷 Feed 时直接读取自己收件箱,读性能极高。
  2. 拉模式 (Pull / Read-on-fan-out):大 V(粉丝数多,如 > 1 万)采用。若对百万粉丝进行推送,写扩散成本巨大。因此大 V 发动态时,只将动态写入自己的“发件箱”。
  3. 推拉结合 (Hybrid Model): 用户刷 Feed 时,其完整的时间线 = 拉取自己的“收件箱”内容(来自所关注的普通用户) + 实时拉取所关注大 V 的“发件箱”内容,最后将两部分内容聚合、按时间排序后返回。

面试官反问:

  1. “在推拉结合模式下,如何定义谁是‘大 V’?这个阈值是固定的吗?”
    • 深化: “大 V”的定义(如粉丝数超过 1 万)应该是动态可配置的。并且,一个普通用户可能成长为大 V,反之亦然。需要有后台任务定期扫描用户粉丝数变化,当一个用户跨越阈值时,需要进行模式迁移。例如,一个普通用户变成大 V,就不再对他进行推模式,同时可能需要清理掉之前为他粉丝推送的历史数据。
  2. “用户新关注了一个人,他的 Feed 流应该如何更新?”
    • 深化: 如果对方是普通用户(推模式),可以触发一个后台任务,将该用户最近的 N 条动态(如 100 条)推送到新粉丝的收件箱中。如果对方是大 V(拉模式),则无需做任何操作,因为下次用户刷 Feed 时,自然会去拉取这个大 V 的内容。
  3. “推模式怎么优化?瓶颈在哪?压力在哪?”
    • 深化:
      • 瓶颈与压力: 主要压力在于Fan-out 服务缓存层。当一个用户发帖,后台需要:1. 拉取粉丝列表(可能上万);2. 循环写入每个粉丝的收件箱缓存。这个过程对执行 Fan-out 的服务器造成 CPU 和网络压力,对 Redis 等缓存系统造成巨大的写 QPS 压力。
      • 优化:
        1. 异步化: 使用消息队列(如 Kafka/RocketMQ)。用户发帖请求只需将PostIDUserID写入 MQ 即可快速返回。
        2. 服务解耦: 由专门的 Fan-out 消费服务组来处理 MQ 中的消息,执行耗时的粉丝列表拉取和缓存写入操作。
        3. 消费者优化: 消费者可以进行批量处理。例如,从 MQ 一次性获取多条消息,或者对同一个粉丝的收件箱操作使用 Redis Pipeline 进行批量写入,减少网络往返。
  4. “上述生产者消息队列消费者瓶颈,消息积压怎么解决?”
    • 深化: 消息积压意味着消费者的处理速度跟不上生产者的速度。
      • 核心思路:提升消费能力。
      1. 水平扩容 (Scale Out): 最直接的方法。增加消费者实例(进程/线程),如果使用 Kafka,可以增加 Topic 的分区数(Partition)并相应增加消费者数量,提高并行处理能力。
      2. 优化消费逻辑: 检查消费者代码,看是否存在性能瓶颈,如不合理的数据库查询、外部 API 调用过慢等。将非核心逻辑异步化。
      3. 批量处理: 确保消费者是批量从 MQ 拉取消息,并批量处理(如批量写入 Redis/DB),而不是一条一条处理。
      4. 资源监控与告警: 对 MQ 的消费延迟(Lag)设置监控和告警,一旦延迟超过阈值,及时介入,或者触发自动扩容策略(如果使用了云原生架构)。
  5. “数据库写入压力大怎么优化?”
    • 深化: 这是后端服务的普遍问题,尤其是在写密集型应用中。
      1. DB/缓存一致性策略: 采用先更新缓存,再异步更新数据库的策略(如通过 MQ)。这样 API 的写入请求可以快速响应,数据库的压力被削峰填谷。
      2. 分库分表 (Sharding): 这是应对海量数据写入的根本性解决方案。对核心的表(如动态表、用户关系表)进行水平切分。例如,动态表可以按UserID进行哈希分片,将写入压力分散到多个数据库实例和物理服务器上。
      3. 使用写入性能更好的存储引擎: 如 MySQL 的 InnoDB。或在某些场景下,使用 LSM-Tree 架构的数据库(如 HBase, Cassandra),它们对顺序写和随机写有很好的优化。
      4. CQRS (命令查询职责分离): 将系统的写操作(Command)和读操作(Query)分离到不同的服务和数据库中。我们的推拉结合模型本身就是一种 CQRS 思想的体现。

即时通讯系统 (IM)

设计思路:

IM 的核心是保证消息必达、有序、不重。设计上必须放弃简单的“推送-ACK”模型,转向更为严谨的基于序列号 (Sequence ID) 的同步模型

  1. 消息上行(发送方 -> 服务器):
    • 客户端生成本地唯一ClientMessageID,将消息发出。在收到服务器 ACK 前,UI 显示“发送中”。
    • 服务器收到消息后,利用ClientMessageID做幂等性检查,防止客户端重试导致消息重复。
    • 为该会话(单聊/群聊)生成一个严格单调递增SeqID,将消息与SeqID绑定后存入存储层(如 HBase/MySQL)。
    • 向发送方 ACK,告知SeqID,发送方 UI 更新为“已发送”。
  2. 消息下行(服务器 -> 接收方):
    • 放弃服务端主动重推完整消息! 这是导致客户端耗电的根源。
    • 轻量级通知: 服务器通过长连接(如 WebSocket)或系统推送(APNs/FCM)向接收方发送一个极小的通知,如{conversationID: "group_123", new_seq_id: 1024}
    • 客户端主动同步: 客户端在收到通知、或 APP 启动、或断线重连时,会携带本地已收到的最大SeqID (last_sync_seq) 向服务器发起同步请求。
    • 差量拉取: 服务器根据客户端的last_sync_seq,从存储层拉取所有SeqID > last_sync_seq的消息,批量返回给客户端。
    • 本地存储与 UI 更新: 客户端收到消息后,按SeqID排序,存入本地数据库,然后更新 UI。

面试官反问:

  1. “你提到客户端收到消息后返回 ACK。如果客户端因为 bug 或崩溃不返回 ACK,服务器不断重试推送会导致用户手机耗电,这怎么解决?”
    • 深化: 这正是我设计的“序列号同步模型”所要解决的核心问题。在这个模型下,服务器根本不会因为客户端不返回 ACK 而重试推送完整的消息体
      • 责任反转: 消息的可靠投递,责任方从“服务器”转移到了“客户端”。服务器的职责是可靠存储和发出轻量级通知。
      • 客户端驱动: 客户端负责在自己状态良好时(有网、在前台)通过SeqID主动同步数据。即使客户端崩溃,它在下次启动时依然能通过同步协议拉取到所有错过的消息,因为last_sync_seq是持久化在本地的。这就从机制上根治了服务器无效重试带来的电量问题。
  2. “如何保证消息的绝对顺序性?比如网络抖动,后发的消息先到了怎么办?”
    • 深化: 顺序性完全由服务器生成的、在单个会话内严格单调递增且连续的SeqID 来保证。
      • 服务端保证: 在中心化的服务节点上为每个会话维护一个SeqID生成器(如使用 Redis INCR),保证了分配出的SeqID是连续且递增的。
      • 客户端保证: 客户端在收到消息后,不能直接展示。必须按照SeqID进行排序。如果收到了SeqID=102,但本地最新的SeqID100,说明101还没到。此时 UI 可以先不展示102,或显示“消息加载中”,直到通过同步机制把101拉取回来,再按序渲染。
  3. “如果我同时在手机和电脑上登录,如何保证两边的消息同步,包括我的已读状态?”
    • 深化: 这是多端同步问题,SeqID模型可以优雅地扩展。
      • 消息同步: 每个设备(手机、PC)独立维护自己的last_sync_seq。当 PC 端登录时,它会从自己记录的SeqID开始同步,保证消息内容与服务器一致。用户在一端发送的消息,会经由服务器扩散,被另一端通过同步机制拉取到。
      • 已读状态同步: 已读状态是另一种类型的事件,也需要同步。当用户在手机上将某会话已读到SeqID=105时,手机客户端会向服务器发送一个“已读回执”指令。服务器收到后,会向该用户的其他在线设备(如 PC)推送一条“指令性消息”(例如,类型为read_receipt的特殊消息),告知 PC 端“conversationID 已读至 105”。PC 客户端收到该指令后,更新自己的 UI,清除未读红点。
  4. “如何处理‘已读’状态同步?比如在一个 500 人的大群里。”
    • 深化: 群消息的已读状态非常复杂。简单地为每个用户和每条消息记录一个已读状态,会导致海量写操作。通常会做简化或聚合
      • 方案一(只管自己): 已读状态只在用户本地或服务端为该用户单独记录,不广播给其他人。
      • 方案二(显示已读人数): 另外用一个计数器(如 Redis acker_count:{MessageID})来记录已读人数,用户已读时 INCR 一下。
      • 方案三(需要显示谁已读): 这在企业 IM(如钉钉)中常见。对于小群,可以实时更新;对于大群,可以只显示“xx, xx 等 N 人已读”,点击后才分页拉取完整已读列表。这本质上还是一个读写分离和按需加载的思路。

杂七杂八的场景题

抢红包

  • 高并发流量方面参考正常的秒杀活动设计,也没有那么秒杀。
  • 公平性与随机性,重点说明对象。
    • 线段切割法,这个算法也叫“二倍均值法”,是目前业界公认的、能同时保证公平性和随机性的优秀算法。

      • 核心思想
        在任何时刻,为当前用户计算随机金额时,要确保剩余的钱足够让剩余的人每人至少能分到 0.01 元。

      • 算法步骤
        假设总金额为 M,总人数为 N
        当第 k 个用户来抢红包时,此时剩余金额为 M_rem,剩余人数为 N_rem

        1. 安全性校验:如果 N_rem = 1,那么该用户直接获得全部 M_rem,算法结束。
        2. 计算随机范围
          • 最小金额min = 0.01 元。
          • 最大金额max = M_rem / N_rem * 2
        3. 生成随机金额:在 [min, max] 区间内生成一个随机数(保留两位小数),作为该用户抢到的金额。
        4. 更新余量:更新 M_remN_rem,等待下一个用户。
      • 为什么这个算法是公平的?
        数学期望证明
        在任何一个时间点,对于当前来抢的用户,他能抢到金额的数学期望是:
        E = (min + max) / 2 = (0.01 + M_rem / N_rem * 2) / 2
        由于 0.01 这个值非常小,我们可以近似地认为期望 E ≈ (M_rem / N_rem * 2) / 2 = M_rem / N_rem
        M_rem / N_rem 正是当前“人均剩余金额”。这意味着,无论你是第几个抢,你抢到的钱的期望值,永远是当前的人均剩余金额。这就从数学上保证了绝对的公平性。

    • 预生成:简单粗暴生成 N 份然后正常发放即可,就是占存储空间。

分布式任务执行框架

Redis 分布式锁在持有锁的线程寄掉之后如何让其他线程察觉到并清除锁然后给自己加锁

参考 Percolator 思想,去除仲裁者身份,所有线程都可以在一定条件下清除其他线程的锁