Skip to content

重复点赞问题

  点赞是社交/内容类产品的核心功能,重复点赞问题本质上是分布式环境下的幂等性问题——同一个用户对同一个目标只能点赞一次,第二次操作要么无效,要么取消点赞。

一、问题场景

用户 A 快速双击点赞按钮:

  请求 1:POST /api/post/100/like  user_id=1  → 到达服务器
  请求 2:POST /api/post/100/like  user_id=1  → 几乎同时到达服务器

  如果没有幂等控制:
  请求 1:查询点赞记录 → 不存在 → 插入点赞 → 点赞数 +1
  请求 2:查询点赞记录 → 不存在(事务还没提交)→ 插入点赞 → 点赞数 +1
  结果:用户 A 点了两次赞,点赞数加了 2

二、方案对比

方案原理优点缺点适用场景
数据库唯一索引UNIQUE(user_id, target_id)简单可靠,数据强一致高并发下有锁竞争中小规模,强一致性要求
分布式锁Redis SETNX 或 Lua 实现互斥极快,原子操作缓存与 DB 可能不一致高并发,允许最终一致
前端防抖按钮置灰 + 防抖用户体验好,减少服务端压力不可信,可绕过所有场景的前置防护

三、方案详解

3.1 数据库唯一索引(最可靠)

sql
-- 点赞表
CREATE TABLE user_like (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    target_id BIGINT NOT NULL,
    target_type VARCHAR(20) NOT NULL COMMENT 'POST/COMMENT',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '1=点赞 0=取消',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_user_target (user_id, target_id, target_type)
);
java
@Service
public class LikeService {

    @Autowired
    private UserLikeMapper userLikeMapper;

    /**
     * 点赞 / 取消点赞(toggle)
     * 利用 status 字段实现:1=点赞 0=取消
     * 唯一索引(user_id, target_id, target_type)保证同一用户同一目标只有一条记录
     */
    @Transactional
    public LikeResult toggleLike(Long userId, Long targetId, String targetType) {
        // 查当前记录
        UserLike existing = userLikeMapper.selectByUserAndTarget(
            userId, targetId, targetType);

        if (existing == null) {
            // 第一次操作:插入点赞记录
            UserLike like = new UserLike();
            like.setUserId(userId);
            like.setTargetId(targetId);
            like.setTargetType(targetType);
            like.setStatus(1);  // 点赞
            userLikeMapper.insert(like);

            updateLikeCount(targetId, targetType, 1);
            return LikeResult.success("点赞成功");
        }

        if (existing.getStatus() == 1) {
            // 已点赞 → 取消点赞
            userLikeMapper.updateStatus(existing.getId(), 0);
            updateLikeCount(targetId, targetType, -1);
            return LikeResult.success("取消点赞");
        } else {
            // 已取消 → 重新点赞
            userLikeMapper.updateStatus(existing.getId(), 1);
            updateLikeCount(targetId, targetType, 1);
            return LikeResult.success("点赞成功");
        }
    }
}
唯一索引 + status 字段防重原理:

  第一次点赞:INSERT → (user_id=1, target_id=100, status=1)  ✅ 插入成功
  取消点赞:  UPDATE status=0 → 记录还在,但 status=0
  重新点赞:  UPDATE status=1 → 同一条记录,不插入新行
  
  并发场景:
  请求 1:SELECT → 不存在 → INSERT → 成功
  请求 2:SELECT → 不存在 → INSERT → DuplicateKeyException(唯一索引冲突)
  
  关键:唯一索引只保证同一 user+target 只有一条记录
        status 字段区分"点赞"和"取消"两种状态,不删记录,反复 toggle

3.2 分布式锁(性能最优)

  分布式锁的本质是互斥——同一时刻只有一个请求能执行点赞操作。Redis 实现分布式锁有两种方式,选哪种取决于业务复杂度。

3.2.1 实现方式一:Redis SETNX(简单场景首选)

  直接用 SETNX 标记"已点赞",key 存在 = 已点赞,一步到位。

java
@Service
public class LikeService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    public LikeResult like(Long userId, Long targetId) {
        String key = "like:" + userId + ":" + targetId;

        // SETNX:key 不存在才设置成功,原子操作
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(key, "1", Duration.ofDays(30));

        if (Boolean.TRUE.equals(success)) {
            // 异步更新数据库和点赞数
            asyncUpdateDB(userId, targetId);
            return LikeResult.success("点赞成功");
        } else {
            return LikeResult.success("已点赞");
        }
    }
}
SETNX 防重原理:

  请求 1:SETNX like:1:100 1 → 成功(key 不存在)
  请求 2:SETNX like:1:100 1 → 失败(key 已存在)
  
  本质:把"点赞记录 key"本身当作锁,免去了"加锁→操作→释放"的流程

3.2.2 实现方式二:Redis Lua(复杂场景)

  当点赞逻辑不止是"标记已点赞",还需要同时更新点赞数、记录操作日志等多个原子操作时,用 Lua 脚本。

lua
-- like.lua
-- KEYS[1] = 点赞记录 key, like:user:1:target:100
-- KEYS[2] = 点赞数 key,    like_count:100
-- ARGV[1] = 过期时间

-- 判断是否已点赞
local exists = redis.call('EXISTS', KEYS[1])
if exists == 1 then
    return 0  -- 已点赞
end

-- 记录点赞
redis.call('SETEX', KEYS[1], ARGV[1], 1)
-- 点赞数 +1
redis.call('INCR', KEYS[2])
return 1  -- 点赞成功
java
public LikeResult like(Long userId, Long targetId) {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setScriptSource(new ResourceScriptSource(
        new ClassPathResource("lua/like.lua")));
    script.setResultType(Long.class);

    Long result = redisTemplate.execute(script,
        Arrays.asList(
            "like:user:" + userId + ":target:" + targetId,
            "like_count:" + targetId
        ),
        String.valueOf(Duration.ofDays(30).getSeconds())
    );

    return result == 1 ? LikeResult.success("点赞成功")
                       : LikeResult.success("已点赞");
}

3.2.3 SETNX vs Lua 在分布式锁中的选择

SETNX(单步操作):
  适合:只需要标记"已点赞",不需要同时做其他操作
  优势:代码最简单,一条命令,性能最高
  局限:做不了条件判断和多步操作

Lua(多步操作):
  适合:点赞 + 计数 + 日志 等多个操作需要原子完成
  优势:原子性更强,支持任何复杂逻辑
  局限:脚本复杂时调试困难,所有 key 必须在同一 Redis 节点

选择原则:能用 SETNX 解决的,不要上 Lua

3.3 前端防抖(第一道防线)

  前端防抖不是服务端方案,但能拦截 90% 的重复请求,是性价比最高的防护手段。

3.3.1 防抖是什么

  防抖(Debounce)= 在 N 秒内只执行最后一次。 每次触发都会重置计时器,直到停止触发 N 秒后才真正执行。

用户连续点击 5 次,防抖 300ms:

  点击 1 ──→ 启动 300ms 计时器
  点击 2 ──→ 取消上一个计时器,启动新的 300ms 计时器
  点击 3 ──→ 取消上一个计时器,启动新的 300ms 计时器
  点击 4 ──→ 取消上一个计时器,启动新的 300ms 计时器
  点击 5 ──→ 取消上一个计时器,启动新的 300ms 计时器
                ... 300ms 内没有新点击 ...
                → 执行!只发了一次请求

  结果:5 次点击,只发了 1 次请求

防抖 vs 节流: 节流(Throttle)是"每 N 秒最多执行一次",适合 scroll、resize;防抖是"N 秒内只执行最后一次",适合搜索输入、点赞按钮。

3.3.2 防抖原理

  防抖的核心是 setTimeout + clearTimeout 组合:

1. 每次触发事件 → clearTimeout 取消上一次的定时器
2. 重新 setTimeout 设置新的定时器
3. 定时器到期 → 执行回调函数

