收藏功能必须用独立中间表user_favorites,含user_id、item_id、item_type(可选)字段,建联合唯一索引(user_id,item_id)及user_id单列索引,查收藏用JOIN而非子查询,取消收藏用DELETE而非软删除,高并发收藏用INSERT IGNORE防重复。

收藏功能必须拆成独立表,不能加到用户或内容表里
直接在 users 表加个 favorite_ids 字段(比如存 CSV)或者在 articles 表加 favorited_by,短期看着省事,但很快会卡死:查某用户所有收藏、查某内容被多少人收藏、取消收藏时更新字符串都难做且慢。关系型数据库的强项是关联查询,不是字符串解析。
正确做法是建一张中间表:user_favorites,至少包含三个字段:id(主键,非必需但建议)、user_id(外键指向 users.id)、item_id(指向被收藏的内容,比如 articles.id 或 products.id),再加一个 item_type 字段支持多类型(可选,见下一点)。
- 如果只收藏一种类型(比如全是文章),
item_id直接关联articles.id,加联合唯一索引(user_id, item_id)防重复收藏 - 如果要同时收藏文章、视频、商品,就用「泛型外键」:加
item_type(如'article'、'video'),并确保应用层校验item_id确实存在对应记录——MySQL 本身无法对动态表名建外键 - 别忘了给
user_id单独建索引,否则按用户查收藏列表会全表扫描
查用户收藏列表时,JOIN 比子查询更稳
想查用户 ID=123 的所有收藏文章及标题,常见写法是:SELECT * FROM articles WHERE id IN (SELECT item_id FROM user_favorites WHERE user_id = 123)。这在数据量小的时候没问题,但一旦 user_favorites 有几十万行,MySQL 可能不走 user_id 索引,或生成临时表拖慢查询。
更可靠的是显式 JOIN:
SELECT a.* FROM user_favorites uf JOIN articles a ON uf.item_id = a.id WHERE uf.user_id = 123 ORDER BY uf.id DESC LIMIT 20;
-
uf.id DESC是按收藏时间倒序(假设用自增主键记录顺序),比用created_at更轻量;如果需要精确时间,就加created_at字段并索引它 - 务必在
user_favorites(user_id, id)上建联合索引,让排序和 LIMIT 能用上索引 - 如果收藏量极大(比如用户收藏了 10 万条),考虑分页改用游标(cursor-based pagination),避免
LIMIT 100000, 20
取消收藏必须用 DELETE,不是 UPDATE
看到有人把 user_favorites 表设计成带 is_deleted 字段的软删除,这是错的。收藏本质是「存在性关系」,不是「状态记录」。软删除会让表越积越大,唯一约束失效(同一用户可重复插入已“删”的记录),查询逻辑变复杂。
- 取消收藏就一条语句:
DELETE FROM user_favorites WHERE user_id = 123 AND item_id = 456 - 执行前不用先
SELECT判断是否存在——直接DELETE返回影响行数,0 表示本来就没收藏,应用层据此返回提示即可 - 确保该语句命中
(user_id, item_id)联合索引,否则可能锁整张表(尤其在高并发取消场景)
注意事务边界和并发冲突
用户点「收藏」按钮时,典型流程是:先查是否已收藏 → 没有则插入。这个两步操作在并发下会出问题:两个请求几乎同时查,都得到「未收藏」,然后都插入,违反唯一约束。
- 最简单解法:忽略唯一键冲突。用
INSERT IGNORE INTO user_favorites (user_id, item_id) VALUES (123, 456),冲突时静默失败,应用层按影响行数判断是否成功 - 更严谨的做法是用
INSERT ... ON DUPLICATE KEY UPDATE,但这里不需要更新任何字段,所以IGNORE更轻量 - 如果业务要求严格返回「本次是否新增」,那就得用事务 +
SELECT ... FOR UPDATE锁住用户行,但会降低并发能力,一般没必要
泛型外键(item_type)带来的校验责任完全落在应用层,数据库不兜底;而唯一索引和外键缺失的地方,就是线上出错的第一现场。










