# 点赞系统设计

# 介绍
点赞是一个典型的高频写和高频读并存的场景。如果每点一次赞都直接操作 MySQL 数据库,数据库的行锁和磁盘 I/O 会瞬间成为系统瓶颈。
# 架构
为了支撑 QPS 十万甚至百万级的流量,系统采用 Redis 缓存异步写入 + MQ 削峰填谷 + MySQL 持久化 的分层架构。
# 写入链路(点赞/取消赞)
客户端请求:通过 API 网关进行限流、防刷和鉴权。
Redis 状态校验与更新:
- 判断用户是否已点赞(幂等性校验)。
- 利用 Redis 内存操作的高性能,快速更新点赞状态和点赞总数。
投递消息至 MQ:更新完 Redis 后,异步发送一条点赞事件消息到消息队列(如 Kafka 或 RocketMQ),直接返回给用户“成功”。
异步消费落库:消费服务批量拉取 MQ 中的消息,通过批量更新(Batch Update)的方式合并写入 MySQL,极大减轻数据库压力。
# 读取链路(是否点赞 + 点赞数)
优先读缓存:聚合查询直接透传到 Redis。
缓存回源:若缓存失效或冷数据,读取 MySQL 并回填缓存。
# 核心数据结构设计
点赞需要记录两个核心数据:谁给什么点赞了(明细) 和 这个东西有多少人点赞(计数)。
# 点赞状态(明细)
数据结构:Set 或 Bitmap
设计策略:
方案 A:Set。Key 为 like:detail:{business_type}:{entity_id},Value 为 user_id。
优点:判断 SISMEMBER 极快,方便找出点赞的用户列表。
缺点:如果某个大 V 的文章有几千万点赞,Set 会非常大(BigKey 风险)。
business_type(业务类型)/ entity_id(实体 ID / 被点赞对象 ID)
方案 B:Hash。Key 按照 entity_id 取模(如 1000 个分片),Field 为 entity_id:user_id,Value 为 1(点赞)或 0(取消赞)。可以有效打散 BigKey。
如果用常规的hash设计,结构通常如下,会导致BigKey
- Key:like:detail:ARTICLE:10086(一篇文章一个 Key)
- Field:user_id(点赞的用户 ID)
- Value:1(已赞)或 0(取消赞)
为了不让某一个 Key 变得无限大,我们需要把一个大 Hash 拆分成 1000 个小 Hash,让数据均匀分布。这就是“取模分片”。
分片id(shard_id)= entity_id mod 1000
结构设计为
- Key:like:detail:ARTICLE:{shard_id}(不再包含具体的 entity_id,只包含分片号!)
- Field:entity_id:user_id(把文章 ID 和用户 ID 拼在一起作为 key)
- Value:1 或 0
方案 C:Bitmap(适用于用户 ID 是连续递增的整数)。Key 为 like:bitmap:{business_type}:{entity_id},Offset 为 user_id。极其节省内存。
# 点赞总数(计数)
数据结构:Hash 或 String
设计策略:使用 Hash 存储。Key 为 like:count:{business_type}:{date_shard},Field 为 entity_id,Value 为点赞数。使用 HINCRBY 进行原子原子加减。
date_shard 为时间分片,表示文章发布的时间
# MySQL 数据库设计
由于数据最终要落库,表结构要尽可能精简,并建立合适的索引。
点赞明细表(以文章点赞为例)
CREATE TABLE `article_like_detail` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`article_id` BIGINT NOT NULL COMMENT '文章ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-点赞,0-取消点赞',
`create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_article_user` (`article_id`, `user_id`),
INDEX `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
分库分表策略:当数据量达到千万/亿级时,按照 article_id 进行哈希分库分表,保证同一文章的点赞数据落入同一张表,方便批量聚合。
# 高并发场景下的挑战
# 挑战一:Redis 缓存与 MQ 消息的一致性
问题:如果在 Redis 操作成功后,发送 MQ 失败了,或者服务器挂了,会导致 Redis 里的数据和 MySQL 不一致(少算或多算)。 架构师解决方案:
Lua 脚本保证 Redis 原子性:将“检查是否已赞 + 改变状态 + 改变计数”写入 Lua 脚本,确保 Redis 内部状态绝对一致。
本地消息表 / 事务消息:使用 RocketMQ 的事务消息。先发半消息(Half Message),Redis 操作成功后确认提交消息。
兜底策略(定时对账):通过 Binlog(Canal)或定时任务,定期将 Redis 中的高频热点计数与 MySQL 进行校对,以 Redis 为准异步修正 MySQL。
# 挑战二:极端热点事件(名人爆款明星点赞)
问题:某个超级大 V 发了一条微博,瞬间产生几十万 QPS 的点赞,Redis 单 Key 遭遇热点(Hot Key)击穿,导致 Redis 代理或分片集群瘫痪。 架构师解决方案:
本地多级缓存(Caffeine):在 Java 服务节点内存中缓存“点赞计数”。在秒级内,直接在内存中做 LongAdder 计数累加,每隔 500ms 批量同步一次 Redis。
Key 散列(分片):将热点 entity_id 加上随机后缀(如 entity_id_1, entity_id_2),分散到不同的 Redis 节点上,读取时再做聚合(Reduce)。
# 挑战三:MQ 消费堆积与 MySQL 写入瓶颈
问题:即使有 MQ 削峰,如果消费端一条条插入 MySQL,依然会把数据库拖垮。 架构师解决方案:
批量聚合消费(Batch Consumer):消费端不要来一条消费一条。设置一个窗口(比如每 200ms 或聚合满 1000 条),在内存中利用 Map 进行去重和合并(如果一个用户在 200ms 内点赞又取消,直接抵消不用落库)。
批量写数据库:合并后利用 INSERT INTO ... ON DUPLICATE KEY UPDATE 进行批量提交,将成千上万次数据库 I/O 变成一次 Batch 操作。