智能电影推荐系统做到数据库这一层,很多人的第一反应是:这有什么好聊的?无非就是建几张表,把电影数据导进去,再给用户开个查询接口。但真正把一个推荐系统从零搭上线之后你就会发现,数据库才是决定项目能不能按时交付、上线后稳不稳的那个隐形瓶颈。算法模型再强,也需要有干净、及时、可回滚的数据喂给它;而数据的入口、出口、中间存储,全部绕不开数据库设计这件事。
这篇文章想聊的,就是我最近在做“智能电影推荐系统”系列时,数据库侧从0到1的完整落地过程。涉及四张核心主表怎么建、行为流水表怎么防重、冷启动数据怎么导、离线特征和在线结果怎么分层存储、以及并发写入时那些最容易让人通宵排查的锁问题。不管你是拿这个题目做课程设计,还是在个人全栈项目里加一个推荐模块,甚至是在小团队里负责推荐系统的底层数据模块,这篇应该都能直接给你一套能落地的参考方案。
1. 推荐系统的数据流里,数据库到底在扛哪些活
1.1 先认清系统里会产生哪几类数据
动手建表之前,我花了不少时间想一个问题:电影推荐系统和普通的管理系统,对数据库的要求到底差在哪?
答案在于数据种类。一个普通后台可能只需要用户表、订单表、商品表,数据生命周期相对单纯。而推荐系统从用户第一次打开App开始,就会源源不断地产出几类特征完全不同的数据:
- 主数据:用户注册信息、电影基础信息、演员导演标签这类静态数据。特点是有明确唯一键,更新频率低,适合用传统的关系模型管理。
- 行为数据:用户浏览了哪些电影、点了哪些电影、看了多久、打了多少分。特点是量大、增长快、只追加不修改,是推荐模型的“燃料”。
- 特征数据:基于行为数据加工出来的用户画像、电影向量、统计指标。特点是加工后会被高频读取,是最需要保证一致性和时效性的部分。
- 推荐结果数据:召回排序模块产出的、每个用户最终看到的电影列表。特点是定期全量刷新,但在线读取要求毫秒级返回。
我见过不少项目把所有数据一股脑塞进同一个 MySQL 库,表倒是建了三十几张,结果一到上线就发现:行为表太大拖慢了主表查询,推荐结果在线读又和频繁写入的任务相互打架。所以第一件事不是设计表结构,而是先在脑子里给这些数据划好边界。
1.2 先算一笔量级账,再决定每一张表的定位
数据量级直接决定表结构怎么设计、索引怎么建、是走单表还是分区、需不需要引入缓存。
我按一个中小型电影推荐场景粗略估算:
| 数据项 | 来源 | 日新增估算 | 一年存量估算 |
|---|---|---|---|
| 用户主数据 | 注册、登录 | 几百到几千条 | 百万级以下 |
| 电影主数据 | 后台录入、接口同步 | 低 | 十万级以下 |
| 用户行为流水 | 曝光、点击、收藏、评分 | 几十万到上百万条 | 亿级 |
| 用户画像与特征 | 每日离线跑批 | 与活跃用户数相当 | 几百万行 |
| 推荐结果 | 生成每个用户的个性化列表 | 百万条级 | 亿级(含历史旧批次) |
注意,这个估算里还没有算“同一条行为被重复上报”的脏数据量。实际做下来,如果客户端有重试机制,行为流水量至少再放大1.2到1.5倍。所以我在设计时给行为表留了单日百万级写入的压力预期,而不是按“理想状态”来设计。
算完这笔账,结论就很清楚:主数据和特征数据在 MySQL 里用标准 InnoDB 表完全没问题;行为流水要避免被随意大规模 JOIN;推荐结果表则要作为高频在线查询表来单独优化,不能和业务流水混在一起。
1.3 事务型查询、统计分析、向量检索不能混用
推荐系统底层还有个特别容易犯的错:把 MySQL 当成什么都干的数据仓库。
有一次我想省事,想直接在行为表上跑一个 SQL,统计全站最近七天的热门类型,然后UPDATE到一张结果表里。单量小的时候两秒就能跑完,但等行为表数据过千万,这个统计 SQL 在业务高峰期跑起来,直接把在线接口拖慢了。从那以后我给自己定了一个原则:MySQL 只承担在线事务和轻量查询,重活交给专门的数据链路。
更准确地说,推荐系统的存储至少应该分三层:
- 在线业务层:用户、电影、行为写入、推荐结果读取,要求低延迟高可用,这是 MySQL 的主场。
- 特征加工层:把行为数据聚合成统计数据、画像特征,通常依赖离线任务或者流式计算,结果再回写到 MySQL / 特征存储。
- 召回检索层:向量的近邻检索、相似 TopN 查询,适合用向量检索组件,而不是让 MySQL 去硬扛高维向量计算。
如果一上来就把三层需求都压给同一个库,最后往往不是死锁就是慢查询,甚至两个问题一起爆。分清职责,后面的表设计才会有意义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 表结构设计的骨架:一个能支撑“智能”的电影库该有哪些表
2.1 电影和用户主表:留好 JSON 扩展字段,但别把全部身家押进去
主表是所有推荐的起点。用户要看什么、能看什么,都得从这两张表出发。我以 MySQL 8.0 为例,先看核心的电影表:
sql复制CREATE TABLE movie (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '物理主键',
movie_code VARCHAR(64) NOT NULL COMMENT '外部业务ID,如同步源ID',
title VARCHAR(255) NOT NULL COMMENT '电影标题',
original_title VARCHAR(255) DEFAULT NULL COMMENT '原始标题',
release_date DATE DEFAULT NULL COMMENT '上映日期',
region VARCHAR(64) DEFAULT NULL COMMENT '地区',
language VARCHAR(64) DEFAULT NULL COMMENT '语言',
duration_min INT UNSIGNED DEFAULT NULL COMMENT '片长(分钟)',
rating DECIMAL(3,1) DEFAULT NULL COMMENT '综合评分',
rating_count INT UNSIGNED DEFAULT 0 COMMENT '评分人数',
genres JSON DEFAULT NULL COMMENT '类型列表,如["动作","科幻"]',
status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1上架 0下架',
extra JSON DEFAULT NULL COMMENT '其他扩展字段',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_movie_code (movie_code),
KEY idx_status_release (status, release_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影主表';
几个关键决策点我想单独说一下。
第一,保留 movie_code 并加唯一索引,而不是把上游 ID 简单当主键。原因之一是以后如果对接多个数据源,自增主键是内部标识,movie_code 才是业务上真正能对上号的标识。数据导入脚本可以反复用 movie_code 做幂等,不会因为主键冲突把导入任务搞挂。
第二,genres 字段用 JSON,而不是塞一个文本字符串。MySQL 8.0 对 JSON 有比较成熟的函数支持,可以做到 JSON_CONTAINS 判断类型匹配,但我基本只在展示和条件筛选中用它,不会用它做大量聚合。真正做类型相关统计时,我会频繁查询“某用户看过哪些类型的电影”,这时候单独的关联表更合适。
第三,字符集必须用 utf8mb4。电影标题里经常出现特殊字符、emoji 甚至是不同语言的文字,utf8 字符集在遇到一些生僻字时可能会有问题,utf8mb4 是稳妥的选择。
用户表的设计思路类似,除了基本信息之外,我还会加一个 profile_tags JSON 字段,用来记录注册时选择或系统打上的兴趣标签。这样的好处是用户画像表即使后面加了新标签,也不需要频繁 ALTER TABLE 加列。
2.2 演员、导演、标签的关联关系:能拆的表必须拆干净
电影和演员、导演、标签是多对多关系。有些人习惯在电影表里放一个“主演id,1,2,3,4,5”这样的字符串,看着省事,真要做“查某个演员出演过的电影”时就会非常痛苦。所以我在项目里直接用关联表。
以导演关系为例:
sql复制CREATE TABLE movie_director (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
movie_id BIGINT UNSIGNED NOT NULL,
person_id BIGINT UNSIGNED NOT NULL,
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '排序,0为主导演',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_movie_person (movie_id, person_id),
KEY idx_person_movie (person_id, movie_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影导演关联表';
这里有个细节:唯一键是 (movie_id, person_id),但单独加了一个 (person_id, movie_id) 的索引。原因是推荐系统里“按导演找作品”“按作品找导演”两个方向都会被高频查询,而 InnoDB 的二级索引天然带有主键值,配合覆盖索引扫描效率很高。如果只建一个唯一键,按 person_id 反查电影时索引就用不上。
演员、类型标签同理,每个方向一张关联表,单独维护 sort_no 作为排序权重。类型相对简单,可以用类型表和电影类型关联表:
sql复制CREATE TABLE movie_genre (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
movie_id BIGINT UNSIGNED NOT NULL,
genre_code VARCHAR(32) NOT NULL COMMENT '类型编码,如action/scifi',
PRIMARY KEY (id),
UNIQUE KEY uniq_movie_genre (movie_id, genre_code),
KEY idx_genre_movie (genre_code, movie_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影类型关联表';
为什么不做成一张通用关联表?比如 movie_tag_rel 带 tag_type 字段。我最初也想过做成“通用关联表”,但后来发现通用表会让 SQL 的可读性下降,索引也会因为 tag_type 的过滤而多一层开销。拆成演员、导演、类型几张独立关联表,每张表都聚焦一种业务关系,反而好维护。
2.3 用户行为流水表:只追加、不修改,是推荐系统最核心的“原材料”
行为表是推荐系统区别于普通业务系统最明显的地方。我建表时把它当成“原材料流水线”而不是“状态表”来设计。
sql复制CREATE TABLE user_behavior (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '物理主键',
request_id VARCHAR(64) NOT NULL COMMENT '业务幂等ID,客户端生成',
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
movie_id BIGINT UNSIGNED NOT NULL COMMENT '电影ID',
behavior_type TINYINT UNSIGNED NOT NULL COMMENT '1曝光 2点击 3收藏 4评分 5观看',
rate TINYINT UNSIGNED DEFAULT NULL COMMENT '评分,仅behavior_type=4时有效',
watch_seconds INT UNSIGNED DEFAULT 0 COMMENT '观看时长,单位秒',
scene VARCHAR(32) NOT NULL DEFAULT 'home' COMMENT '推荐场景,home/detail/search等',
device VARCHAR(32) DEFAULT NULL COMMENT '设备渠道',
event_time DATETIME NOT NULL COMMENT '行为发生时间',
extra JSON DEFAULT NULL COMMENT '扩展字段',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间',
PRIMARY KEY (id),
UNIQUE KEY uniq_request_behavior (request_id, behavior_type),
KEY idx_user_time (user_id, event_time),
KEY idx_movie_time (movie_id, event_time),
KEY idx_behavior_time (behavior_type, event_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户电影行为流水表';
这张表有几个地方是反复调整后定下来的。
一是唯一键。物理主键 id 只是行标识,它不能阻止同一条行为被重复插入。真正负责“幂等”的,是 (request_id, behavior_type) 上的唯一索引。客户端每次上报事件时生成一个全局唯一的 request_id,同一个 request_id 的同类型行为在数据库里只允许出现一次。关于 request_id 在网络重试场景下的细节,第六节我会展开讲。
二是因为行为流水是“只追加为主”的表,我尽量控制了索引数量。很多人刚开始建表时,为了让所有查询都快,疯狂加索引,结果行为表写入速度被严重拖慢。这里我留四个索引:幂等唯一索引、按用户查、按电影查、按行为类型和时间范围统计。业务上九成查询都能覆盖到。
第三,我没有把“取消收藏”“取消点赞”做成对行为行的 UPDATE,而是在底层再单独建一张“状态表”来维护当前关系。事件流水是历史事实,一旦产生就不应该被抹掉,否则后期做“看了几部收藏过又取消的电影”这类分析时会失灵。需要当前状态时查状态表,需要历史轨迹时查流水表,各司其职。
2.4 画像和特征表:按主键高频读写,建模成 KV 而不是宽表 JOIN
用户画像表是特征工程的落点。我的设计原则是:一个用户一行,查询时按主键直接取整行,绝不为了取画像去 JOIN 三四张表。
sql复制CREATE TABLE user_profile (
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
age TINYINT UNSIGNED DEFAULT NULL,
gender TINYINT UNSIGNED DEFAULT NULL COMMENT '1男 2女 0未知',
register_time DATETIME DEFAULT NULL,
favorite_genres JSON DEFAULT NULL COMMENT '高频偏好类型',
prefer_director_ids JSON DEFAULT NULL,
level TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '活跃等级',
profile_version VARCHAR(32) DEFAULT NULL COMMENT '画像版本号',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户画像表';
这张表就是典型的 KV 型表:主键是 user_id,列里直接放加工好的结果。实时推荐服务在拿到用户ID后,用一条等值查询就能取回画像,延迟极低。favorite_genres 和 prefer_director_ids 用 JSON 存数组,是为了减少行数膨胀和避免频繁的关联查询。
需要说明的是,这张表里我只放“适合在线读取的轻量画像”。真正更复杂的统计特征,我会放到后续的日级特征宽表里,这里不重复堆积。
3. 冷启动阶段的数据从哪来:导入与清洗的“脏活”清单
3.1 公开数据源和业务数据的导入:LOAD DATA 与批量写入
智能电影推荐系统刚搭起来,大概率没有真实的用户行为数据。大部分项目会先用公开数据集起步,比如 MovieLens 的数据集,里面有用户对电影的评分、标签等;也常见通过电影资料站点的接口抓取电影基础信息,此时需要注意接口限速和使用条款。
拿到 CSV 或 JSON 之后,我一般不会直接一条条 INSERT,而是先落到一张结构一致的暂存表,再做加工。MySQL 里最常用的全量装载方式是 LOAD DATA:
sql复制LOAD DATA LOCAL INFILE '/data/movies.csv'
INTO TABLE movie_stg
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(movie_code, title, genres);
LOAD DATA 比逐条 INSERT 快一个数量级,但有一个前提:源文件字段顺序和表列要对齐,否则数据会串位。我的经验是先建一张字段顺序完全模拟 CSV 的暂存表,把原始数据原封不动地装进去,之后再通过 SQL 做类型转换和清洗,这样出了问题也能快速定位到是哪一步导致的。
临时表方案在初学阶段特别容易被忽略。很多人直接拿 CSV 往正式表里灌,灌完之后才发现评分字段有一堆空值,类型转换直接把任务中断。先入暂存表,再 INSERT INTO ... SELECT,虽然多一步,但可以随时重来,不会把正式表污染掉。
如果数据规模不大,也可以用 Python + pandas 直接批量写:
python复制import pandas as pd
from sqlalchemy import create_engine
engine = create_engine("mysql+pymysql://user:password@localhost:3306/movie_rec")
df = pd.read_csv("ml-latest-small/ratings.csv")
df.columns = ["user_id", "movie_code", "rate", "event_time"]
df.to_sql("behavior_stg", engine, if_exists="append", index=False, chunksize=1000)
注意这里的 to_sql 默认是逐条执行,加上 chunksize 后批量提交,性能会好很多。但用它做大文件导入还是不如 LOAD DATA 快,所以我更多把它用在调试和小批量的数据修复上。
3.2 写一个可重复执行的导入任务
数据导入最忌讳的是“只能跑一次”。第一次导完数据后,如果第二天业务方又给了一份增加了新片的 CSV,你总不能手动把
