从零设计一个高并发点赞系统:缓存+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<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;如果量极大,则需要引入更精细的热点治理策略。

相关推荐
胡萝卜术13 小时前
深度重构:Agent Skills——从 Prompt 工程到能力工程
javascript·架构·github
踏月的造梦星球14 小时前
DMDPC 学习:架构、部署、运维与调优
运维·数据库·学习·架构
韩楚风14 小时前
【参天引擎】事务生命周期 / MVCC / Undo / ACID / 分布式事务 功能域整体解析
数据库·分布式·mysql·架构·cantian
通问AI14 小时前
Apple Intelligence 国行备案深度技术解析:阿里千问如何被集成到苹果端侧AI架构
人工智能·架构
明航咨询—张老师14 小时前
软考软件评测师技术深度剖析:2026年考试改革后的知识体系与备考架构
大数据·架构
阿维的博客日记15 小时前
MultipartFile 是不是表示仅仅是一个分片?
java·后端·spring·multipartfile
程序员无隅15 小时前
Coding Agent 为什么压缩上下文后还能继续工作?上下文模块设计拆解
java·开发语言·数据库
Python+9915 小时前
Java 枚举类(Enum)详解:从基础到高级应用
java·开发语言·python
二炮手亮子15 小时前
浅记java线程池
java·开发语言
一路向北North15 小时前
Spring Security OAuth2.0(23):分布式系统授权-转发明文给微服务
java·spring·微服务