这个机制确保:只要事件还在持续触发,回调就永远不会执行
时间线视角:

  0ms:   点击 ─→ setTimeout(fn, 300)  ─→ 计时器 1 开始
  100ms: 点击 ─→ clearTimeout(计时器1) ─→ setTimeout(fn, 300) ─→ 计时器 2 开始
  200ms: 点击 ─→ clearTimeout(计时器2) ─→ setTimeout(fn, 300) ─→ 计时器 3 开始
  ... 300ms 内没有新点击 ...
  500ms: 计时器 3 到期 ─→ 执行 fn()

3.3.3 基础实现

javascript
/**
 * 防抖函数——基础版
 * @param {Function} fn    - 要执行的函数
 * @param {number}   delay - 延迟时间(毫秒)
 */
function debounce(fn, delay) {
    let timer = null;  // 闭包保存定时器 ID

    return function(...args) {
        clearTimeout(timer);               // 取消上一次的定时器
        timer = setTimeout(() => {          // 设置新的定时器
            fn.apply(this, args);           // 保持 this 绑定 + 传递参数
        }, delay);
    };
}

// 使用
const likeBtn = document.getElementById('like-btn');
likeBtn.addEventListener('click', debounce(function() {
    console.log('发送点赞请求');
    // fetch('/api/post/100/like', { method: 'POST' });
}, 300));

3.3.4 进阶:支持立即执行(leading edge)

  基础版防抖的问题是:用户点击后要等 300ms 才有反应,体验不好。leading 模式让第一次点击立即执行,后续点击在冷却期内被忽略。

javascript
/**
 * 防抖函数——支持立即执行
 * @param {Function} fn        - 要执行的函数
 * @param {number}   delay     - 延迟时间(毫秒)
 * @param {boolean}  immediate - true=第一次立即执行 / false=最后执行
 */
function debounce(fn, delay, immediate = false) {
    let timer = null;

    return function(...args) {
        const callNow = immediate && !timer;  // 判断是否立即执行

        clearTimeout(timer);
        timer = setTimeout(() => {
            timer = null;
            if (!immediate) {
                fn.apply(this, args);  // trailing:延迟后执行
            }
        }, delay);

        if (callNow) {
            fn.apply(this, args);  // leading:立即执行
        }
    };
}

// 点赞场景:第一次点击立即生效,300ms 内的重复点击忽略
likeBtn.addEventListener('click', debounce(sendLike, 300, true));
leading 模式时间线:

  0ms:   点击 → 立即执行 fn() + 启动 300ms 冷却期
  100ms: 点击 → 冷却期内,重置计时器,不执行
  200ms: 点击 → 冷却期内,重置计时器,不执行
  500ms: 冷却期结束,timer 置 null
  600ms: 点击 → 立即执行 fn() + 启动新的冷却期

3.3.5 生产级实现(支持 maxWait 兜底)

javascript
/**
 * 生产级防抖函数
 * @param {Function} fn      - 要执行的函数
 * @param {number}   delay   - 延迟时间(毫秒)
 * @param {Object}   options - 配置项
 *   - leading: 是否首次立即执行(默认 false)
 *   - trailing: 是否延迟后执行(默认 true)
 *   - maxWait: 最大等待时间,超过后强制执行(防止一直被重置)
 */
function debounce(fn, delay, options = {}) {
    const { leading = false, trailing = true, maxWait } = options;
    let timer = null;
    let lastCallTime = 0;       // 最后一次调用时间
    let lastInvokeTime = 0;     // 最后一次实际执行时间

    function invokeFunc(...args) {
        lastInvokeTime = Date.now();
        fn.apply(this, args);
    }

    return function(...args) {
        const now = Date.now();
        lastCallTime = now;

        // maxWait 兜底逻辑:超过最大等待时间强制执行
        if (maxWait && !timer && lastInvokeTime + maxWait < now) {
            invokeFunc.apply(this, args);
            return;
        }

        const callNow = leading && !timer;

        clearTimeout(timer);
        timer = setTimeout(() => {
            timer = null;
            // 如果 maxWait 内已经执行过,trailing 不再执行
            if (trailing && lastCallTime - lastInvokeTime >= delay) {
                invokeFunc.apply(this, args);
            }
        }, delay);

        if (callNow) invokeFunc.apply(this, args);
    };
}

