点赞功能看似简单,但日活千万下,如何保证数据一致性、防止刷赞、应对热点流量?本文将带你从零实现一套可支撑高并发的点赞系统。
一、业务场景与挑战
点赞是几乎所有社交/内容平台的标配功能。当你的产品日活达到千万级,一个简单的点赞按钮背后,其实隐藏着不少技术挑战:
- 高并发写入:热门内容瞬间可能涌入数十万点赞请求
- 数据一致性:点赞状态要准,计数不能错
- 防刷治理:总有"热心用户"想刷爆你的接口
- 热点穿透:明星官宣时,单条内容的QPS可能突破百万
核心设计理念:读写分离、异步解耦、最终一致
二、整体架构设计
先看一张架构全景图(用文字描述):
text
┌─────────┐ ┌─────────┐ ┌─────────────────┐ ┌─────────┐
│ 客户端 │───▶│ 网关层 │───▶│ 点赞服务层 │───▶│ Redis │
│ (防抖) │ │ (限流) │ │ (业务逻辑+Lua) │ │ (缓存) │
└─────────┘ └─────────┘ └────────┬────────┘ └─────────┘
│
▼ (异步消息)
┌───────────────┐
│ Kafka/MQ │
└───────┬───────┘
▼ (批量消费)
┌───────────────┐
│ MySQL │
│ (最终存储) │
└───────────────┘
各层职责:
| 层级 | 技术选型 | 核心职责 |
|---|---|---|
| 客户端层 | 前端/SDK | 防抖处理、乐观更新UI |
| 网关层 | Spring Cloud Gateway | 限流、熔断、鉴权 |
| 服务层 | Spring Boot | 业务编排、Lua脚本执行 |
| 缓存层 | Redis Cluster | 扛实时读写、存储点赞关系 |
| 消息层 | Kafka/Pulsar | 异步解耦、流量削峰 |
| 存储层 | MySQL/TiDB | 数据最终持久化 |
三、核心数据结构设计
3.1 Redis 存储设计
这是系统的关键。我们使用三种数据结构各司其职:
| 业务场景 | Redis结构 | Key设计 | 说明 |
|---|---|---|---|
| 点赞关系 | Hash | like:{type}:{targetId} |
Field=userId, Value=时间戳,支持快速查询单个用户状态 |
| 点赞计数 | String | like_count:{type}:{targetId} |
存储总赞数,INCR/DECR原子操作 |
| 用户点赞列表 | ZSet | user_like:{userId} |
Score=时间戳, Value=targetId,支持分页查询"我赞过的" |
为什么选 Hash 而不是 Set?
Set 虽然也能存 userId,但 Hash 可以额外存储时间戳,便于后续做点赞时间轴展示。此外 Hash 的
HEXISTS命令时间复杂度 O(1),性能极佳。
3.2 数据库表设计
作为数据的最终落脚点,两张核心表就够了:
sql
-- 点赞记录表(记录每一次点赞行为)
CREATE TABLE `like_record` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`target_id` VARCHAR(64) NOT NULL COMMENT '目标业务ID',
`target_type` TINYINT NOT NULL COMMENT '目标类型:1帖子 2评论',
`status` TINYINT DEFAULT 1 COMMENT '状态:1点赞 0取消',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_target` (`user_id`, `target_id`, `target_type`),
KEY `idx_target` (`target_id`, `target_type`)
) ENGINE=InnoDB;
-- 点赞计数表(存储每个目标的点赞总数快照)
CREATE TABLE `like_count` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`target_id` VARCHAR(64) NOT NULL,
`target_type` TINYINT NOT NULL,
`count` BIGINT DEFAULT 0,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_target` (`target_id`, `target_type`)
) ENGINE=InnoDB;
关键点 :like_record 表的唯一索引 uk_user_target 是实现批量 Upsert 的基础,也是防止重复点赞的最后一道防线。
四、核心流程:点赞操作的完整链路
4.1 整体流程图
text
用户点❤️
│
▼
① 网关限流 (滑动窗口)
│
▼
② 执行Lua脚本 (Redis原子操作)
├─ 检查是否已赞
├─ 更新Hash关系
└─ INCR/DECR计数
│
▼
③ 发送Kafka消息 (异步)
│
▼
④ 立即返回 "点赞成功" ← 用户感知到此为止
│
▼ (后台异步进行)
⑤ Kafka消费者批量拉取
│
▼
⑥ 批量Upsert到MySQL
│
▼
⑦ 定时校对 (凌晨兜底)
4.2 第一步:原子性操作 ------ Lua 脚本
这是整个系统最核心的代码。为什么必须用 Lua?
如果分成两步:
查询是否已赞→更新计数,在高并发下会出现竞态条件:两个线程同时查到"未赞",然后都执行 INCR,导致计数多算。Lua 脚本在 Redis 中执行是串行的,天然原子。
点赞/取消赞 Lua 脚本:
lua
-- KEYS[1] = 点赞关系Hash (like:post:123)
-- KEYS[2] = 点赞计数 (like_count:post:123)
-- ARGV[1] = 用户ID
-- ARGV[2] = 操作类型 (1点赞, 0取消)
-- ARGV[3] = 时间戳
local relation_key = KEYS[1]
local count_key = KEYS[2]
local user_id = ARGV[1]
local operate = tonumber(ARGV[2])
local ts = ARGV[3]
local is_liked = redis.call('HEXISTS', relation_key, user_id)
if operate == 1 then
-- === 点赞 ===
if is_liked == 1 then
return 0 -- 幂等,已赞过不再重复处理
end
redis.call('HSET', relation_key, user_id, ts)
redis.call('INCR', count_key)
return 1
else
-- === 取消赞 ===
if is_liked == 0 then
return 0 -- 幂等,本来就没赞
end
redis.call('HDEL', relation_key, user_id)
local cnt = redis.call('DECR', count_key)
if cnt < 0 then
redis.call('SET', count_key, 0)
end
return 1
end
Spring Boot 集成执行:
java
@Component
public class LikeRedisService {
@Autowired
private StringRedisTemplate redisTemplate;
private DefaultRedisScript<Long> likeScript;
@PostConstruct
public void init() {
likeScript = new DefaultRedisScript<>();
likeScript.setLocation(new ClassPathResource("lua/like.lua"));
likeScript.setResultType(Long.class);
}
/**
* 执行点赞操作,返回 1=状态变更,0=幂等无变化
*/
public Long execute(Long userId, String targetId, Integer targetType, Boolean isLike) {
String relationKey = "like:" + targetType + ":" + targetId;
String countKey = "like_count:" + targetType + ":" + targetId;
return redisTemplate.execute(
likeScript,
Arrays.asList(relationKey, countKey),
userId.toString(),
isLike ? "1" : "0",
String.valueOf(System.currentTimeMillis())
);
}
}
4.3 第二步:服务层编排
java
@Service
@Slf4j
public class LikeService {
@Autowired
private LikeRedisService redisService;
@Autowired
private LikeEventProducer producer; // Kafka生产者
@Transactional // 注意:这里只管理本地事务,不涉及Redis
public Boolean toggle(Long userId, String targetId, Integer targetType, Boolean isLike) {
// 1. 执行Redis原子操作(< 1ms)
Long result = redisService.execute(userId, targetId, targetType, isLike);
// 2. 发送异步消息(不阻塞主流程)
LikeEventDTO event = LikeEventDTO.builder()
.userId(userId)
.targetId(targetId)
.targetType(targetType)
.isLike(isLike)
.timestamp(System.currentTimeMillis())
.build();
producer.send(event);
// 3. 返回结果(前端自己做乐观更新)
return isLike;
}
}
这里有个小细节:即使
result == 0(幂等操作),我们仍然发送了 MQ 消息。为什么?因为缓存和 DB 可能因为某些异常出现不一致,多发送一条消息可以让 DB 最终状态与用户操作对齐,相当于多了一层兜底。
五、异步持久化:Kafka 批量消费
点赞的最终一致性 由 MQ 消费者保证。关键在于批量消费 + 批量 Upsert,将数据库 IO 次数从 O(N) 降到 O(1)。
5.1 生产者:快速投递
java
@Component
@Slf4j
public class LikeEventProducer {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
private static final String TOPIC = "like_event";
public void send(LikeEventDTO event) {
String json = JSON.toJSONString(event);
// 使用 targetId 作为分区Key,保证同一目标的消息顺序消费
kafkaTemplate.send(TOPIC, event.getTargetId(), json)
.addCallback(
r -> log.debug("发送成功: {}", event),
ex -> log.error("发送失败: {}", event, ex)
);
}
}
5.2 消费者:批量 Upsert
java
@Component
@Slf4j
public class LikeEventConsumer {
@Autowired
private LikeRecordMapper recordMapper;
@KafkaListener(topics = "like_event", batch = "true", containerFactory = "batchFactory")
public void consume(List<ConsumerRecord<String, String>> records) {
if (records.isEmpty()) return;
List<LikeRecord> list = records.stream()
.map(r -> JSON.parseObject(r.value(), LikeEventDTO.class))
.map(this::convert)
.collect(Collectors.toList());
// 批量插入或更新,每批1000条
int batchSize = 1000;
for (int i = 0; i < list.size(); i += batchSize) {
int end = Math.min(i + batchSize, list.size());
recordMapper.batchInsertOrUpdate(list.subList(i, end));
}
log.info("批量持久化完成,数量: {}", list.size());
}
private LikeRecord convert(LikeEventDTO dto) {
LikeRecord record = new LikeRecord();
record.setUserId(dto.getUserId());
record.setTargetId(dto.getTargetId());
record.setTargetType(dto.getTargetType());
record.setStatus(dto.getIsLike() ? 1 : 0);
record.setUpdateTime(new Date(dto.getTimestamp()));
return record;
}
}
对应的 MyBatis XML(利用唯一索引实现 Upsert):
xml
<insert id="batchInsertOrUpdate" parameterType="list">
INSERT INTO like_record (user_id, target_id, target_type, status, update_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.userId}, #{item.targetId}, #{item.targetType}, #{item.status}, #{item.updateTime})
</foreach>
ON DUPLICATE KEY UPDATE
status = VALUES(status),
update_time = VALUES(update_time)
</insert>
六、防刷治理:滑动窗口限流
点赞接口是最容易被刷的接口之一。我们基于 Redis ZSet 实现滑动窗口限流,相比固定窗口更平滑:
java
@Component
public class SlidingWindowRateLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 检查是否允许通过
* @param key 限流Key (建议: rate:like:{userId})
* @param windowSeconds 窗口大小(秒)
* @param maxRequests 最大请求数
*/
public boolean tryAcquire(String key, int windowSeconds, int maxRequests) {
long now = System.currentTimeMillis();
long windowStart = now - windowSeconds * 1000L;
String member = now + "-" + UUID.randomUUID().toString().substring(0, 6);
String lua =
"redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) " +
"local cnt = redis.call('ZCARD', KEYS[1]) " +
"if cnt < tonumber(ARGV[2]) then " +
"redis.call('ZADD', KEYS[1], ARGV[3], ARGV[4]) " +
"redis.call('EXPIRE', KEYS[1], ARGV[5]) " +
"return 1 " +
"else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(lua, Long.class),
Collections.singletonList(key),
String.valueOf(windowStart),
String.valueOf(maxRequests),
String.valueOf(now),
member,
String.valueOf(windowSeconds * 2) // 过期时间留足冗余
);
return result != null && result == 1L;
}
}
在 Controller 中使用:
java
@PostMapping("/toggle")
public ResultVO toggle(@RequestBody LikeRequest req) {
String limitKey = "rate:like:" + req.getUserId();
if (!rateLimiter.tryAcquire(limitKey, 60, 30)) {
throw new BusinessException("操作过于频繁,请稍后再试");
}
// ... 正常业务
}
七、兜底方案:定时校对
即使有了 MQ,极端情况下(网络抖动、消费者重启)仍可能出现 Redis 与 DB 不一致。我们加一个凌晨执行的定时校准任务:
java
@Component
@Slf4j
public class LikeConsistencyTask {
@Autowired
private LikeRecordMapper recordMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Scheduled(cron = "0 0 3 * * ?")
public void reconcile() {
log.info("开始点赞数据校准...");
// 从DB按目标分组统计真实点赞数
List<CountStat> stats = recordMapper.selectCountGroupByTarget();
for (CountStat stat : stats) {
String key = "like_count:" + stat.getTargetType() + ":" + stat.getTargetId();
// 以DB为准覆盖Redis
redisTemplate.opsForValue().set(key, String.valueOf(stat.getCount()));
}
log.info("校准完成,共处理 {} 个目标", stats.size());
}
}
八、拓展思考
8.1 如果单日点赞量极大,MySQL 扛不住怎么办?
- 冷热分离:只保留最近 3 个月的热数据在 MySQL,历史数据归档到 OSS 或 ClickHouse
- 分库分表 :按
target_id取模分库分表,将写入压力打散 - TiDB:直接使用分布式数据库,自动水平扩展
8.2 如何应对热点内容(明星官宣)?
- 多级缓存:在应用服务器本地增加 Caffeine 缓存,命中后直接返回,不再访问 Redis
- 热点发现:通过 HeavyKeeper 算法实时识别突发热点 Key,自动推送到本地缓存
- 限流降级:对热点 Key 单独配置更严格的限流策略,优先保障系统稳定
8.3 如何保证消息不丢失?
- Producer 端使用
acks=all+ 回调重试 - Consumer 端手动提交 Offset,业务处理成功后才提交
- 开启 Kafka 的幂等性生产者 (
enable.idempotence=true)
九、总结
点赞系统虽小,但五脏俱全。通过本文的完整实现,我们可以看到:
| 设计原则 | 具体实现 |
|---|---|
| 读写分离 | 读走 Redis,写先 Redis 后异步刷 DB |
| 原子操作 | Lua 脚本保证 Redis 内多操作原子性 |
| 异步解耦 | Kafka 将写入 DB 的操作异步化 |
| 批量处理 | 批量消费 + 批量 Upsert 降低 DB 压力 |
| 最终一致 | MQ + 定时校对保证数据最终一致 |
| 防刷治理 | 滑动窗口限流 + 用户行为频率控制 |
这套方案已在多个中等规模项目中稳定运行,支撑了千万级日活。你可以根据自身业务场景灵活裁剪------如果并发量不大,甚至可以去掉 MQ 层,直接用 Spring 的 @Async 异步写入 DB;如果量极大,则需要引入更精细的热点治理策略。