如何设计点赞系统
一、问题描述
1.1 业务背景
点赞是互联网产品最基础、最常见的互动功能,广泛应用于:
- 社交平台:微信朋友圈、微博、抖音、小红书
- 内容平台:知乎、B站、掘金、CSDN
- 电商平台:淘宝评论点赞、京东问答点赞
- 视频平台:YouTube、TikTok、爱奇艺
1.2 核心功能
基础功能:
- 点赞/取消点赞:用户可以点赞或取消点赞
- 点赞数展示:显示内容的总点赞数
- 点赞状态:显示当前用户是否已点赞
- 点赞列表:查看谁点赞了这条内容
进阶功能:
- 点赞通知:通知内容创作者
- 点赞排行:热门内容排序
- 防刷:防止恶意刷赞
- 点赞动画:前端交互效果
1.3 技术挑战
高并发读写:
- 热门内容可能被百万人点赞
- 读QPS远大于写QPS(读:写 = 100:1)
- 需要快速响应(<50ms)
数据一致性:
- 点赞数统计必须准确
- 防止重复点赞
- 并发点赞时的竞态条件
性能优化:
- 点赞数频繁变化,如何避免频繁写库
- 如何快速判断用户是否已点赞
- 如何高效获取点赞列表
存储压力:
- 每天可能产生亿级点赞数据
- 点赞记录需要持久化
- 历史数据如何归档
1.4 面试考察点
- 缓存设计:如何用Redis加速查询
- 数据一致性:缓存和数据库如何保持一致
- 并发控制:如何防止重复点赞
- 性能优化:如何优化高并发场景
- 扩展性:如何支持更多点赞类型
二、需求分析
2.1 功能性需求
| 需求 | 描述 | 优先级 |
|---|---|---|
| FR1 | 用户可以点赞内容(文章、视频、评论等) | P0 |
| FR2 | 用户可以取消点赞 | P0 |
| FR3 | 显示内容的总点赞数 | P0 |
| FR4 | 显示当前用户是否已点赞 | P0 |
| FR5 | 查看点赞列表(谁点赞了) | P1 |
| FR6 | 点赞通知创作者 | P1 |
| FR7 | 按点赞数排序(热门内容) | P1 |
| FR8 | 点赞数统计和分析 | P2 |
2.2 非功能性需求
性能需求:
- 点赞/取消响应时间:<50ms(P95)
- 点赞数查询:<20ms(P95)
- 支持QPS:读10万+,写1万+
- 批量查询点赞状态:<100ms(100个内容)
一致性需求:
- 点赞数最终一致(允许短暂延迟)
- 点赞/取消操作强一致(不能重复点赞)
- 点赞记录可追溯(审计、风控)
可用性需求:
- 系统可用性:99.9%
- 点赞失败有友好提示
- 缓存故障可降级到数据库
扩展性需求:
- 支持多种点赞类型(文章、视频、评论)
- 支持点赞数阈值提醒(突破1000赞)
- 支持取消点赞后的"反悔"功能
2.3 数据规模
假设:
- 日活用户:1000万
- 每用户每天点赞:10次
- 日点赞量:1亿次
- 存储周期:永久(支持取消)
计算:
写QPS = 1亿 / 86400 ≈ 1157 QPS(峰值3000+)
读QPS = 写QPS × 100 ≈ 115,700 QPS
存储 = 1亿条/天 × 365天 × 20字节 ≈ 730GB/年2.4 业务约束
- 用户约束:每个用户对同一内容只能点赞一次
- 时效约束:点赞数延迟<1秒可接受
- 安全约束:防止刷赞、批量点赞
- 合规约束:用户可以删除自己的点赞记录
三、技术选型
3.1 存储方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| MySQL | 事务支持、可靠 | 性能一般、并发低 | 持久化存储 |
| Redis | 极高性能 | 易丢失、成本高 | 缓存热数据 |
| MongoDB | 高性能、灵活 | 无事务 | 点赞记录 |
| Cassandra | 高写入性能 | 查询受限 | 海量数据 |
推荐方案:
- MySQL:存储点赞记录、点赞数(持久化)
- Redis:缓存点赞数、点赞状态(加速查询)
- 消息队列:异步写库、削峰填谷
3.2 缓存策略对比
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Cache Aside | 简单、可控 | 一致性差 | 通用场景 |
| Read Through | 自动加载 | 复杂 | 读多写少 |
| Write Through | 强一致 | 性能差 | 强一致性 |
| Write Behind | 高性能 | 可能丢数据 | 高并发写 |
推荐:Write Behind(异步写库)
3.3 技术栈
| 组件 | 技术选型 | 作用 |
|---|---|---|
| 数据库 | MySQL | 持久化存储 |
| 缓存 | Redis | 热数据缓存 |
| 消息队列 | Kafka | 异步写库 |
| 应用框架 | Spring Boot / Go | 业务逻辑 |
| 通知 | WebSocket / 推送 | 实时通知 |
四、架构设计
4.1 系统架构图
mermaid
graph TB
subgraph 客户端
A[移动端/Web]
end
subgraph 应用层
B[点赞API]
B --> C[点赞服务]
B --> D[统计服务]
B --> E[通知服务]
end
subgraph 缓存层
F[Redis集群]
F --> F1[点赞数<br/>Key: like:count:post:id]
F --> F2[点赞状态<br/>Key: like:status:user:id:post:id]
F --> F3[点赞列表<br/>Key: like:list:post:id]
end
subgraph 消息队列
G[Kafka]
C --> G
G --> H[消费者<br/>写库]
end
subgraph 存储层
I[MySQL主库]
J[MySQL从库]
H --> I
I --> J
end
subgraph 通知层
E --> K[WebSocket]
E --> L[推送服务]
end
A --> B
C --> F
D --> F
D --> J4.2 核心流程
点赞流程
mermaid
sequenceDiagram
participant U as 用户
participant API as 点赞API
participant Redis as Redis
participant MQ as Kafka
participant DB as MySQL
participant Notify as 通知服务
U->>API: 点赞请求
API->>Redis: 检查是否已点赞
alt 已点赞
Redis-->>API: 已点赞
API-->>U: 提示已点赞
else 未点赞
Redis-->>API: 未点赞
par 并行处理
API->>Redis: 1. 点赞数+1
API->>Redis: 2. 记录点赞状态
API->>Redis: 3. 添加到点赞列表
and
API->>MQ: 发送点赞消息
end
API-->>U: 点赞成功
MQ->>DB: 异步写库
MQ->>Notify: 触发通知
Notify->>创作者: 推送点赞通知
end取消点赞流程
mermaid
sequenceDiagram
participant U as 用户
participant API as 点赞API
participant Redis as Redis
participant MQ as Kafka
participant DB as MySQL
U->>API: 取消点赞
API->>Redis: 检查是否已点赞
alt 未点赞
Redis-->>API: 未点赞
API-->>U: 提示未点赞
else 已点赞
par 并行处理
API->>Redis: 1. 点赞数-1
API->>Redis: 2. 删除点赞状态
API->>Redis: 3. 从列表移除
and
API->>MQ: 发送取消消息
end
API-->>U: 取消成功
MQ->>DB: 异步更新数据库
end4.3 数据库设计
点赞记录表(MySQL)
sql
CREATE TABLE like_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
target_type TINYINT NOT NULL COMMENT '目标类型 1文章 2视频 3评论',
target_id BIGINT NOT NULL COMMENT '目标ID',
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_type, target_id),
KEY idx_target (target_type, target_id, create_time),
KEY idx_user (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='点赞记录表';
-- 分表策略:按target_id取模分16张表
-- like_record_0, like_record_1, ..., like_record_15点赞统计表(MySQL)
sql
CREATE TABLE like_count (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
target_type TINYINT NOT NULL COMMENT '目标类型',
target_id BIGINT NOT NULL COMMENT '目标ID',
like_count INT DEFAULT 0 COMMENT '点赞数',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='点赞统计表';Redis数据结构
redis
# 1. 点赞数(String)
Key: like:count:{target_type}:{target_id}
Value: 12345
TTL: 永久(热数据)
# 2. 用户点赞状态(String)
Key: like:status:{user_id}:{target_type}:{target_id}
Value: 1(已点赞)或 0(未点赞)
TTL: 7天
# 3. 点赞用户列表(ZSet,按时间排序)
Key: like:list:{target_type}:{target_id}
Score: 时间戳
Member: user_id
TTL: 1天(只缓存最新的)
# 4. 用户点赞列表(ZSet)
Key: user:like:{user_id}:{target_type}
Score: 时间戳
Member: target_id
TTL: 7天五、核心实现
5.1 点赞服务(Go实现)
点击查看完整实现
go
package like
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
)
// LikeService 点赞服务
type LikeService struct {
db *gorm.DB
redis *redis.Client
mq MessageQueue
}
// LikeRecord 点赞记录
type LikeRecord struct {
ID int64 `gorm:"primarykey"`
UserID int64 `gorm:"index:uk_user_target,priority:1"`
TargetType int8 `gorm:"index:uk_user_target,priority:2"`
TargetID int64 `gorm:"index:uk_user_target,priority:3;index:idx_target,priority:1"`
Status int8 `gorm:"default:1"`
CreateTime time.Time `gorm:"autoCreateTime"`
UpdateTime time.Time `gorm:"autoUpdateTime"`
}
// Like 点赞
func (s *LikeService) Like(ctx context.Context, userID, targetID int64, targetType int8) error {
// 1. 检查是否已点赞(Redis)
statusKey := s.getLikeStatusKey(userID, targetID, targetType)
status, err := s.redis.Get(ctx, statusKey).Int()
if err == nil && status == 1 {
return fmt.Errorf("already liked")
}
// 2. Redis操作(原子性)
pipe := s.redis.Pipeline()
// 2.1 点赞数+1
countKey := s.getLikeCountKey(targetID, targetType)
pipe.Incr(ctx, countKey)
// 2.2 记录点赞状态
pipe.Set(ctx, statusKey, 1, 7*24*time.Hour)
// 2.3 添加到点赞列表(ZSet,score为时间戳)
listKey := s.getLikeListKey(targetID, targetType)
pipe.ZAdd(ctx, listKey, &redis.Z{
Score: float64(time.Now().Unix()),
Member: userID,
})
pipe.Expire(ctx, listKey, 24*time.Hour)
// 2.4 用户点赞列表
userLikeKey := s.getUserLikeKey(userID, targetType)
pipe.ZAdd(ctx, userLikeKey, &redis.Z{
Score: float64(time.Now().Unix()),
Member: targetID,
})
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("redis error: %w", err)
}
// 3. 发送到消息队列(异步写库)
msg := LikeMessage{
UserID: userID,
TargetID: targetID,
TargetType: targetType,
Action: "like",
Timestamp: time.Now().Unix(),
}
if err := s.mq.Send("like_topic", msg); err != nil {
// 记录日志,但不影响用户体验
// 后续可通过对账补偿
log.Errorf("send mq error: %v", err)
}
return nil
}
// Unlike 取消点赞
func (s *LikeService) Unlike(ctx context.Context, userID, targetID int64, targetType int8) error {
// 1. 检查是否已点赞
statusKey := s.getLikeStatusKey(userID, targetID, targetType)
status, err := s.redis.Get(ctx, statusKey).Int()
if err != nil || status != 1 {
return fmt.Errorf("not liked yet")
}
// 2. Redis操作
pipe := s.redis.Pipeline()
// 2.1 点赞数-1(防止负数)
countKey := s.getLikeCountKey(targetID, targetType)
pipe.Decr(ctx, countKey)
// 2.2 删除点赞状态
pipe.Del(ctx, statusKey)
// 2.3 从点赞列表移除
listKey := s.getLikeListKey(targetID, targetType)
pipe.ZRem(ctx, listKey, userID)
// 2.4 从用户点赞列表移除
userLikeKey := s.getUserLikeKey(userID, targetType)
pipe.ZRem(ctx, userLikeKey, targetID)
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("redis error: %w", err)
}
// 3. 发送到消息队列
msg := LikeMessage{
UserID: userID,
TargetID: targetID,
TargetType: targetType,
Action: "unlike",
Timestamp: time.Now().Unix(),
}
s.mq.Send("like_topic", msg)
return nil
}
// GetLikeCount 获取点赞数
func (s *LikeService) GetLikeCount(ctx context.Context, targetID int64, targetType int8) (int64, error) {
countKey := s.getLikeCountKey(targetID, targetType)
// 先从Redis获取
count, err := s.redis.Get(ctx, countKey).Int64()
if err == nil {
return count, nil
}
// Redis miss,从数据库获取
var record LikeCount
err = s.db.Where("target_type = ? AND target_id = ?", targetType, targetID).
First(&record).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, err
}
// 写回Redis
s.redis.Set(ctx, countKey, record.LikeCount, 0)
return int64(record.LikeCount), nil
}
// IsLiked 判断是否已点赞
func (s *LikeService) IsLiked(ctx context.Context, userID, targetID int64, targetType int8) (bool, error) {
statusKey := s.getLikeStatusKey(userID, targetID, targetType)
// 从Redis获取
status, err := s.redis.Get(ctx, statusKey).Int()
if err == nil {
return status == 1, nil
}
// Redis miss,从数据库获取
var record LikeRecord
err = s.db.Where("user_id = ? AND target_type = ? AND target_id = ? AND status = 1",
userID, targetType, targetID).First(&record).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// 写入Redis(未点赞)
s.redis.Set(ctx, statusKey, 0, 7*24*time.Hour)
return false, nil
}
return false, err
}
// 写入Redis(已点赞)
s.redis.Set(ctx, statusKey, 1, 7*24*time.Hour)
return true, nil
}
// GetLikeList 获取点赞列表
func (s *LikeService) GetLikeList(ctx context.Context, targetID int64, targetType int8, offset, limit int) ([]int64, error) {
listKey := s.getLikeListKey(targetID, targetType)
// 从Redis ZSet获取(按时间倒序)
members, err := s.redis.ZRevRange(ctx, listKey, int64(offset), int64(offset+limit-1)).Result()
if err != nil || len(members) == 0 {
// Redis miss,从数据库获取
var records []LikeRecord
err := s.db.Where("target_type = ? AND target_id = ? AND status = 1", targetType, targetID).
Order("create_time DESC").
Offset(offset).
Limit(limit).
Find(&records).Error
if err != nil {
return nil, err
}
userIDs := make([]int64, len(records))
for i, record := range records {
userIDs[i] = record.UserID
}
return userIDs, nil
}
// 转换为int64
userIDs := make([]int64, len(members))
for i, member := range members {
// 这里简化处理,实际需要类型转换
fmt.Sscanf(member, "%d", &userIDs[i])
}
return userIDs, nil
}
// BatchGetLikeInfo 批量获取点赞信息
func (s *LikeService) BatchGetLikeInfo(ctx context.Context, userID int64, targetIDs []int64, targetType int8) (map[int64]LikeInfo, error) {
result := make(map[int64]LikeInfo)
// 使用Pipeline批量查询Redis
pipe := s.redis.Pipeline()
countCmds := make(map[int64]*redis.StringCmd)
statusCmds := make(map[int64]*redis.StringCmd)
for _, targetID := range targetIDs {
countKey := s.getLikeCountKey(targetID, targetType)
countCmds[targetID] = pipe.Get(ctx, countKey)
statusKey := s.getLikeStatusKey(userID, targetID, targetType)
statusCmds[targetID] = pipe.Get(ctx, statusKey)
}
_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
return nil, err
}
// 解析结果
for _, targetID := range targetIDs {
info := LikeInfo{
TargetID: targetID,
}
// 点赞数
if count, err := countCmds[targetID].Int64(); err == nil {
info.LikeCount = count
}
// 是否已点赞
if status, err := statusCmds[targetID].Int(); err == nil {
info.IsLiked = (status == 1)
}
result[targetID] = info
}
return result, nil
}
// Key生成方法
func (s *LikeService) getLikeCountKey(targetID int64, targetType int8) string {
return fmt.Sprintf("like:count:%d:%d", targetType, targetID)
}
func (s *LikeService) getLikeStatusKey(userID, targetID int64, targetType int8) string {
return fmt.Sprintf("like:status:%d:%d:%d", userID, targetType, targetID)
}
func (s *LikeService) getLikeListKey(targetID int64, targetType int8) string {
return fmt.Sprintf("like:list:%d:%d", targetType, targetID)
}
func (s *LikeService) getUserLikeKey(userID int64, targetType int8) string {
return fmt.Sprintf("user:like:%d:%d", userID, targetType)
}
// LikeInfo 点赞信息
type LikeInfo struct {
TargetID int64
LikeCount int64
IsLiked bool
}
// LikeMessage 点赞消息
type LikeMessage struct {
UserID int64 `json:"user_id"`
TargetID int64 `json:"target_id"`
TargetType int8 `json:"target_type"`
Action string `json:"action"` // like/unlike
Timestamp int64 `json:"timestamp"`
}5.2 消息消费者(异步写库)
点击查看完整实现
go
package consumer
import (
"context"
"encoding/json"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// LikeConsumer 点赞消息消费者
type LikeConsumer struct {
db *gorm.DB
}
// ConsumeLikeMessage 消费点赞消息
func (c *LikeConsumer) ConsumeLikeMessage(msg []byte) error {
var likeMsg LikeMessage
if err := json.Unmarshal(msg, &likeMsg); err != nil {
return err
}
if likeMsg.Action == "like" {
return c.handleLike(likeMsg)
} else if likeMsg.Action == "unlike" {
return c.handleUnlike(likeMsg)
}
return nil
}
// handleLike 处理点赞
func (c *LikeConsumer) handleLike(msg LikeMessage) error {
return c.db.Transaction(func(tx *gorm.DB) error {
// 1. 插入点赞记录(如果存在则更新status)
record := LikeRecord{
UserID: msg.UserID,
TargetType: msg.TargetType,
TargetID: msg.TargetID,
Status: 1,
CreateTime: time.Unix(msg.Timestamp, 0),
UpdateTime: time.Unix(msg.Timestamp, 0),
}
// UPSERT: ON DUPLICATE KEY UPDATE
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "target_type"}, {Name: "target_id"}},
DoUpdates: clause.AssignmentColumns([]string{"status", "update_time"}),
}).Create(&record).Error
if err != nil {
return err
}
// 2. 更新点赞统计
err = tx.Exec(`
INSERT INTO like_count (target_type, target_id, like_count)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE like_count = like_count + 1
`, msg.TargetType, msg.TargetID).Error
return err
})
}
// handleUnlike 处理取消点赞
func (c *LikeConsumer) handleUnlike(msg LikeMessage) error {
return c.db.Transaction(func(tx *gorm.DB) error {
// 1. 更新点赞记录状态
err := tx.Model(&LikeRecord{}).
Where("user_id = ? AND target_type = ? AND target_id = ?",
msg.UserID, msg.TargetType, msg.TargetID).
Update("status", 0).
Update("update_time", time.Unix(msg.Timestamp, 0)).
Error
if err != nil {
return err
}
// 2. 更新点赞统计(防止负数)
err = tx.Exec(`
UPDATE like_count
SET like_count = GREATEST(like_count - 1, 0)
WHERE target_type = ? AND target_id = ?
`, msg.TargetType, msg.TargetID).Error
return err
})
}5.3 Java实现(Spring Boot)
java
@Service
public class LikeService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private LikeRecordMapper likeRecordMapper;
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
/**
* 点赞
*/
@Transactional(rollbackFor = Exception.class)
public void like(Long userId, Long targetId, Integer targetType) {
// 1. 检查是否已点赞
String statusKey = getLikeStatusKey(userId, targetId, targetType);
Integer status = (Integer) redisTemplate.opsForValue().get(statusKey);
if (status != null && status == 1) {
throw new BusinessException("已经点赞过了");
}
// 2. Redis操作(Pipeline)
redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
// 点赞数+1
String countKey = getLikeCountKey(targetId, targetType);
operations.opsForValue().increment(countKey, 1);
// 记录点赞状态
operations.opsForValue().set(statusKey, 1, 7, TimeUnit.DAYS);
// 添加到点赞列表
String listKey = getLikeListKey(targetId, targetType);
operations.opsForZSet().add(listKey, userId, System.currentTimeMillis());
operations.expire(listKey, 1, TimeUnit.DAYS);
// 用户点赞列表
String userLikeKey = getUserLikeKey(userId, targetType);
operations.opsForZSet().add(userLikeKey, targetId, System.currentTimeMillis());
return null;
}
});
// 3. 发送到Kafka
LikeMessage msg = new LikeMessage();
msg.setUserId(userId);
msg.setTargetId(targetId);
msg.setTargetType(targetType);
msg.setAction("like");
msg.setTimestamp(System.currentTimeMillis());
kafkaTemplate.send("like_topic", JSON.toJSONString(msg));
}
/**
* 取消点赞
*/
public void unlike(Long userId, Long targetId, Integer targetType) {
// 1. 检查是否已点赞
String statusKey = getLikeStatusKey(userId, targetId, targetType);
Integer status = (Integer) redisTemplate.opsForValue().get(statusKey);
if (status == null || status != 1) {
throw new BusinessException("还未点赞");
}
// 2. Redis操作
redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
// 点赞数-1
String countKey = getLikeCountKey(targetId, targetType);
operations.opsForValue().decrement(countKey, 1);
// 删除点赞状态
operations.delete(statusKey);
// 从点赞列表移除
String listKey = getLikeListKey(targetId, targetType);
operations.opsForZSet().remove(listKey, userId);
// 从用户点赞列表移除
String userLikeKey = getUserLikeKey(userId, targetType);
operations.opsForZSet().remove(userLikeKey, targetId);
return null;
}
});
// 3. 发送到Kafka
LikeMessage msg = new LikeMessage();
msg.setUserId(userId);
msg.setTargetId(targetId);
msg.setTargetType(targetType);
msg.setAction("unlike");
msg.setTimestamp(System.currentTimeMillis());
kafkaTemplate.send("like_topic", JSON.toJSONString(msg));
}
/**
* 获取点赞数
*/
public Long getLikeCount(Long targetId, Integer targetType) {
String countKey = getLikeCountKey(targetId, targetType);
// 从Redis获取
Integer count = (Integer) redisTemplate.opsForValue().get(countKey);
if (count != null) {
return count.longValue();
}
// Redis miss,从数据库获取
LikeCount likeCount = likeCountMapper.selectByTarget(targetType, targetId);
if (likeCount == null) {
return 0L;
}
// 写回Redis
redisTemplate.opsForValue().set(countKey, likeCount.getLikeCount());
return likeCount.getLikeCount().longValue();
}
/**
* 批量获取点赞信息
*/
public Map<Long, LikeInfo> batchGetLikeInfo(Long userId, List<Long> targetIds, Integer targetType) {
Map<Long, LikeInfo> result = new HashMap<>();
// Pipeline批量查询
List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
for (Long targetId : targetIds) {
// 查询点赞数
String countKey = getLikeCountKey(targetId, targetType);
operations.opsForValue().get(countKey);
// 查询是否已点赞
String statusKey = getLikeStatusKey(userId, targetId, targetType);
operations.opsForValue().get(statusKey);
}
return null;
}
});
// 解析结果(results是扁平列表,每个targetId对应2个结果)
for (int i = 0; i < targetIds.size(); i++) {
Long targetId = targetIds.get(i);
Integer count = (Integer) results.get(i * 2);
Integer status = (Integer) results.get(i * 2 + 1);
LikeInfo info = new LikeInfo();
info.setTargetId(targetId);
info.setLikeCount(count != null ? count.longValue() : 0L);
info.setLiked(status != null && status == 1);
result.put(targetId, info);
}
return result;
}
// Key生成方法
private String getLikeCountKey(Long targetId, Integer targetType) {
return String.format("like:count:%d:%d", targetType, targetId);
}
private String getLikeStatusKey(Long userId, Long targetId, Integer targetType) {
return String.format("like:status:%d:%d:%d", userId, targetType, targetId);
}
private String getLikeListKey(Long targetId, Integer targetType) {
return String.format("like:list:%d:%d", targetType, targetId);
}
private String getUserLikeKey(Long userId, Integer targetType) {
return String.format("user:like:%d:%d", userId, targetType);
}
}java
@RestController
@RequestMapping("/api/like")
public class LikeController {
@Autowired
private LikeService likeService;
/**
* 点赞
*/
@PostMapping("/like")
public Response<Void> like(@RequestBody LikeRequest request) {
Long userId = UserContext.getCurrentUserId();
likeService.like(userId, request.getTargetId(), request.getTargetType());
return Response.success();
}
/**
* 取消点赞
*/
@PostMapping("/unlike")
public Response<Void> unlike(@RequestBody LikeRequest request) {
Long userId = UserContext.getCurrentUserId();
likeService.unlike(userId, request.getTargetId(), request.getTargetType());
return Response.success();
}
/**
* 获取点赞数
*/
@GetMapping("/count")
public Response<Long> getLikeCount(@RequestParam Long targetId,
@RequestParam Integer targetType) {
Long count = likeService.getLikeCount(targetId, targetType);
return Response.success(count);
}
/**
* 批量获取点赞信息
*/
@PostMapping("/batch")
public Response<Map<Long, LikeInfo>> batchGetLikeInfo(@RequestBody BatchLikeRequest request) {
Long userId = UserContext.getCurrentUserId();
Map<Long, LikeInfo> result = likeService.batchGetLikeInfo(
userId, request.getTargetIds(), request.getTargetType()
);
return Response.success(result);
}
}六、性能优化
6.1 缓存优化
1. 缓存预热
go
// 热门内容预加载到Redis
func (s *LikeService) PreloadHotContent(ctx context.Context) error {
// 从数据库查询热门内容
var hotContents []LikeCount
err := s.db.Where("like_count > ?", 1000).
Order("like_count DESC").
Limit(1000).
Find(&hotContents).Error
if err != nil {
return err
}
// 批量写入Redis
pipe := s.redis.Pipeline()
for _, content := range hotContents {
countKey := s.getLikeCountKey(content.TargetID, content.TargetType)
pipe.Set(ctx, countKey, content.LikeCount, 0)
}
_, err = pipe.Exec(ctx)
return err
}2. 缓存穿透防护
go
// 使用布隆过滤器
func (s *LikeService) IsContentExist(targetID int64) bool {
return s.bloomFilter.Test([]byte(fmt.Sprintf("%d", targetID)))
}6.2 数据库优化
1. 读写分离
go
// 写主库
func (s *LikeService) writeLike(record *LikeRecord) error {
return s.masterDB.Create(record).Error
}
// 读从库
func (s *LikeService) readLikeCount(targetID int64, targetType int8) (*LikeCount, error) {
var count LikeCount
err := s.slaveDB.Where("target_type = ? AND target_id = ?", targetType, targetID).
First(&count).Error
return &count, err
}2. 分表策略
go
// 按target_id分表
func getTableName(targetID int64) string {
tableIndex := targetID % 16
return fmt.Sprintf("like_record_%d", tableIndex)
}6.3 性能数据
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 点赞响应时间 | 200ms | 15ms | 13.3x |
| 查询响应时间 | 100ms | 5ms | 20x |
| 写QPS | 500 | 10,000 | 20x |
| 读QPS | 5,000 | 100,000 | 20x |
| Redis命中率 | 80% | 99% | - |
七、数据一致性
7.1 缓存和数据库一致性
策略:延迟双删 + 定时对账
go
// 延迟双删
func (s *LikeService) UpdateLikeWithDoubleDelete(ctx context.Context, record *LikeRecord) error {
countKey := s.getLikeCountKey(record.TargetID, record.TargetType)
// 1. 删除缓存
s.redis.Del(ctx, countKey)
// 2. 更新数据库
err := s.db.Save(record).Error
if err != nil {
return err
}
// 3. 延迟500ms再删除缓存
time.AfterFunc(500*time.Millisecond, func() {
s.redis.Del(context.Background(), countKey)
})
return nil
}7.2 定时对账
go
// 定时任务:Redis和MySQL对账
func (s *LikeService) ReconcileData(ctx context.Context) error {
// 1. 从MySQL查询点赞统计
var counts []LikeCount
err := s.db.Find(&counts).Error
if err != nil {
return err
}
// 2. 对比Redis
for _, count := range counts {
countKey := s.getLikeCountKey(count.TargetID, count.TargetType)
redisCount, _ := s.redis.Get(ctx, countKey).Int64()
// 差异超过阈值,修正
if abs(redisCount-int64(count.LikeCount)) > 10 {
log.Warnf("data inconsistency: target=%d, redis=%d, mysql=%d",
count.TargetID, redisCount, count.LikeCount)
// 以MySQL为准,更新Redis
s.redis.Set(ctx, countKey, count.LikeCount, 0)
}
}
return nil
}八、防刷机制
8.1 限流
go
// 用户维度限流:每秒最多点赞5次
func (s *LikeService) CheckRateLimit(ctx context.Context, userID int64) error {
key := fmt.Sprintf("like:rate_limit:%d", userID)
// 滑动窗口限流
now := time.Now().Unix()
pipe := s.redis.Pipeline()
// 移除1秒前的记录
pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", now-1))
// 添加当前时间戳
pipe.ZAdd(ctx, key, &redis.Z{Score: float64(now), Member: now})
// 统计1秒内的数量
pipe.ZCard(ctx, key)
// 设置过期时间
pipe.Expire(ctx, key, 2*time.Second)
cmds, err := pipe.Exec(ctx)
if err != nil {
return err
}
// 获取计数
count := cmds[2].(*redis.IntCmd).Val()
if count > 5 {
return fmt.Errorf("rate limit exceeded")
}
return nil
}8.2 风控检测
go
// 异常行为检测
func (s *LikeService) DetectAbnormal(userID int64) bool {
// 1. 检查短时间内点赞数量
count := s.getUserLikeCountInMinute(userID)
if count > 100 {
return true // 异常:1分钟点赞超过100次
}
// 2. 检查点赞后立即取消的频率
cancelRate := s.getCancelRate(userID)
if cancelRate > 0.8 {
return true // 异常:取消率超过80%
}
// 3. 检查是否批量点赞同一作者
sameAuthorRate := s.getSameAuthorRate(userID)
if sameAuthorRate > 0.9 {
return true // 异常:90%点赞同一作者
}
return false
}九、面试要点
9.1 常见追问
Q1: 如何保证点赞数的准确性?
A:
- Redis原子操作:使用INCR/DECR保证单次操作原子性
- 消息队列:异步写库,防止丢失
- 定时对账:Redis和MySQL定期对账,发现差异修正
- 幂等设计:消费者处理消息幂等,防止重复扣减
Q2: 如何处理缓存和数据库的一致性?
A:
- 写操作:先更新MySQL,再删除Redis(延迟双删)
- 读操作:先读Redis,miss再读MySQL并回写
- 异步补偿:消息队列保证最终一致性
- 定时对账:发现不一致主动修正
Q3: 如何优化热门内容的点赞性能?
A:
- 缓存预热:热门内容提前加载到Redis
- 本地缓存:应用层增加Guava Cache
- 批量写入:攒批后批量写MySQL(牺牲一致性)
- 读写分离:读从库,降低主库压力
Q4: 如何防止恶意刷赞?
A:
- 限流:用户维度、IP维度限流
- 验证码:高频操作要求验证码
- 风控规则:异常行为检测(短时间大量点赞)
- 黑名单:封禁刷赞账号
9.2 扩展知识点
十、总结
点赞系统看似简单,但要做好需要考虑:
- 性能优化:Redis缓存 + 异步写库,支持10万+QPS
- 数据一致性:延迟双删 + 定时对账,保证最终一致
- 并发控制:唯一索引 + 原子操作,防止重复点赞
- 防刷机制:限流 + 风控,保护系统稳定性
面试中要能说清楚架构设计、缓存策略、一致性保证、性能优化等关键点。
相关场景题:
