基于B站用户行为分析系统Python毕业设计实战:从表结构到性能优化
简介这是一套基于哔哩哔哩用户行为分析的系统源码面向毕业设计或课程设计场景采用Python编程语言和MySQL数据库作为主要技术栈提供完整前后端、数据库脚本和说明文档。资源共422个文件包括35个Python源文件、36个HTML页面、93个JavaScript脚本、99张图片、28个文本说明及SQL、Word文档等压缩包大小约39.54MB目录结构清晰便于二次开发。推荐环境为Python3.6.8、MySQL5.7并配合PyCharm与Navicat使用目前已有99人学习。系统内置账号信息、创作者分析、用户分析、综合分析、多维分析、排名分析等模块可对活跃度、使用偏好、粉丝互动、发布频率、观看量、点赞数、评论数等指标进行统计、关联与排序。附带说明文档和项目记录既能作为毕业设计的完整方案参考也能用于课程设计练手适合想掌握完整用户行为分析流程的初、中级开发者。1. 基于 B 站用户行为分析系统先想清数据链路再动手拿到「基于B站用户行为分析系统」这套 Python 毕业设计源码先别急着打开 MySQL 改密码。答辩时真正会卡住你的不是跑不起来而是三个追问行为数据从哪来PV、留存、漏斗是怎么算出来的数据量变大后接口为什么慢这三个问题都指向同一条链路用户在前端点下播放或点赞行为落到 MySQL 的 event_log 表Python 后端按天聚合Vue 前端把聚合结果画成图表。把这条链路拆开看完整前后端项目的答案就藏在表结构、接口和 SQL 口径里。这篇文章按常见毕业设计实现先讲 MySQL 表怎么建再给 Python 后端接口和 Vue 页面代码最后用 EXPLAIN 和压测把性能问题讲清楚。适合正在做 Python 毕业设计、前后端分离项目或者想用 MySQL 存行为日志的开发者参考。2. 用户行为分析系统的 MySQL 表结构事件表、维表与造数脚本传统做法是把每条日志放到一张大宽表里user_id、video_id、event_time、event_type 排成一列一行后面再拼上用户等级、视频分区。这个设计对几十万行数据确实查得快但毕业设计要讲“数据规范”时容易被追问如果用户等级变了历史行为也跟着变这个账怎么算所以常见做法是拆成事实表和维表形成星型模型。B站用户行为分析的主要对象是「观看、点赞、投币、收藏、转发、评论、关注」这几类动作。一次动作对应一行行为事实记录用户和视频单独放维表。这样好处有两个一是行为表只存最小必要信息写入压力小二是后续按分区或者按人群过滤时可以在 JOIN 阶段决定要不要带维表字段报表口径更清楚。2.1 事件表怎么建字段顺序、类型和索引设置事件表字段不宜贪多。通常保留这些user_id、video_id、event_type、session_id、duration_sec、device、page_url、create_time。其中 create_time 代表用户操作事件发生的时间而不是数据库写入时间这一点在数据导入实测中经常被混淆。建表 SQL 如下CREATE DATABASE IF NOT EXISTS bilibili_behavior DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE bilibili_behavior; CREATE TABLE IF NOT EXISTS event_log ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, user_id BIGINT UNSIGNED NOT NULL COMMENT 用户ID, video_id BIGINT UNSIGNED NOT NULL COMMENT 视频ID, event_type VARCHAR(32) NOT NULL COMMENT view/like/coin/favorite/share/comment/follow, session_id VARCHAR(64) NOT NULL DEFAULT COMMENT 会话ID, duration_sec INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 观看时长秒, device VARCHAR(16) NOT NULL DEFAULT pc COMMENT pc/mobile/pad, page_url VARCHAR(255) NOT NULL DEFAULT COMMENT 来源页, create_time DATETIME NOT NULL COMMENT 行为发生时间, KEY idx_user_time (user_id, create_time), KEY idx_video_time (video_id, create_time), KEY idx_event_type_time (event_type, create_time) ) ENGINEInnoDB COMMENTB站用户行为事件表;这里有几个参数值得解释。KEY idx_event_type_time (event_type, create_time)是一个组合索引靠左前缀原则能同时服务“某种事件在时间段内的统计”和“全部事件按时间分组”两类查询。session_id虽然很多入门表里不建但 count distinct session_id 可以算会话数比单纯 PV/UV 更接近真实分析需求。duration_sec对 view 事件表示播放时长非观看事件默认 0不要用 NULL否则 SUM/AVG 都要处理 NULL 传播。注意不要给 event_log 设置外键。行为表是写入主表外键会在批量插入时逐行检约束测试数据一多就明显变慢。表和表之间用 user_id、video_id 做逻辑关联即可。2.2 维度表用户维表和视频维表的字段取舍用户维表user_dim和视频维表video_dim的粒度都是“一行一个实体”。字段不用照抄 B 站真实接口按分析目标反推。比如要做新老用户对比user_dim 要有 register_date要做用户付费层级加 vip_status要做内容偏好video_dim 必须有 zone_id。表名粒度关联字段主要支撑的分析user_dim一个用户一条user_id新老用户占比、VIP 用户留存、等级分布video_dim一个视频一条video_id分区偏好、up主贡献、视频时长与播放关系event_log一个行为一条user_id video_idPV/UV、漏斗、行为序列、DAU实际项目里分析接口为了少做 JOIN会把 user_level、zone_id 临时冗余到查询结果里而不是存进 event_log。我一般这样权衡如果计算指标时维表字段参与 GROUP BY比如“按分区统计播放量”就在查询时 JOIN video_dim 后再分组如果只是展示给前端可以在接口里查一次维表构建映射字典避免每行都 JOIN。这个思路也能回答答辩里“为什么要三张表”的提问。2.3 造数脚本没有真实埋点时用 Python 模拟一个月行为B 站不会开放完整用户日志毕业设计需要自己造数。最常见做法是写一个 Python 脚本往 MySQL 灌几十万行模拟行为。造数要模拟“幂律分布”大部分行为是 view小部分是 coin 和 favorite不然 DAU 和漏斗看起来不对劲。import random from datetime import datetime, timedelta import pymysql conn pymysql.connect( host127.0.0.1, userroot, password123456, databasebilibili_behavior, charsetutf8mb4, ) cur conn.cursor() # 先灌 1000 个用户500 个视频均只保留基础字段 users list(range(1, 1001)) videos list(range(1, 501)) event_types [view, like, coin, favorite, share, comment, follow] for day_offset in range(30): day datetime.now() - timedelta(daysday_offset) rows [] for _ in range(3000): user_id random.choice(users) video_id random.choice(videos) event_type random.choices( event_types, weights[70, 12, 4, 3, 3, 5, 3], k1, )[0] duration random.randint(5, 600) if event_type view else 0 ts day.replace( hourrandom.randint(0, 23), minuterandom.randint(0, 59), secondrandom.randint(0, 59), ) rows.append((user_id, video_id, event_type, duration, ts)) cur.executemany( INSERT INTO event_log (user_id, video_id, event_type, duration_sec, create_time) VALUES (%s, %s, %s, %s, %s), rows, ) conn.commit() cur.close() conn.close() print(模拟数据生成完成)这段脚本的关键参数有两个weights[70, 12, 4, 3, 3, 5, 3]模拟行为占比view 占 70%互动行为低一些executemany批量插入先拼列表再一次 commit避免每天 3000 条数据逐行插入。生成节奏按天 commit 的好处是如果某天的数据概率分布异常可以单独 delete 那天的记录重跑。造完数后用SELECT COUNT(*) FROM event_log;确认行数同时跑一条SHOW TABLE STATUS LIKE event_log;看Data_length用于和性能测试对比。3. Python 后端接口把 MySQL 行为数据交给前端图表前后端分离的毕业设计通常前端是 Vue后端是 Flask 或 FastAPI。这里用 FastAPI 讲因为它的 OpenAPI 文档能在答辩时直接展示所有接口参数比截图更有说服力。选择它的第二个原因是 pydantic 会自动校验请求参数类型路径里的 start、end 写得不对时会返回 422而不是把错误 SQL 抛给用户。Flask 做法相似只是把路径装饰器换成app.route。本节实现的接口不追求花哨围绕行为分析系统最常用的四个能力展开PV/UV 曲线、事件分布、漏斗转化、用户特征。每个接口只做一件事前端拿到 JSON 后自己决定画折线还是饼图。3.1 用 PooledDB 管理 MySQL 连接避免参数校验前先被连接打垮直接在每个请求里pymysql.connect()能跑通但连接建立和销毁占掉的耗时经常比 SQL 本身还高。用 DBUtils 连接池把连接复用起来是更稳的做法。先写一个通用的查询模块from dbutils.pooled_db import PooledDB import pymysql pool PooledDB( creatorpymysql, maxconnections10, mincached2, maxcached8, blockingTrue, host127.0.0.1, userroot, password123456, databasebilibili_behavior, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor, ) def query(sql: str, args: tuple ()) - list: conn pool.connection() try: with conn.cursor() as cur: cur.execute(sql, args) return cur.fetchall() finally: conn.close()maxconnections10表示池中最多同时有 10 个 MySQL 连接单机毕业设计足够blockingTrue表示连接被占满时请求排队而不是直接报错。DictCursor让返回结果带着字段名后端 JSON 序列化时直接可用。这里有一个细节conn.close()并不是真的断掉连接而是把连接归还给池所以finally里必须调用否则池一旦耗尽接口卡住。3.2 PV/UV、事件分布和漏斗接口参数怎么定SQL 怎么写FastAPI 路由的代码可以拆成两层路由负责接收参数SQL 负责聚合。以 PV/UV 接口为例时间范围用半开区间最容易避免歧义。from fastapi import FastAPI, Query from db import query app FastAPI(titleB站用户行为分析API) app.get(/api/metrics/pv_uv) def pv_uv( start: str Query(..., description开始日期 2024-12-01), end: str Query(..., description结束日期 2024-12-30), ): sql SELECT DATE(create_time) AS day, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv, COUNT(DISTINCT video_id) AS video_uv FROM event_log WHERE create_time %s AND create_time %s GROUP BY DATE(create_time) ORDER BY day rows query(sql, (start 00:00:00, end 23:59:59)) return {code: 0, data: rows}这里没有用BETWEEN因为BETWEEN的右边界是闭区间必须拼 23:59:59 才能包含完整一天。用 start加 end 1这样的半开区间更稳。COUNT(DISTINCT video_id)得出有内容的视频数在答辩里可以补充解释 PV 和真实播放量的区别。事件分布接口和一小时活跃曲线同理换一个 GROUP BY 字段即可。漏斗接口要额外注意口径直接用SUM(event_typelike)得出的是事件次数不是人数。下面写法按用户去重app.get(/api/funnel) def funnel( start: str Query(..., description开始日期), end: str Query(..., description结束日期), ): sql SELECT COUNT(DISTINCT CASE WHEN event_type view THEN user_id END) AS step_view, COUNT(DISTINCT CASE WHEN event_type like THEN user_id END) AS step_like, COUNT(DISTINCT CASE WHEN event_type coin THEN user_id END) AS step_coin, COUNT(DISTINCT CASE WHEN event_type favorite THEN user_id END) AS step_favorite FROM event_log WHERE create_time %s AND create_time %s row query(sql, (start 00:00:00, end 23:59:59))[0] return {code: 0, data: row}这个漏斗每一步都是一个独立人群看过视频的人、点过赞的人、投过币的人、收藏过的人适合讲网站层面转化。如果要算“同一批用户从观看走到点赞”的路径漏斗就得先按 user_id 做行为序列再判断先后顺序一般用 Python 在接口里处理不放 SQL。下面给出一个简化的接口参数表方便答辩时对着讲接口参数返回说明/api/metrics/pv_uvstart, end按天 PV/UV/视频数活跃趋势主图/api/events/distributionstart, end各事件类型占比行为构成饼图/api/funnelstart, end各环节去重人数整体转化漏斗3.3 Vue 前端请求接口跨域代理和 ECharts 渲染Vue 侧代码不需要写得很重重点是 axios 调用和图表组件的数据绑定。先配 Vite 的开发代理否则浏览器直接请求 8000 端口会被跨域拦掉// vite.config.js export default { server: { proxy: { /api: http://127.0.0.1:8000 } } }这样前端axios.get(/api/metrics/pv_uv)会转发到 FastAPI。绘制折线图的核心代码import * as echarts from echarts import axios from axios export default { data() { return { chart: null } }, mounted() { axios.get(/api/metrics/pv_uv, { params: { start: 2024-12-01, end: 2024-12-30 } }).then(res { const data res.data.data this.chart echarts.init(this.$refs.chart) this.chart.setOption({ xAxis: { type: category, data: data.map(item item.day) }, yAxis: { type: value }, series: [ { name: PV, type: line, data: data.map(item item.pv) }, { name: UV, type: line, data: data.map(item item.uv) } ] }) }) } }这里params对象会被 axios 序列化为 query string后端 FastAPI 里定义了必填的 start/end如果前端两个参数没传后端会返回 422 而不是 500。ECharts 实例要挂在this.$refs.chart对应模板里一个带refchart的 div。图表渲染后发现数据为空时先看浏览器 Network 面板里的请求 URL再用 MySQL 客户端执行同一条 SQL就能判断问题在接口还是前端。4. 用户行为分析 SQLDAU、留存率与 RFM 分层口径数据进了 MySQL接口也通了接下来看分析指标本身。行为分析系统的核心不是图表而是指标口径。同一张表DAU 可以写成count(distinct user_id)也可以限定 event_typeview 才计次留可以按自然日对齐也可以按“首次活跃后 24 小时”对齐。毕业设计答辩最容易被追问的就是口径下面几个 SQL 是按常见产品定义写的可以直接落进接口。4.1 日活跃、周活跃和按小时活跃分布DAU 的定义是“当天至少产生一条行为的去重用户数”。这个定义要求 event_log 每一行都是真实行为而不是服务器心跳。SQL 写法如下SELECT DATE(create_time) AS day, COUNT(DISTINCT user_id) AS dau, COUNT(DISTINCT CASE WHEN duration_sec 0 THEN user_id END) AS real_view_user FROM event_log WHERE create_time 2024-12-01 00:00:00 AND create_time 2024-12-31 00:00:00 GROUP BY day ORDER BY day;CASE WHEN duration_sec 0 THEN user_id END把表里播放时长为 0 的行为排除掉得到真正有过观看行为的用户数。如果造数脚本里非 view 事件 duration 默认 0这个指标就能区分“来过的人”和“看过视频的人”。周活跃用 WEEK 函数SELECT YEAR(create_time) AS y, WEEK(create_time, 1) AS week, COUNT(DISTINCT user_id) AS wau FROM event_log GROUP BY y, week ORDER BY y, week;WEEK(create_time, 1)的第二个参数 1 表示以周一作为一周起点避免默认周日起点和产品后台口径不一致。这一行参数在答辩时值得专门讲一下因为周活跃的定义在不同团队可能差一个周末归属。4.2 次留存和 N 日留存用 LEFT JOIN 保留未回来的人留存率的分母是某个基准日的活跃用户数分子是这批用户在第 N 天还有行为的数量。常见错误是用了 INNER JOIN没回来的人直接被滤掉分母缩水。正确写法用 LEFT JOIN 配合条件聚合WITH base AS ( SELECT DISTINCT user_id FROM event_log WHERE create_time 2024-12-01 00:00:00 AND create_time 2024-12-02 00:00:00 ), retained AS ( SELECT DISTINCT user_id FROM event_log WHERE create_time 2024-12-02 00:00:00 AND create_time 2024-12-03 00:00:00 ) SELECT COUNT(DISTINCT b.user_id) AS base_users, COUNT(DISTINCT r.user_id) AS retained_users, COUNT(DISTINCT r.user_id) / COUNT(DISTINCT b.user_id) AS day1_retain_rate FROM base b LEFT JOIN retained r ON b.user_id r.user_id;baseCTE 存 12 月 1 日去重用户retainedCTE 存 12 月 2 日去重用户两个都先去重再 JOIN结果最准确。LEFT JOIN 保证 12 月 1 日活跃但 12 月 2 日没来的人仍在结果集里R 值为 NULL分子不计入分母不受影响。要做 7 日留存把第二个 CTE 的日期范围改成DATE_ADD(2024-12-01, INTERVAL 7 DAY)即可。4.3 RFM 分层把行为日志变成可解释的用户画像用户画像模块在毕业设计里常用 RFM 模型只不过 B 站没有消费金额用互动数代替 M 值更合理。R 表示最近一次行为距今的天数F 表示 30 天行为次数M 表示互动行为次数。计算 SQLWITH user_features AS ( SELECT user_id, DATEDIFF(CURDATE(), MAX(DATE(create_time))) AS R, COUNT(*) AS F, SUM(CASE WHEN event_type IN (like, coin, favorite, share) THEN 1 ELSE 0 END) AS M FROM event_log WHERE create_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY user_id ) SELECT user_id, R, F, M, CASE WHEN R 3 THEN 5 WHEN R 7 THEN 4 WHEN R 14 THEN 3 WHEN R 30 THEN 2 ELSE 1 END AS r_score, CASE WHEN F 100 THEN 5 WHEN F 50 THEN 4 WHEN F 20 THEN 3 WHEN F 5 THEN 2 ELSE 1 END AS f_score, CASE WHEN M 30 THEN 5 WHEN M 15 THEN 4 WHEN M 5 THEN 3 WHEN M 1 THEN 2 ELSE 1 END AS m_score FROM user_features;RFM 的阈值需要根据造数脚本的数据分布调整不要照抄电商的金额分箱。更好的做法是先用SELECT COUNT(*), PERCENTILE_CONT()之类语句看分位再写死到 SQL。对于强调可解释性的毕业设计可以在接口里直接把 r_score、f_score、m_score 拼成三类标签高活跃高互动、观看深度用户、沉默用户等返回给前端做人群表格。5. EXPLAIN、并发测试与答辩自检让行为分析系统站得住数据能跑只是开始数据量翻倍后交互卡顿才是答辩评审常见问题。这里给一条可直接执行的优化和验证路径顺序不要颠倒。5.1 用 EXPLAIN 定位索引失效的慢查询在 MySQL 客户端对最慢的分析 SQL 执行 EXPLAINEXPLAIN SELECT * FROM event_log WHERE event_type like AND create_time 2024-12-01 00:00:00 AND create_time 2024-12-02 00:00:00;type 字段如果显示 ALL 说明是全表扫描。常见的坑是 event_type 和 create_time 各自建了单列索引MySQL 最终只选其中一个另一个条件还是全表过滤。解决方式是建组合索引ALTER TABLE event_log ADD KEY idx_event_time (event_type, create_time);再次 EXPLAIN看到 key 变成 idx_event_time 且 rows 下降说明索引已经生效。注意组合索引的字段顺序不能写反范围条件 create_time 放最后。5.2 并发压测、缓存自检表和提交前的验证顺序直接请求同一个时间范围时可以用 lru_cache 让重复参数落在缓存里from functools import lru_cache lru_cache(maxsize32) def get_pv_uv_cached(start: str, end: str): return query( SELECT DATE(create_time) AS day, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv FROM event_log WHERE create_time %s AND create_time %s GROUP BY DATE(create_time) ORDER BY day, (start 00:00:00, end 23:59:59), )再运行压测命令ab -n 1000 -c 50 http://127.0.0.1:8000/api/metrics/pv_uv?start2024-12-01end2024-12-30关注 Requests per second 和失败率。如果 QPS 个位数优先查 EXPLAIN 而不是加机器。压测后对比缓存前后的吞吐量把数据写进说明文档。提交前按下面这张表做最后自检检查项操作通过标准数据完整性SELECT COUNT(*), COUNT(DISTINCT user_id) FROM event_log两条计数非零参数校验不传 start 请求 /pv_uv返回 422 而不是 500索引生效EXPLAIN 分析 SQLtype 至少为 range并发稳定性ab 1000 请求 50 并发失败率 0%自检通过后把三张表的结构图、EXPLAIN 前后的 rows、压测吞吐对比三样东西放进答辩文档。优化结论写成「把单列索引改成 (event_type, create_time) 组合索引后rows 从全表降到 1/10」比空泛描述更站得住。本文还有配套的精品资源点击获取