基于本文回答

播面 播面

刷题像听歌,多听自然懂
0
评论

像微博/微信这种高并发的点赞/取消点赞功能,后端如何设计?

知识点图片

设计像微博、微信朋友圈这样高并发的点赞/取消点赞功能,核心挑战在于巨大的瞬时写流量(Write Heavy)海量数据的存储与读取

直接操作数据库(如 MySQL)是绝对扛不住的,必须采用 “缓存 + 异步 + 最终一致性” 的架构设计。

以下是详细的架构设计方案,分为核心流程、缓存设计、持久化设计、以及极端场景优化四个部分。


1. 核心架构思路

整体遵循 Write-Behind(写后/异步写入) 策略:

  1. 用户点赞:请求先落到 Redis 缓存,直接返回成功(前端即时反馈)。
  2. 异步处理:通过消息队列(MQ)削峰填谷,将点赞动作异步写入数据库。
  3. 数据读取:优先读 Redis,Redis 没有再查数据库并回填。

2. Redis 缓存设计 (抗压第一线)

Redis 是抗住高并发的核心。我们需要存储两类数据:

  1. 点赞数(Count):这条微博有多少人点赞。
  2. 点赞状态(Relationship):具体是谁点了赞(用于判断“我是否点赞过”以及展示“张三、李四觉得很赞”)。

2.1 数据结构选择

  • 方案 A:Hash 结构 (推荐)

    • Key: Like::Post::{post_id}
    • Field: user_id
    • Value: timestamp (点赞时间)
    • 优点:查询“我是否点赞”非常快 (O(1)O(1)),可以获取点赞列表。
    • 缺点:如果单条微博点赞数过百万,这就变成了 大 Key (Big Key),会阻塞 Redis 线程。
  • 方案 B:Set 结构

    • Key: Like::Post::{post_id}
    • Value: user_id
    • 优点:天然去重。
    • 缺点:同样存在 Big Key 问题。
  • 方案 C:Bitmap (极致节省空间)

    • Key: Like::Post::{post_id}
    • Offset: user_id (要求 user_id 是连续整数)
    • 优点:极省内存,几亿点赞也只占几十 MB。
    • 缺点:不适合存储非整数 ID,无法存储点赞时间,且在分片集群中维护复杂。
  • 方案 D:Redis String (计数) + ZSet (列表) (主流方案)

    • 计数:使用 INCR 操作维护一个 Key Like::Count::{post_id}
    • 列表:使用 ZSet 存储最新的 N 个点赞用户(如只存最近 1000 个),Key 为 Like::List::{post_id},Score 为时间戳。
    • 用户状态:为了快速判断“我是否点赞”,可以使用 Bloom Filter 或者单独维护用户的点赞 Set Like::User::{user_id}(存该用户点赞过的文章 ID)。

2.2 解决 Big Key 问题(热点微博)

当某个明星发微博,瞬间几百万点赞,单一 Redis Key 会成为热点。
策略:分片(Sharding)
将一个大 Key 拆分为多个小 Key。

  • 逻辑:Key = Like::Post::{post_id} :: {user_id % 100}
  • 写入时:根据用户 ID 取模,写入对应的分片 Key。
  • 读取总数时:汇总所有分片 Key 的 size(或者单独维护一个总 Count Key)。

3. 消息队列设计 (削峰填谷)

Redis 写入成功后,不能直接写库,否则数据库会挂。

  1. 发送消息:Server 将点赞动作(user_id, post_id, action=ADD/REMOVE, time)发送到 MQ(如 Kafka, RocketMQ)。
  2. 合并写入 (Batch)
    • Consumer 消费消息时,不要一条一条插库。
    • 聚合:在内存中积累 100 条或者 1 秒内的操作,批量合并。
    • 优化:如果同一个用户对同一文章 1 秒内点了赞又取消,这两条消息可以在内存中抵消,无需落库。

4. 数据库持久化设计 (最终一致性)

数据库主要用于数据归档、冷数据查询和校对。

4.1 表结构设计

不要做物理删除(DELETE),建议使用逻辑删除流水表

表 1:点赞流水表 (Like_Flow)

  • 记录每一次操作,只增不减。
  • id, user_id, post_id, action_type (1=点赞, 0=取消), create_time

表 2:点赞关系表 (Like_Relation) - 也就是现状表

  • user_id, post_id, status (1=有效, 0=取消), update_time
  • 唯一索引Unique Key (user_id, post_id)
  • 操作:使用 INSERT ON DUPLICATE KEY UPDATE status = VALUES(status) 语法,避免先查后写。

4.2 分库分表

单表数据量会极大(百亿级)。

  • 分片键:通常根据 post_id 分片,方便查询“这篇文章谁点赞了”。
  • 如果业务需要频繁查询“我点赞了哪些文章”,则需要建立基于 user_id 的索引表或异构数据(如 ES)。

5. 完整交互流程总结

场景一:点赞

  1. API 层:校验用户 Token。
  2. Redis
    • SADD Like::Post::{post_id} {user_id} (存关系)
    • INCR Like::Count::{post_id} (加计数)
    • 注意:使用 Lua 脚本保证这两个操作的原子性。
  3. MQ:发送一条“点赞消息”。
  4. Return:接口直接返回成功。
  5. Worker:消费 MQ,批量写入 MySQL Like_Relation 表。

场景二:取消点赞

  1. Redis
    • SREM Like::Post::{post_id} {user_id}
    • DECR Like::Count::{post_id}
  2. MQ:发送一条“取消点赞消息”。
  3. Worker:消费 MQ,更新 MySQL status=0

场景三:读取(查看帖子)

  1. 查 Redis:获取 Like::Count::{post_id}SISMEMBER (判断当前用户是否点赞)。
  2. Redis 击穿/未命中:如果 Redis 没数据(冷数据),去查 MySQL,然后回填 Redis 并设置过期时间。

6. 极端场景与优化 (面试加分项)

6.1 缓存与数据库不一致怎么办?

由于是异步写入,Redis 可能会丢数据(宕机),或者 MQ 积压导致 DB 延迟。

  • 以 Redis 为准:在前端展示上,短期内以 Redis 数据为准。
  • 定时校对:使用定时任务(T+1)扫描热门文章的数据库 Count 和 Redis Count,如果不一致,进行修复(通常是 Redis 覆盖 DB,或者 DB 覆盖 Redis,取决于业务定义谁是 Source of Truth)。

6.2 甚至 Redis 都扛不住热点 Key 怎么办?

比如微博热搜第一,QPS 几十万。

  • 本地缓存 (Local Cache):在应用服务(Tomcat/Go Server)内存中再加一层缓存(如 Guava Cache/BigCache)。
    • 当读取请求来时,先读本地内存,有则返回。
    • 写请求依然走 Redis,但读请求可以被本地缓存挡住 90%。
    • 缺点是不同服务器间数据有短暂不一致(几秒),但对于点赞数来说,用户不敏感(看到 100w 和 100.01w 没区别)。

6.3 恶意刷赞怎么办?

  • 限流:针对单 User ID 做频率限制。
  • 风控:在进入 Redis 之前,通过风控系统判断 IP、设备指纹,如果是机器号直接拦截。

总结图示

plaintext
[用户] 
  | (点赞)
  v
[网关/API服务] --(1. 本地缓存校验/限流)--> [返回]
  |
  +---(2. 写 Redis: Set/Hash + Counter)---> [Redis 集群]
  |
  +---(3. 异步消息)---> [消息队列 (Kafka)]
                          |
                          v
                    [消费者服务 (Batch)]
                          |
                          v
                    [MySQL 分库分表]
00:00
00:00