像微博/微信这种高并发的点赞/取消点赞功能,后端如何设计?
设计像微博、微信朋友圈这样高并发的点赞/取消点赞功能,核心挑战在于巨大的瞬时写流量(Write Heavy)和海量数据的存储与读取。
直接操作数据库(如 MySQL)是绝对扛不住的,必须采用 “缓存 + 异步 + 最终一致性” 的架构设计。
以下是详细的架构设计方案,分为核心流程、缓存设计、持久化设计、以及极端场景优化四个部分。
1. 核心架构思路
整体遵循 Write-Behind(写后/异步写入) 策略:
- 用户点赞:请求先落到 Redis 缓存,直接返回成功(前端即时反馈)。
- 异步处理:通过消息队列(MQ)削峰填谷,将点赞动作异步写入数据库。
- 数据读取:优先读 Redis,Redis 没有再查数据库并回填。
2. Redis 缓存设计 (抗压第一线)
Redis 是抗住高并发的核心。我们需要存储两类数据:
- 点赞数(Count):这条微博有多少人点赞。
- 点赞状态(Relationship):具体是谁点了赞(用于判断“我是否点赞过”以及展示“张三、李四觉得很赞”)。
2.1 数据结构选择
方案 A:Hash 结构 (推荐)
Key:Like::Post::{post_id}Field:user_idValue:timestamp(点赞时间)- 优点:查询“我是否点赞”非常快 (),可以获取点赞列表。
- 缺点:如果单条微博点赞数过百万,这就变成了 大 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操作维护一个 KeyLike::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 写入成功后,不能直接写库,否则数据库会挂。
- 发送消息:Server 将点赞动作(
user_id,post_id,action=ADD/REMOVE,time)发送到 MQ(如 Kafka, RocketMQ)。 - 合并写入 (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. 完整交互流程总结
场景一:点赞
- API 层:校验用户 Token。
- Redis:
SADD Like::Post::{post_id} {user_id}(存关系)INCR Like::Count::{post_id}(加计数)- 注意:使用 Lua 脚本保证这两个操作的原子性。
- MQ:发送一条“点赞消息”。
- Return:接口直接返回成功。
- Worker:消费 MQ,批量写入 MySQL
Like_Relation表。
场景二:取消点赞
- Redis:
SREM Like::Post::{post_id} {user_id}DECR Like::Count::{post_id}
- MQ:发送一条“取消点赞消息”。
- Worker:消费 MQ,更新 MySQL
status=0。
场景三:读取(查看帖子)
- 查 Redis:获取
Like::Count::{post_id}和SISMEMBER(判断当前用户是否点赞)。 - 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 分库分表]