本文目录导读:

在PHP项目中实现“关注与粉丝”关系(通常称为社交关系或用户关系),通常采用关系型数据库(如MySQL)来存储用户之间的关注信息,下面我将从数据结构设计、核心功能逻辑、性能优化及代码示例四个方面为你详细讲解。
数据库设计(核心)
最经典的设计是使用一张独立的关系表,而不是在用户表中存储JSON数组。
CREATE TABLE `user_follow` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`follower_id` INT UNSIGNED NOT NULL COMMENT '关注者ID',
`followed_id` INT UNSIGNED NOT NULL COMMENT '被关注者ID',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '关注时间',
`status` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1:关注中, 0:取消关注(软删除)',
UNIQUE KEY `uk_follower_followed` (`follower_id`, `followed_id`),
INDEX `idx_follower_status` (`follower_id`, `status`),
INDEX `idx_followed_status` (`followed_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注关系表';
设计要点:
- 联合唯一索引
(follower_id, followed_id)确保同一对用户仅有一条记录。 - 软删除(status字段)比物理删除更方便恢复数据和做统计。
- 复合索引
(follower_id, status)用于快速查询某人关注了谁;(followed_id, status)用于查询某人的粉丝。
用户表(users)补充字段(可选但推荐):
ALTER TABLE `users` ADD COLUMN `follower_count` INT UNSIGNED DEFAULT 0; ALTER TABLE `users` ADD COLUMN `following_count` INT UNSIGNED DEFAULT 0;
这两个冗余字段可以避免每次查询都要COUNT,大大提升列表页和首页的性能。
核心功能逻辑实现
关注/取消关注
public function toggleFollow(int $currentUserId, int $targetUserId): array
{
// 禁止关注自己
if ($currentUserId === $targetUserId) {
return ['code' => 400, 'msg' => '不能关注自己'];
}
// 检查目标用户是否存在
$user = User::find($targetUserId);
if (!$user) {
return ['code' => 404, 'msg' => '用户不存在'];
}
// 使用事务保证数据一致性
DB::beginTransaction();
try {
$record = UserFollow::where('follower_id', $currentUserId)
->where('followed_id', $targetUserId)
->first();
if ($record) {
// 已存在记录 -> 反转状态
$newStatus = !$record->status;
$record->status = $newStatus;
$record->save();
} else {
// 首次关注
UserFollow::create([
'follower_id' => $currentUserId,
'followed_id' => $targetUserId,
'status' => true
]);
$newStatus = true;
}
// 更新计数器(可根据status增减)
$this->updateCounters($currentUserId, $targetUserId, $newStatus);
DB::commit();
return ['code' => 200, 'msg' => $newStatus ? '关注成功' : '取消关注'];
} catch (\Exception $e) {
DB::rollBack();
return ['code' => 500, 'msg' => '操作失败'];
}
}
private function updateCounters(int $followerId, int $followedId, bool $isFollow)
{
$diff = $isFollow ? 1 : -1;
User::where('id', $followerId)->increment('following_count', $diff);
User::where('id', $followedId)->increment('follower_count', $diff);
}
获取粉丝列表(关注我的人)
public function getFans(int $userId, int $page = 1, int $pageSize = 20): array
{
return UserFollow::where('followed_id', $userId)
->where('status', true)
->orderBy('created_at', 'desc')
->paginate($pageSize, ['*'], 'page', $page)
->through(function ($follow) {
return [
'id' => $follow->follower->id,
'name' => $follow->follower->name,
'avatar' => $follow->follower->avatar,
'followed_at' => $follow->created_at
];
});
}
判断是否已关注(批量判断常用)
// 单个判断
public function isFollowing(int $currentUserId, int $targetUserId): bool
{
return UserFollow::where('follower_id', $currentUserId)
->where('followed_id', $targetUserId)
->where('status', true)
->exists();
}
// 批量判断(例如首页Feed流需要标记已关注状态)
public function batchCheckFollowStatus(int $currentUserId, array $targetUserIds): array
{
$exists = UserFollow::where('follower_id', $currentUserId)
->whereIn('followed_id', $targetUserIds)
->where('status', true)
->pluck('followed_id')
->toArray();
return array_fill_keys($targetUserIds, false) + array_fill_keys($exists, true);
}
高并发与性能优化
索引优化
- 上面的表设计已包含两个核心索引,避免全表扫描。
- 如果查询量大,可以为
(follower_id, created_at)建索引用于查询“我最近关注的人”。
计数器缓存(Redis)
- 将
follower_count和following_count缓存在Redis,设置过期时间(如1小时)。 - 更新时先更新数据库,再删除Redis缓存(Cache Aside模式),下次查询自动回填。
public function getUserFollowCount(int $userId): array
{
$redis = Redis::connection();
$key = "user:{$userId}:follow_count";
$counts = $redis->hgetall($key);
if (!$counts) {
// 从数据库读取并存入缓存
$user = User::find($userId, ['follower_count', 'following_count']);
$counts = [
'follower' => $user->follower_count,
'following' => $user->following_count
];
$redis->hmset($key, $counts);
$redis->expire($key, 3600);
}
return $counts;
}
异步更新计数器
关注/取关操作非常频繁,可以使用消息队列(RabbitMQ/Redis Stream)异步更新MySQL中的计数,减少主流程响应时间。
常见问题与注意事项
| 问题 | 解决方案 |
|---|---|
| 幂等性(重复点击关注) | 利用数据库 unique key,第二次插入报错,或使用INSERT ... ON DUPLICATE KEY UPDATE |
| 用户注销/删除 | 删除用户时同时逻辑删除所有关注关系 (UPDATE ... SET status=0 WHERE follower_id=xxx OR followed_id=xxx) |
| 分页性能 | 粉丝/关注列表避免用OFFSET大数值,改用游标分页(cursor based pagination),基于id或created_at |
| 双向关注判定 | 查询 (a->b) AND (b->a) 可定义为好友,用一条SQL + 自连接实现 |
游标分页示例:
$lastId = request('last_id', 0);
$list = UserFollow::where('followed_id', $userId)
->where('status', true)
->where('id', '<', $lastId) // 游标方式
->orderBy('id', 'desc')
->limit(20)
->get();
完整的关注接口示例(Laravel风格)
// routes/api.php
Route::post('/user/{user}/follow', [FollowController::class, 'toggle']);
// FollowController.php
public function toggle(Request $request, User $user)
{
$result = $this->followService->toggleFollow(
$request->user()->id,
$user->id
);
if ($result['code'] !== 200) {
return response()->json($result, $result['code']);
}
return response()->json([
'followed' => $result['msg'] === '关注成功',
'following_count' => $request->user()->fresh()->following_count,
'follower_count' => $user->fresh()->follower_count
]);
}
| 模块 | 推荐做法 |
|---|---|
| 存储结构 | 独立关系表 + 联合唯一索引 + 软删除status字段 |
| 计数器 | 用户表冗余字段 + Redis缓存加速 |
| 判断逻辑 | 批量查询使用 whereIn 一次返回 |
| 并发安全 | MySQL事务 + 唯一索引保证不重复 |
| 分页 | 游标分页优于传统OFFSET |
这个设计在百万级用户、千万级关注关系的场景下依然能保持良好性能,如果你的项目用户量级更大(如亿级),需要考虑分表(按用户ID取模)或改用图数据库(如Neo4j)。