本文目录导读:

在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列表的接口)。
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播