简介:这是一套基于哔哩哔哩用户行为分析的系统源码,面向毕业设计或课程设计场景,采用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) ) ENGINE=InnoDB COMMENT='B站用户行为事件表';这里有几个参数值得解释。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_id | PV/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( host="127.0.0.1", user="root", password="123456", database="bilibili_behavior", charset="utf8mb4", ) 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(days=day_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], k=1, )[0] duration = random.randint(5, 600) if event_type == "view" else 0 ts = day.replace( hour=random.randint(0, 23), minute=random.randint(0, 59), second=random.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( creator=pymysql, maxconnections=10, mincached=2, maxcached=8, blocking=True, host="127.0.0.1", user="root", password="123456", database="bilibili_behavior", charset="utf8mb4", cursorclass=pymysql.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()maxconnections=10表示池中最多同时有 10 个 MySQL 连接,单机毕业设计足够;blocking=True表示连接被占满时请求排队,而不是直接报错。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(title="B站用户行为分析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_type='like')得出的是事件次数,不是人数。下面写法按用户去重:
@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_uv | start, end | 按天 PV/UV/视频数 | 活跃趋势主图 |
/api/events/distribution | start, end | 各事件类型占比 | 行为构成饼图 |
/api/funnel | start, 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,对应模板里一个带ref="chart"的 div。图表渲染后发现数据为空时,先看浏览器 Network 面板里的请求 URL,再用 MySQL 客户端执行同一条 SQL,就能判断问题在接口还是前端。
4. 用户行为分析 SQL:DAU、留存率与 RFM 分层口径
数据进了 MySQL,接口也通了,接下来看分析指标本身。行为分析系统的核心不是图表,而是指标口径。同一张表,DAU 可以写成count(distinct user_id),也可以限定 event_type='view' 才计;次留可以按自然日对齐,也可以按“首次活跃后 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 表示互动行为次数。计算 SQL:
WITH 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 执行 EXPLAIN:
EXPLAIN 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(maxsize=32) 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?start=2024-12-01&end=2024-12-30"关注 Requests per second 和失败率。如果 QPS 个位数,优先查 EXPLAIN 而不是加机器。压测后对比缓存前后的吞吐量,把数据写进说明文档。提交前按下面这张表做最后自检:
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 数据完整性 | SELECT COUNT(*), COUNT(DISTINCT user_id) FROM event_log | 两条计数非零 |
| 参数校验 | 不传 start 请求 /pv_uv | 返回 422 而不是 500 |
| 索引生效 | EXPLAIN 分析 SQL | type 至少为 range |
| 并发稳定性 | ab 1000 请求 50 并发 | 失败率 0% |
自检通过后,把三张表的结构图、EXPLAIN 前后的 rows、压测吞吐对比三样东西放进答辩文档。优化结论写成「把单列索引改成 (event_type, create_time) 组合索引后,rows 从全表降到 1/10」,比空泛描述更站得住。
本文还有配套的精品资源,点击获取