ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django+MySQL图片推荐系统:MTV、ORM、召回排序与索引调优

Django+MySQL图片推荐系统:MTV、ORM、召回排序与索引调优 简介基于Django框架与MySQL数据库的图片推荐系统设计文档面向计算机相关专业的本科生、研究生及Web开发入门者可作为毕业设计或课程设计的选题参考与实现范本。文档围绕图片推荐这一典型场景分析了传统图片管理模式在信息共享与推荐效率上的不足并给出从需求分析到系统落地的完整设计思路。压缩包内仅含1个docx文件体积约3.75MB以论文式章节组织涵盖需求分析、功能模块划分、数据库表结构设计、核心代码实现、测试与维护等环节其中用户管理、图片信息管理与推荐展示模块均有对应说明。目前已有52人学习说明该选题在毕业设计群体中具有一定参考价值。读者可据此了解B/S架构下Python、Django与MySQL的协作方式借鉴其目录结构与模块划分方法用于搭建推荐系统或完成论文撰写。1. 图片推荐系统为什么落在 Django MySQL 这套组合上先摆一个具体场景站上有一批图片用户能浏览、点赞、收藏运营希望首页不再按上传时间倒序而是给每个人一份不一样的列表——这就是图片推荐系统要解决的问题。Django 提供 MTV 分层、ORM、自带后台和权限体系图片的上传、审核、打标签、运营位都能在 Admin 里管起来不用另外写一套管理端MySQL 用来看住用户、图片、标签和行为流水事务能力和索引能力撑住十万级图片、百万级行为的中小规模完全够用。反直觉的一点是第一版别急着上向量数据库或专门的召回引擎先把「行为采集 → 特征落库 → 候选召回 → 排序 → 曝光回写」这条闭环跑通模型后面再换也不迟。这套路径适合已经有 Python 基础、希望把推荐做成一个能上线的 Web 功能、而不是停在离线实验里的团队。2. 用 Django 的 MTV 搭出图片推荐系统的最小骨架2.1 django-admin startproject 到 startappMTV 各层在推荐链路里的分工Django 的 MTV 和常见的 MVC 只是命名差异但在推荐类项目里这个分层直接决定代码能不能复用。Model 负责图片、标签、行为这三类数据的结构和关系View 负责把请求里的用户、分页游标、场景参数解析出来Template 或者 DRF 的 Serializer 负责把结果渲染出去。真正的推荐计算既不写在 View 里也不写在 Model 里我一般单独建一个services/recommend.py让在线接口和离线刷分脚本调同一份函数后面换召回策略时视图层一行都不用动。# 依赖装齐驱动、图片处理、向量计算 pip install django mysqlclient Pillow numpy django-admin startproject imgsite cd imgsite python manage.py startapp images # 业务域图片、标签、行为 python manage.py startapp recsys # 推荐域召回、排序、评估django-admin startproject生成的是项目骨架管配置和总路由startapp生成的才是按业务切的模块。推荐逻辑单独拆一个 app好处是它只依赖 ORM 和 numpy不依赖任何视图离线脚本python -m recsys.jobs.refresh_hot可以照常跑。层文件位置在图片推荐系统里的职责Modelimages/models.py图片元数据、标签关系、用户行为流水Viewimages/views.py解析用户、页码、场景参数调用推荐服务Templatetemplates/ 或 Serializer列表渲染、字段裁剪、埋点字段下发Servicerecsys/services.py召回、相似度计算、排序、兜底URLimgsite/urls.py推荐流、详情页、行为上报路由2.2 三个核心模型怎么写Image、Tag、UserAction数据模型是推荐系统的地基字段设计错的代价比算法选错大得多。图片表要同时承载「内容特征」和「统计特征」行为表要能支撑按用户、按时间两个方向的高频查询标签表则是冷启动阶段唯一的语义信号。# images/models.py from django.db import models class Tag(models.Model): name models.CharField(max_length32, uniqueTrue) def __str__(self): return self.name class Image(models.Model): title models.CharField(max_length200) file models.ImageField(upload_toimages/%Y/%m/) tags models.ManyToManyField(Tag, related_nameimages, blankTrue) phash models.CharField(max_length32, db_indexTrue) # 感知哈希用于快速去重 embedding models.JSONField(nullTrue, blankTrue) # 深度特征1024 维左右 hot_score models.FloatField(default0.0, db_indexTrue) # 热度分离线刷新 created_at models.DateTimeField(auto_now_addTrue) class Meta: indexes [ # 联合索引顺序和 ORDER BY 保持一致避免额外排序 models.Index(fields[-hot_score, -created_at], nameidx_hot_created), ] class UserAction(models.Model): ACTION [(view, view), (like, like), (collect, collect)] user models.ForeignKey(auth.User, on_deletemodels.CASCADE) image models.ForeignKey(Image, on_deletemodels.CASCADE) action models.CharField(max_length10, choicesACTION) weight models.SmallIntegerField(default1) # view1, like3, collect5 created_at models.DateTimeField(auto_now_addTrue) class Meta: indexes [models.Index(fields[user, -created_at], nameidx_user_time)] unique_together [(user, image, action)]几个参数值得单独说。db_indexTrue用在phash上是给去重和近邻候选用的感知哈希只有 16 位十六进制等值查询极快hot_score建索引是为了让「热门兜底」这条 SQL 走索引扫描而不是全表扫。行为表上的联合索引(user, -created_at)顺序不能反查询永远是「某个用户最近的行为」把 user 放最左才符合最左前缀原则。unique_together防的是同一个用户反复点赞同一条数据导致分数虚高写法上是幂等上报的基础。ImageField需要 Pillow并且要在 settings 里配好MEDIA_ROOT和MEDIA_URL。2.3 把 MySQL 接上mysql安装配置的关键几步与 settings 参数本地起 MySQL 最省事的路径是容器一条命令把字符集也定死。字符集一定要用utf8mb4图片标题里的 emoji 和部分中文生僻字用utf8会直接报错或截断。# 起一个本地 MySQL 8.0端口、字符集一次配好 docker run -d --name rec-mysql \ -e MYSQL_ROOT_PASSWORDStrongPass123 \ -p 3306:3306 \ mysql:8.0 \ --character-set-serverutf8mb4 \ --collation-serverutf8mb4_unicode_ci-- 建库同样指定字符集避免继承服务端默认 CREATE DATABASE imgsite DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER rec% IDENTIFIED BY RecPass123; GRANT ALL PRIVILEGES ON imgsite.* TO rec%; FLUSH PRIVILEGES;如果用系统包安装Ubuntu 上apt install mysql-server之后要跑一次mysql_secure_installation设置 root 密码和移除匿名用户Windows 上官网安装包一路下一步注意勾选「Use Legacy Authentication」还是默认的caching_sha2_password——Django 用 mysqlclient 连接时后者也能用但旧版驱动会报认证插件不支持。装完先mysql -u rec -p -e select version();确认能连上再动 Django 配置。# imgsite/settings.py DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: imgsite, USER: rec, PASSWORD: RecPass123, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, init_command: SET sql_modeSTRICT_TRANS_TABLES, }, CONN_MAX_AGE: 60, # 持久连接避免每个请求都握手 } } MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media配置里容易踩的是HOST写成localhostMySQL 客户端在 Linux 上会把localhost解释成 socket 连接容器里的 MySQL 没有挂载 socket 文件结果报Cant connect to local MySQL server through socket。统一写127.0.0.1走 TCP 就不会有这个问题。另一个高频报错是版本门槛较新的 Django 版本会抬高对 MySQL 的最低要求接 MySQL 8.0 时可能直接抛django.db.utils.NotSupportedError: MySQL 8.4 or later is required (found 8.0)。这时候只有两条路要么把服务端升到要求版本要么把 Django 锁定在仍然接受 8.0 的版本区间别去改驱动绕过检查后面会有兼容坑。配好后python manage.py makemigrations python manage.py migrate再createsuperuser建个后台账号图片上传和标签维护就能先用 Admin 顶着了。3. 图片特征怎么存进 MySQL又怎么按相似度排序3.1 特征向量落库JSON 字段还是拆列存特征怎么存直接决定后面召回能不能做快。1024 维的浮点向量拆成 1024 个列是灾难MySQL 单表列数上限和行宽都扛不住用BLOB存 float32 二进制体积最小但没法在 SQL 里读JSONField在 MySQL 5.7.8 之后是原生 JSON 类型可读性好运维排查时SELECT embedding-$[0]就能看代价是文本解析比二进制慢。几条路线的取舍可以对着下表选。存法单条体积1024 维可读性适用阶段JSONField约 20 KB 文本高SQL 可直接查起步阶段量级十万以内BLOBfloat324 KB 二进制低需代码解析向量规模上来后独立列少量维度视列数高只用 pHash 这类低维特征我一般这么分phash这种 64 位特征直接建列加索引走等值或前缀匹配深度特征先用JSONField等图片量过十万、单次召回延迟超过 200ms再迁到 BLOB 或干脆引入专门的向量检索组件。这个迁移时机比一开始就上重型方案务实得多。3.2 用 pHash 和余弦相似度算近邻候选集裁剪比算法本身重要相似图片推荐的第一步不是算相似度是把候选集从几十万压到几千。做法是先用标签和时间窗做一次粗筛再在 Python 里算余弦相似度——这是因为 MySQL 里没有原生的向量运算函数硬算只能靠存储过程逐行解析 JSON性能会崩。# recsys/services.py import numpy as np from images.models import Image def cosine_topk(target_vec, candidates, k20): 候选集内算余弦相似度返回 top-k 的 (image_id, score) mat np.asarray([c.embedding for c in candidates], dtypenp.float32) if mat.size 0: return [] target np.asarray(target_vec, dtypenp.float32) # 先归一化点积就等于余弦相似度省掉逐行除法 mat_norm mat / (np.linalg.norm(mat, axis1, keepdimsTrue) 1e-8) t_norm target / (np.linalg.norm(target) 1e-8) sims mat_norm t_norm order np.argsort(-sims)[:k] return [(candidates[i].id, float(sims[i])) for i in order] def similar_images(image_id, k20): seed Image.objects.get(idimage_id) # 粗筛同标签 近 90 天把候选控制在几千条 candidates list( Image.objects.filter(tags__inseed.tags.all()) .exclude(idimage_id) .only(id, embedding)[:3000] ) return cosine_topk(seed.embedding, candidates, k)only(id, embedding)是必须的不加会把 title、file 这些大字段一起捞出来候选三千条时内存和网络开销翻好几倍。 1e-8是防止零向量导致除零。np.argsort(-sims)里取负号是为了拿到降序比先排序再反转少一次拷贝。真正的工程瓶颈在粗筛阶段标签命中太少时可以把时间窗放宽到 180 天或者混入一小批热门图保证候选集不为空。3.3 MySQL 侧的排序与索引ORDER BY 别踩 Using filesort在线接口最终下发的是「推荐流」它和相似度计算是两条路一条走内容相似一条走热度加权的兜底序列。兜底序列完全由 SQL 决定索引没建对翻到第 20 页时延迟会明显抖。-- 按热度倒序取索引列顺序与 ORDER BY 完全一致 CREATE INDEX idx_hot_created ON images_image (hot_score DESC, created_at DESC); -- 验证执行计划 EXPLAIN SELECT id, title, hot_score FROM images_image WHERE hot_score 0 ORDER BY hot_score DESC, created_at DESC LIMIT 20 OFFSET 0;查看EXPLAIN的几列有个固定关注点。列期望值说明typerange 或 index出现 ALL 说明全表扫描索引没用上keyidx_hot_created实际选中的索引rows接近 LIMIT 量级预估扫描行数远大于 20 就有问题Extra不含 Using filesort出现即走了内存/磁盘排序MySQL 8.0 才真正支持降序索引5.7 虽然接受DESC语法但会忽略靠反向扫描也能用只是联合列上有升有降时会退化成 filesort。另一个坑是ORDER BY hot_score DESC, created_at DESC和索引(hot_score, created_at)顺序不一致加id做稳定排序时更容易触发 filesort深分页场景建议换成游标WHERE hot_score :last_score OR (hot_score :last_score AND id :last_id)。3.4 用 UPDATE 回流热度分一条 SQL 把近 7 天行为算进权重热度分不实时算靠定时任务刷。MySQL 的多表 UPDATE 配合子查询能一次算完不用把数据拉到应用层再写回去。-- 近 7 天加权行为分衰减叠加到热度分上 UPDATE images_image i JOIN ( SELECT image_id, SUM(weight) AS delta FROM images_useraction WHERE created_at NOW() - INTERVAL 7 DAY GROUP BY image_id ) t ON t.image_id i.id SET i.hot_score i.hot_score * 0.9 t.delta;0.9是衰减系数让老图自然降温SUM(weight)用的是行为表里的权重列view 记 1、like 记 3、collect 记 5。子查询必须先聚合再 JOIN不然同一张图会被更新多次结果取决于执行顺序不可控。Django 侧做批量衰减可以用表达式更新避免逐条 savefrom django.db.models import F from images.models import Image # 一次 UPDATE 搞定批量衰减不触发 save() 和信号 Image.objects.filter(id__instale_ids).update(hot_scoreF(hot_score) * 0.9).update()不会调用save()也不会触发post_save信号性能和逐条保存差一个数量级代价是auto_now字段不会自动更新需要手动带上。4. Django 查询集与 MySQL 联调召回、分页与部署排错4.1 冷启动召回ORM 写权重排序新用户也能有列表新用户没有任何行为纯协同过滤给不出结果兜底必须做在召回层。常见做法是按标签命中数加权热度写成一条 ORM 查询。from django.db.models import F, Count, Q, Case, When, IntegerField from images.models import Image, UserAction def recommend_for(user, k20): if not user.is_authenticated: return Image.objects.filter(hot_score__gt0).order_by(-hot_score, -created_at)[:k] recent list( UserAction.objects.filter(useruser) .order_by(-created_at) .values_list(image_id, flatTrue)[:50] ) if not recent: return Image.objects.filter(hot_score__gt0).order_by(-hot_score, -created_at)[:k] tag_ids Image.objects.filter(id__inrecent).values_list(tags__id, flatTrue) return ( Image.objects.filter(tags__id__intag_ids) .exclude(id__inrecent) .annotate(tag_hitCount(tags, filterQ(tags__id__intag_ids))) .order_by(-tag_hit, -hot_score) .distinct()[:k] )Count带回filter做条件计数命中标签越多排得越前-hot_score是第二排序键用来打破同分。.distinct()不能省一个图片挂多个命中标签时会被 JOIN 出多行不去重会下发重复图。recent取值用values_list(flatTrue)只取 id比取整个对象省内存。这套召回不需要任何模型训练上线当天就能有可用的推荐流后面要加协同过滤也只需要在召回层并联一路再合并。4.2 分页与 N1select_related 和游标分页的实际写法列表页最容易出的两个问题一个是 N1 查询一个是深分页。前者一页 20 条图可能打出 40 多条 SQL后者用OFFSET 10000时要先扫过一万行再丢掉。# 反例每条图的标签和外键都单独查一次 for img in qs: print(img.tags.all()) # 每次循环一条 SQL # 正例一次预取外键用 select_related多对多用 prefetch_related qs ( Image.objects.select_related(uploader) .prefetch_related(tags) .order_by(-hot_score, -id)[:20] )select_related走 SQL JOIN适合 ForeignKey 和 OneToOneprefetch_related走第二次 IN 查询在 Python 里拼装适合 ManyToMany 和反查两者不能互相替代。标签数量少时prefetch_related更稳因为 JOIN 会让结果集行数膨胀。游标分页则把OFFSET换成一个「上次看到哪儿」的锚点def page_by_cursor(last_scoreNone, last_idNone, size20): qs Image.objects.order_by(-hot_score, -id) if last_score is not None: # 复合游标条件保证同分图片不会重复或漏掉 qs qs.filter(Q(hot_score__ltlast_score) | Q(hot_scorelast_score, id__ltlast_id)) return list(qs[:size])游标的两个字段必须和ORDER BY完全一致hot_score有重复值时只靠它会漏数据所以带上id做二次比较。QuerySet加切片后返回的是列表不会再多打一次 COUNT 查询接口延迟能稳定住。4.3 连接与部署CONN_MAX_AGE、waitress nginx 该怎么配Django 默认每个请求结束就关连接QPS 一上来握手开销很可观。CONN_MAX_AGE设成 60 秒让连接复用注意这个值要小于 MySQL 的wait_timeout默认 28800 秒否则会用到已经被服务端关掉的连接报MySQL server has gone away。线程数提高后单进程持久连接会不够用社区里有django-db-connection-pool这类连接池方案但更简单的做法是控制进程数让「进程数 × CONN_MAX_AGE 连接数」落在max_connections之内。Windows 或中小规模部署常用 waitress 加 nginx命令很直接pip install waitress python manage.py collectstatic --noinput waitress-serve --listen0.0.0.0:8000 --threads8 imgsite.wsgi:applicationserver { listen 80; location /media/ { alias /srv/imgsite/media/; } # 图片交给 nginx 直接发 location /static/ { alias /srv/imgsite/staticfiles/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }--threads8是每进程线程数numpy 做相似度计算时会释放部分 GIL线程不是越多越好超过 CPU 核数两倍换来的只有上下文切换。/media/必须由 nginx 直出图片走 Django 转发一次带宽和响应时间都会翻倍。4.4 慢查询定位Django 侧和 MySQL 侧各看一眼推荐接口变慢时先分清是 SQL 慢还是 Python 算得慢。Django 侧在 shell 里打印本次查询耗时MySQL 侧打开慢查询日志找出具体语句。from django.db import connection, reset_queries from django.conf import settings settings.DEBUG True reset_queries() recommend_for(user, k20) for q in connection.queries: print(q[time], q[sql][:120])SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0.2; -- 超过 200ms 记录 SHOW VARIABLES LIKE slow_query_log_file;connection.queries只在 DEBUG 为真时收集线上别开会吃掉内存。慢查询日志按 sql 字段能直接看出是ORDER BY缺索引还是LIKE %关键词%这类无法走索引的写法。定位到具体 SQL 后回到EXPLAIN看看rows和Extra是否和预期一致绝大多数推荐接口的延迟问题都出在候选集没有裁剪、或者排序字段和索引顺序对不上这两处。5. 推荐效果的离线验证与上线前必调的参数5.1 离线评估HitRateK 和覆盖率一起看只盯着点击率容易被热门图绑架离线阶段至少要算两个指标HitRateK 看「用户下一个真正互动的图有没有出现在推荐的前 K 条里」覆盖率看「推荐结果里有多少比例的图被推出去过」。做法是把行为按时间切分前 80% 做种子后 20% 做验证集。def hit_rate_at_k(split_actions, topk20, k20): hits 0 for user_id, actions in split_actions.items(): seed actions[:int(len(actions) * 0.8)] truth {a.image_id for a in actions[int(len(actions) * 0.8):]} recs recommend_for_user_id(user_id, kk, seedseed) if truth {r for r in recs}: hits 1 return hits / len(split_actions)topk是候选集大小k是最终下发条数两个值要分开调候选扩到 2000 时 HitRate 通常还有提升但延迟也线性上涨一般卡在候选 3000、下发 20 这个位置。覆盖率低于 30% 说明召回太集中在头部把hot_score的权重降一点、标签召回的权重提一点通常能改善。5.2 上线前值得花时间调的参数参数建议起点调大/调小的后果候选集大小3000调大 HitRate 微涨延迟线性涨下发条数 K20调大曝光多点击率分母变大热度衰减系数0.9调小老图掉得更快新图机会多行为权重like/collect3 / 5调高会让少数深度用户主导排序推荐结果缓存时长300 秒调长省算力用户行为反馈变迟钝缓存是延迟的救命稻草把user_id和召回结果按 5 分钟写进 Django 的 cache 后端命中时直接返回未命中再走召回。要注意的是行为上报后应主动删掉该用户的缓存键否则用户刚点赞完刷新页面看到的还是老列表反馈链路的体感会差很多。上线第一周建议把每次曝光的推荐来源相似、标签、热门一并落库排查「为什么不推我喜欢的图」时这几个字段比任何日志都有用。本文还有配套的精品资源点击获取
返回列表