PHP点赞功能高并发怎么弄?从数据库锁到Redis原子操作的终极方案
目录导读
- 高并发点赞的痛点:为什么简单的
UPDATE会崩溃? - 第一层优化:数据库层面的“轻量级”抗压
- 第二层优化:引入Redis,把“写压力”转移
- 第三层优化:异步队列与最终一致性
- 终极方案:Lua脚本+Redis原子递增(附代码)
- 高频问答(FAQ):关于防刷、计数准确性与降级方案
高并发点赞的痛点:为什么简单的UPDATE会崩溃?
在许多初学者的代码里,点赞功能往往是这样实现的:

// 用户点击点赞
$db->query("UPDATE posts SET likes = likes + 1 WHERE id = 123");
在低并发下,这没问题,但在高并发场景(比如秒杀、爆款文章)下,这个UPDATE语句会成为行锁的牺牲品。
核心痛点:
- 锁竞争:InnoDB的行锁会让所有请求排队,吞吐量急剧下降。
- 连接耗尽:PHP-FPM默认连接池有限,大量请求等待数据库锁,导致连接堆积。
- 重复写入:用户疯狂点击,可能造成同一用户多次点赞(防刷问题)。
搜索引擎老文章常犯的错误:它们会建议你用INSERT ... ON DUPLICATE KEY UPDATE,但这只解决“重复点赞”问题,并没有解决“写并发”问题。
第一层优化:数据库层面的“轻量级”抗压
方案A:合并更新(Batch Update) 不直接操作主表,先将点赞记录写入一个临时表,通过定时任务(如每5秒)合并到主表。
-- 临时点赞表 CREATE TABLE likes_buffer ( post_id INT, user_id INT, created_at TIMESTAMP, PRIMARY KEY(post_id, user_id) ); -- 合并脚本(每分钟执行一次) INSERT INTO posts (id, likes) SELECT post_id, COUNT(*) FROM likes_buffer GROUP BY post_id ON DUPLICATE KEY UPDATE likes = likes + VALUES(likes);
方案B:乐观锁CAS(Compare And Swap)
利用version字段,减少锁等待。
UPDATE posts SET likes = likes + 1, version = version + 1 WHERE id = 123 AND version = 1; -- 假设读到的version是1
但以上方案仍有隐患:数据库依然是IO瓶颈,真正解决问题,必须引入内存级缓存。
第二层优化:引入Redis,把“写压力”转移
核心思路:点赞数先写Redis,不直接落库。
- 存储结构:用Redis的
Hash存储每个帖子的点赞数。 - 点赞操作:使用
HINCRBY命令,这是原子操作,天然防并发。
// 伪代码
$redis->hIncrBy('post_likes', $postId, 1);
// 同时记录用户是否已点赞,防止重复
$isLiked = $redis->sAdd('post_liked_users:'.$postId, $userId);
if (!$isLiked) {
// 如果已经存在,则回滚
$redis->hIncrBy('post_likes', $postId, -1);
}
为什么Redis扛得住?
- Redis是单线程模型,所有命令串行执行,不存在锁竞争。
- 内存操作,QPS轻松上万。
数据落库策略:
- 定时将
Hash中的数据持久化到MySQL(如每小时一次)。 - 或者,利用MySQL的
LOAD DATA INFILE批量导入。
第三层优化:异步队列与最终一致性
问题:如果Redis挂了怎么办?或者数据丢失?
方案:将“点赞成功”事件写入消息队列(如RabbitMQ、Kafka),由消费者异步写入数据库。
用户点击 -> 写入Redis(记录点赞数) -> 同时推一条消息到MQ
消费者 -> 从MQ取出 -> 写MySQL(做幂等处理)
幂等处理核心:在MySQL中,将(post_id, user_id)设为唯一索引,用INSERT IGNORE保证不重复插入,这样即使MQ重复消费,也不会有问题。
终极方案:Lua脚本+Redis原子递增
这是目前互联网大厂(如微博、知乎)最常用的方案。将判断是否已点赞、递增计数、记录用户操作合并为一个Lua脚本,一次性执行,彻底避免竞态。
-- 检查用户是否已在集合中
local isMember = redis.call('SISMEMBER', KEYS[1], ARGV[1])
if isMember == 1 then
return 0 -- 已点赞,返回0
else
redis.call('SADD', KEYS[1], ARGV[1])
redis.call('HINCRBY', KEYS[2], ARGV[2], 1)
return 1 -- 点赞成功
end
PHP调用代码:
$script = <<<LUA
-- 前面Lua代码
LUA;
$result = $redis->eval($script,
['post_liked_users:' . $postId, 'post_likes:' . $postId, $userId, $postId],
2 // 两个key
);
if ($result == 1) {
echo "点赞成功";
} else {
echo "您已经赞过了";
}
为什么这是终极方案?
- 原子性:Lua脚本在Redis中执行是原子性的,不会被打断。
- 高效:只经历一次网络IO(PHP到Redis),不经过多次往返。
- 防重复:利用集合的唯一性本质。
高频问答(FAQ)
Q1:高并发下,用户疯狂点击怎么办?
答:使用Lua脚本的SISMEMBER检查,在Redis内存中直接拦截第二次点击,还可以配合前端节流(如点击后按钮变灰1秒)。
Q2:Redis数据最终一致,如果Redis宕机,点赞数会丢吗? 答:不会,方案是先写Redis,但后台每10秒同步一次MySQL,就算Redis宕机,最多丢失10秒的点赞数据,但用户可以重新点赞,更严谨的可以用AOF持久化。
Q3:如何防止恶意刷赞(小号刷量)?
答:在Lua脚本中增加IP限制或用户行为分析(如点赞频率)。INCR用户每小时点赞次数,超过阈值则拒绝。
Q4:如何查询点赞数?
答:优先从Redis读HGET post_likes postId,如果不存在则从MySQL读取并回填Redis。
Q5:为什么要用Hash而不是String直接存点赞数?
答:因为Hash可以存储多个帖子的赞数,且支持HINCRBY命令,如果用字符串,你需要为每个帖子单独建一个key,管理麻烦。
Q6:这套方案适用于“踩”和“点赞”共存的功能吗? 答:适用,可以扩展Lua脚本,传入标志位(1为赞,0为踩),维护两个不同的Hash或一个Rank结构。
高并发点赞的本质是把随机写变成顺序写(Redis),再把内存数据异步批量刷到磁盘(MySQL),记住搜索引擎老文章里只谈“事务”和“行锁”,那是十年前的做法。Redis + Lua + 队列才是构建高可用点赞系统的王道。