本文目录导读:

在PHP项目中实现内容收藏统计,通常有两种主要思路:实时查询数据库计数 和 使用缓存(如Redis),对于大多数中小型项目,合理利用数据库索引和缓存即可高效实现。
以下是完整的实现方案,包含数据库设计、核心代码示例和性能优化建议。
核心功能拆解
- 用户收藏/取消收藏(写操作)
- 查询某个内容的收藏数(读操作)
- 判断当前用户是否已收藏(读操作)
基础实现(基于关系型数据库 + 计数冗余)
这种方式简单可靠,适合并发不高的项目(如日均PV < 10万)。
数据库表设计
需要三张表:用户表、内容表(如文章)、收藏关系表。
核心收藏表 (favorites)
CREATE TABLE `favorites` ( `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '用户ID', `target_id` int(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '被收藏内容ID(如文章ID)', `target_type` varchar(30) NOT NULL DEFAULT 'article' COMMENT '内容类型(支持多种内容,如文章、视频)', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间', PRIMARY KEY (`id`), -- 关键索引:用于快速查询某个用户对某个内容的收藏状态,以及统计数量 UNIQUE KEY `uk_user_target` (`user_id`, `target_id`, `target_type`), KEY `idx_target_count` (`target_id`, `target_type`) -- 用于统计该内容的收藏总数 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
表(冗余计数字段,如 articles)**
ALTER TABLE `articles` ADD COLUMN `fav_count` int(10) UNSIGNED NOT NULL DEFAULT '0' COMMENT '收藏数';
PHP代码实现
<?php
class FavoriteService {
private $db; // PDO 实例
public function __construct($db) {
$this->db = $db;
}
/**
* 切换收藏状态(如果已收藏则取消,否则收藏)
* @param int $userId
* @param int $targetId
* @param string $targetType
* @return array ['status' => 'collected'|'uncollected', 'fav_count' => int]
*/
public function toggle($userId, $targetId, $targetType = 'article') {
// 先检查是否已收藏(利用唯一索引)
$stmt = $this->db->prepare("SELECT id FROM favorites WHERE user_id = ? AND target_id = ? AND target_type = ? LIMIT 1");
$stmt->execute([$userId, $targetId, $targetType]);
$existing = $stmt->fetchColumn();
if ($existing) {
// 已收藏:取消收藏
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare("DELETE FROM favorites WHERE id = ?");
$stmt->execute([$existing]);
// 更新内容表收藏计数(减1)
$stmt = $this->db->prepare("UPDATE articles SET fav_count = GREATEST(fav_count - 1, 0) WHERE id = ?");
$stmt->execute([$targetId]);
$this->db->commit();
} catch (Exception $e) {
$this->db->rollBack();
throw $e;
}
$status = 'uncollected';
} else {
// 未收藏:添加收藏
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare("INSERT INTO favorites (user_id, target_id, target_type) VALUES (?, ?, ?)");
$stmt->execute([$userId, $targetId, $targetType]);
// 更新内容表收藏计数(加1)
$stmt = $this->db->prepare("UPDATE articles SET fav_count = fav_count + 1 WHERE id = ?");
$stmt->execute([$targetId]);
$this->db->commit();
} catch (Exception $e) {
$this->db->rollBack();
throw $e;
}
$status = 'collected';
}
// 返回最新的收藏数
$stmt = $this->db->prepare("SELECT fav_count FROM articles WHERE id = ?");
$stmt->execute([$targetId]);
$favCount = (int) $stmt->fetchColumn();
return ['status' => $status, 'fav_count' => $favCount];
}
/**
* 获取内容的收藏总数
*/
public function getCount($targetId, $targetType = 'article') {
$stmt = $this->db->prepare("SELECT fav_count FROM articles WHERE id = ?");
$stmt->execute([$targetId]);
return (int) $stmt->fetchColumn();
}
/**
* 检查用户是否收藏了某个内容
*/
public function isFavored($userId, $targetId, $targetType = 'article') {
$stmt = $this->db->prepare("SELECT 1 FROM favorites WHERE user_id = ? AND target_id = ? AND target_type = ? LIMIT 1");
$stmt->execute([$userId, $targetId, $targetType]);
return (bool) $stmt->fetchColumn();
}
}
优缺点
- 优点:数据强一致,实现简单,不需要额外组件。
- 缺点:每次收藏/取消都写两次数据库(插入/删除 + 更新计数),高并发下,
UPDATE fav_count = fav_count - 1可能产生行锁等待。
高性能实现(推荐,Redis + MySQL异步落盘)
适用于高并发场景(如短视频、新闻客户端),核心思想:收藏操作直接写入Redis,然后通过定时任务或消息队列异步同步到MySQL做持久化。
Redis数据结构选择
- 一个用户收藏了哪些内容:使用 Set,key 为
user:1:favs(value 为 target_id)。 - 被哪些用户收藏:使用 Set,key 为
article:100:fav_users,的收藏总数**:使用计数器(string),key 为article:100:fav_count。
核心流程
用户收藏/取消(PHP -> Redis)
<?php
class FavoriteFastService {
private $redis;
public function __construct($redis) {
$this->redis = $redis;
}
public function toggle($userId, $targetId) {
// 判断是否已收藏
$key = "user:{$userId}:favs";
$isFav = $this->redis->sIsMember($key, $targetId);
if ($isFav) {
// 取消收藏
$this->redis->multi() // 开启事务
->sRem($key, $targetId)
->sRem("article:{$targetId}:fav_users", $userId)
->decr("article:{$targetId}:fav_count")
->exec();
return ['status' => 'uncollected', 'fav_count' => $this->redis->get("article:{$targetId}:fav_count")];
} else {
// 收藏
$this->redis->multi()
->sAdd($key, $targetId)
->sAdd("article:{$targetId}:fav_users", $userId)
->incr("article:{$targetId}:fav_count")
->exec();
return ['status' => 'collected', 'fav_count' => $this->redis->get("article:{$targetId}:fav_count")];
}
}
public function getCount($targetId) {
return (int) $this->redis->get("article:{$targetId}:fav_count");
}
public function isFavored($userId, $targetId) {
return $this->redis->sIsMember("user:{$userId}:favs", $targetId);
}
}
异步同步到MySQL(保证数据不丢)
方案A:定时任务(Crontab)
- 每分钟执行一个脚本:遍历Redis中所有收藏相关Key,将数据批量写入MySQL的
favorites表,并更新articles.fav_count。 - 缺点:如果进程崩溃,可能丢失1分钟数据。
方案B:消息队列(推荐)
- 用户收藏后,除了写Redis,还推送一条消息到消息队列(如RabbitMQ、Kafka或Redis Streams)。
- 一个消费者进程(Worker)从队列消费消息,执行:
INSERT INTO favorites ... ON DUPLICATE KEY UPDATE和UPDATE articles SET fav_count = ?。 - 可以在每次消费时直接更新文章的
fav_count,也可以在消费者中累积一段时间后再批量更新。
示例:使用 Redis Streams 做消息队列
// 收藏成功后,推送消息
$redis->xAdd('fav_stream', '*', [
'user_id' => $userId,
'target_id' => $targetId,
'action' => $isFav ? 'remove' : 'add'
]);
// 消费者进程(可以写成一个常驻脚本)
while (true) {
$messages = $redis->xReadGroup('fav_group', 'worker_1', ['fav_stream' => '>'], 1, 5000);
if ($messages) {
foreach ($messages as $stream => $items) {
foreach ($items as $id => $data) {
// 写MySQL表
$db->execute('...');
// 确认消息处理完毕(ACK)
$redis->xAck('fav_stream', 'fav_group', [$id]);
}
}
}
}
优缺点
- 优点:性能极高(全部基于内存操作),支持水平扩展。
- 缺点:系统复杂度增加,需要引入Redis和数据同步组件,在Redis宕机且未同步到MySQL时会丢失部分数据(可通过持久化RDB/AOF缓解)。
前端交互示例(AJAX + 动画)
无论后端用哪种方案,前端逻辑类似:
// 假设 .fav-btn 是收藏按钮
$('.fav-btn').on('click', function() {
const $btn = $(this);
const targetId = $btn.data('id'); // 文章ID
$.post('/api/favorite/toggle', { target_id: targetId }, function(res) {
if (res.code === 0) {
// 更新UI
$btn.toggleClass('active', res.data.status === 'collected');
$btn.find('.count').text(res.data.fav_count);
// 触发放心形动画等
} else {
alert('操作失败,请重试');
}
});
});
总结与选择建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 小型站点、用户量<1万 | 方案一(MySQL直接操作) | 实现简单,无需额外组件,数据一致 |
| 中型站点、高并发读 | 方案一 + 本地缓存(如Redis做纯只读缓存) | 写操作直接落库,但读时走Redis |
| 大型站点、社交类应用 | 方案二(Redis计数 + 异步落盘) | 支持高并发读写,用户体验好 |
额外建议:类型不止一种,target_type 字段非常有用。
- 在用户列表页或文章列表页展示收藏数时,务必使用冗余字段
fav_count,而不是COUNT(*),否则会严重拖慢性能。 - 对于“该用户是否收藏过”的判断,如果用户经常刷新列表,可以考虑前端缓存用户收藏的ID列表(如localStorage或发送一次完整ID列表的接口)。