从零设计一个高并发点赞系统:缓存+MQ异步持久化实战

从零设计一个高并发点赞系统:缓存+MQ异步持久化实战

点赞功能看似简单,但日活千万下,如何保证数据一致性、防止刷赞、应对热点流量?本文将带你从零实现一套可支撑高并发的点赞系统。

一、业务场景与挑战

点赞是几乎所有社交/内容平台的标配功能。当你的产品日活达到千万级,一个简单的点赞按钮背后,其实隐藏着不少技术挑战:

高并发写入:热门内容瞬间可能涌入数十万点赞请求

数据一致性:点赞状态要准,计数不能错

防刷治理:总有"热心用户"想刷爆你的接口

热点穿透:明星官宣时,单条内容的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 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 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> records) {

if (records.isEmpty()) return;

List 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 INTO like_record (user_id, target_id, target_type, status, update_time)

VALUES

(#{item.userId}, #{item.targetId}, #{item.targetType}, #{item.status}, #{item.updateTime})

ON DUPLICATE KEY UPDATE

status = VALUES(status),

update_time = VALUES(update_time)

六、防刷治理:滑动窗口限流

点赞接口是最容易被刷的接口之一。我们基于 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 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;如果量极大,则需要引入更精细的热点治理策略。

相关推荐

手机课 | 淘宝如何查询商家信息
365beat

手机课 | 淘宝如何查询商家信息

📅 10-13 👁️ 1483
车载手机支架装哪里好?尽量别装在这3个地方,不但不实用还危险
苹果手机最好用的文件管理软件,最佳苹果手机文件管理软件推荐
beat365正版网站唯一官网app

苹果手机最好用的文件管理软件,最佳苹果手机文件管理软件推荐

📅 08-07 👁️ 8348