// 使用示例
likeBtn.addEventListener('click', debounce(sendLike, 300, {
    leading: true,   // 第一次立即执行
    trailing: false, // 不需要尾随执行
    maxWait: 1000    // 最多等 1 秒强制刷新(防止无限延期)
}));

3.3.6 React 中的防抖 Hook

jsx
import { useRef, useCallback, useEffect } from 'react';

/**
 * useDebounce - React 防抖 Hook
 */
function useDebounce(fn, delay) {
    const timerRef = useRef(null);
    const fnRef = useRef(fn);

    // 始终保持 fn 最新引用
    useEffect(() => { fnRef.current = fn; }, [fn]);

    // 组件卸载时清理定时器
    useEffect(() => {
        return () => {
            if (timerRef.current) clearTimeout(timerRef.current);
        };
    }, []);

    return useCallback((...args) => {
        if (timerRef.current) clearTimeout(timerRef.current);
        timerRef.current = setTimeout(() => {
            fnRef.current(...args);
        }, delay);
    }, [delay]);
}

// 使用
function LikeButton({ postId }) {
    const [liked, setLiked] = useState(false);

    const handleLike = useDebounce(async () => {
        await fetch(`/api/post/${postId}/like`, { method: 'POST' });
        setLiked(true);
    }, 300);

    return <button onClick={handleLike} disabled={liked}>点赞</button>;
}

3.3.7 防抖 vs 按钮置灰

防抖(Debounce)按钮置灰
原理延迟执行,新触发取消旧触发请求发出后禁用按钮,响应返回后恢复
防御方式时间窗口内只执行一次状态锁,请求未完成前不可操作
漏网可能有(快速点击超过防抖窗口)无(请求没返回前按钮不可用)
用户体验无感知,不卡顿有短暂禁用感
适用场景搜索输入、窗口 resize点赞、提交、支付
javascript
// 点赞场景推荐:防抖 + 按钮置灰 组合使用
let isLiking = false;

likeBtn.addEventListener('click', debounce(async function() {
    if (isLiking) return;   // 按钮置灰兜底

    isLiking = true;
    likeBtn.disabled = true;
    likeBtn.textContent = '...';

    try {
        await fetch('/api/post/100/like', { method: 'POST' });
        likeBtn.textContent = '已点赞';
        likeBtn.classList.add('liked');
    } catch (e) {
        likeBtn.disabled = false;
        likeBtn.textContent = '点赞';
        isLiking = false;
    }
}, 300, true));

3.3.8 为什么前端防抖不能作为唯一方案

前端防抖的致命缺陷:

  1. 用户打开 DevTools → Network → 看到请求 URL
     → 直接在 Console 里写:
     fetch('/api/post/100/like', {method:'POST'})  ← 绕过所有前端逻辑

  2. 用户用 curl / Postman 直接调 API

  3. 恶意脚本可以同时发 1000 个请求,每个都是不同的 TCP 连接

  4. 浏览器卡顿、内存不足等极端情况可能导致定时器异常

  前端防抖挡得住正常用户,挡不住故意的。
  服务端必须独立做幂等控制,不能依赖前端。

四、推荐组合方案

前端防抖(第一道防线)


分布式锁 — Redis SETNX / Lua(第二道防线,快速拦截)


数据库唯一索引(第三道防线,兜底保证)
  • 前端防抖:减少 90% 的重复请求
  • 分布式锁:毫秒级拦截并发重复(简单场景用 SETNX,复杂场景用 Lua)
  • 唯一索引:数据层的最终保障

五、面试要点

Q1:点赞要存数据库吗?

  取决于业务。社交类产品(微博、抖音)点赞量大,通常用 Redis 为主存储,异步落库,允许少量丢失;但涉及金钱或强一致性场景(如投票),必须用数据库唯一索引。

Q2:Redis 的 SETNX 和数据库唯一索引哪个好?

  不是替代关系,是互补关系。Redis 扛高并发读取和写入,数据库做最终持久化和兜底。两者组合才是最佳实践。

Q3:如果 Redis 挂了怎么办?

  降级到数据库唯一索引,虽然性能下降但功能不丢。可以配合本地缓存(Caffeine)做多级防护。