每小时千万级的点赞场景如何设计?
业务场景
在线教育平台,学生可以为优秀作品点赞。
流量特征:
- 高峰期每小时千万级点赞
- 读多写多(查点赞数 + 点赞操作)
- 数据最终一致即可
设计挑战
| 挑战 | 说明 |
|---|---|
| 高并发写 | 每秒数千次点赞 |
| 高并发读 | 查询点赞数更频繁 |
| 数据持久化 | 需要落库 |
| 防重复点赞 | 同一用户只能点赞一次 |
| 热点问题 | 热门作品点赞集中 |
架构设计
┌─────────────────────────────────────────────────────────┐
│ 点赞服务 │
├─────────────────────────────────────────────────────────┤
│ │
│ 点赞请求 → Redis(实时计数) → 异步队列 → 数据库 │
│ │
│ 查询请求 → Redis(直接返回) │
│ │
└─────────────────────────────────────────────────────────┘
核心策略
- Redis前置:所有读写都走Redis
- 异步落库:定时批量同步到MySQL
- Set去重:防止重复点赞
数据结构设计
Redis数据结构
# 点赞计数(String)
work:like:count:{workId} = 1234
# 点赞用户集合(Set,用于去重和取消点赞)
work:like:users:{workId} = {user1, user2, user3...}
# 用户已赞作品(Set,用于"我的点赞"列表)
user:likes:{userId} = {work1, work2, work3...}
MySQL表结构
CREATE TABLE work_likes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
work_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status TINYINT DEFAULT 1 COMMENT '1点赞 0取消',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_work_user (work_id, user_id)
);
CREATE TABLE work_like_counts (
work_id BIGINT PRIMARY KEY,
like_count BIGINT DEFAULT 0,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
核心代码实现
点赞接口
@Service
public class LikeService {
@Autowired
private StringRedisTemplate redis;
public boolean like(Long userId, Long workId) {
String countKey = "work:like:count:" + workId;
String usersKey = "work:like:users:" + workId;
String userLikesKey = "user:likes:" + userId;
// 1. 检查是否已点赞(SISMEMBER O(1))
Boolean exists = redis.opsForSet().isMember(usersKey, userId.toString());
if (Boolean.TRUE.equals(exists)) {
return false; // 已点赞
}
// 2. Lua脚本保证原子性
String script = """
redis.call('SADD', KEYS[1], ARGV[1])
redis.call('SADD', KEYS[2], ARGV[2])
redis.call('INCR', KEYS[3])
return 1
""";
redis.execute(new DefaultRedisScript<>(script, Long.class),
List.of(usersKey, userLikesKey, countKey),
userId.toString(), workId.toString());
// 3. 发送异步消息落库
kafkaTemplate.send("like-topic", JSON.toJSONString(
new LikeEvent(userId, workId, LikeAction.LIKE)));
return true;
}
public boolean unlike(Long userId, Long workId) {
String countKey = "work:like:count:" + workId;
String usersKey = "work:like:users:" + workId;
String userLikesKey = "user:likes:" + userId;
// 检查是否已点赞
Boolean exists = redis.opsForSet().isMember(usersKey, userId.toString());
if (!Boolean.TRUE.equals(exists)) {
return false; // 未点赞
}
// Lua脚本原子取消
String script = """
redis.call('SREM', KEYS[1], ARGV[1])
redis.call('SREM', KEYS[2], ARGV[2])
redis.call('DECR', KEYS[3])
return 1
""";
redis.execute(new DefaultRedisScript<>(script, Long.class),
List.of(usersKey, userLikesKey, countKey),
userId.toString(), workId.toString());
kafkaTemplate.send("like-topic", JSON.toJSONString(
new LikeEvent(userId, workId, LikeAction.UNLIKE)));
return true;
}
}
查询点赞数
public Long getLikeCount(Long workId) {
String countKey = "work:like:count:" + workId;
String count = redis.opsForValue().get(countKey);
return count != null ? Long.parseLong(count) : 0L;
}
public boolean isLiked(Long userId, Long workId) {
String usersKey = "work:like:users:" + workId;
return Boolean.TRUE.equals(
redis.opsForSet().isMember(usersKey, userId.toString()));
}
异步落库
@KafkaListener(topics = "like-topic")
public void syncToDb(List<ConsumerRecord<String, String>> records) {
Map<String, LikeEvent> latestEvents = new HashMap<>();
// 合并同一用户对同一作品的操作(只保留最新状态)
for (ConsumerRecord<String, String> record : records) {
LikeEvent event = JSON.parseObject(record.value(), LikeEvent.class);
String key = event.getUserId() + ":" + event.getWorkId();
latestEvents.put(key, event);
}
// 批量更新
for (LikeEvent event : latestEvents.values()) {
likeMapper.upsert(event);
}
// 更新计数表
updateLikeCounts(latestEvents.values());
}
热点问题处理
问题
热门作品点赞集中,Redis单Key压力大。
解决方案:分片计数
// 将计数分散到多个key
public void likeWithSharding(Long workId) {
int shard = ThreadLocalRandom.current().nextInt(10);
String key = "work:like:count:" + workId + ":" + shard;
redis.opsForValue().increment(key);
}
// 查询时汇总
public Long getLikeCountWithSharding(Long workId) {
long total = 0;
for (int i = 0; i < 10; i++) {
String key = "work:like:count:" + workId + ":" + i;
String count = redis.opsForValue().get(key);
total += count != null ? Long.parseLong(count) : 0;
}
return total;
}
面试答题框架
业务特征:高并发读写、最终一致
核心设计:
1. Redis前置所有读写
2. Set结构去重(SISMEMBER O(1))
3. Lua脚本保证原子性
4. 异步消息落库
数据结构:
- 计数: String
- 去重: Set
- 用户已赞: Set
热点处理:
- 分片计数(10个子key)
- 本地缓存热点作品
效果:
- 点赞响应<5ms
- 支持千万级/小时
总结
| 设计点 | 方案 |
|---|---|
| 实时读写 | Redis |
| 去重 | Set SISMEMBER |
| 原子性 | Lua脚本 |
| 持久化 | Kafka异步落库 |
| 热点 | 分片计数 |