Appearance
重复点赞问题
点赞是社交/内容类产品的核心功能,重复点赞问题本质上是分布式环境下的幂等性问题——同一个用户对同一个目标只能点赞一次,第二次操作要么无效,要么取消点赞。
一、问题场景
用户 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 字段区分"点赞"和"取消"两种状态,不删记录,反复 toggle3.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 解决的,不要上 Lua3.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)做多级防护。
