在实际的社区类产品里,“动态太多导致发布视频加载慢”是一个很典型的性能问题。很多团队收到用户反馈后,第一反应是清理历史动态,甚至有人会开玩笑说“朋友,请删掉一些动态吧”。但真正的问题是:为什么动态数据量变大之后,不只是列表加载变慢,连发布视频操作也会变慢?这两条链路之间存在哪些共享资源?如果只清数据而不优化链路,过一段时间问题还会再次出现。
这篇文章以一个常见的动态社区为例,从“发布视频”和“加载动态”两条链路出发,梳理索引、缓存、异步化、对象存储、数据归档等优化手段,最终目标是让系统在动态数据持续增长时,依然能保持较快的发布和加载体验。文章会提供建表 SQL、慢查询排查命令、Spring Boot 异步示例、对象存储接入示例,以及一份可以直接用于上线前检查的清单。
1. 先把“发布视频加载慢”拆成两条链路
“发布视频加载慢”这句话其实包含了两个方向:一个是用户点击发布后等待时间过长,另一个是动态列表里视频加载变慢。它们看起来是一个问题,但在数据库、存储、网络层面会互相影响。把两条链路拆开,才能知道优化应该做在哪一层。
1.1 发布视频的完整时序
发布视频不是“上传一个文件,插入一条记录”这么简单。从用户点击“发布”到其他用户看到动态,中间通常包括以下步骤:
- 用户选择本地视频,客户端检查视频格式、大小、时长。
- 客户端将视频上传到后端或对象存储。如果直传对象存储,后端只负责签发上传凭证。
- 后端接收上传完成回调,校验视频是否可访问,获取视频元信息。
- 后端生成封面图、缩略图,并触发视频转码任务。
- 如果转码和截帧放在同步链路里,用户会一直等待;如果异步处理,则先返回“发布中”状态。
- 后端向动态主表插入一条新动态记录,向视频资源表写入文件的存储路径。
- 后端刷新相关用户的时间线缓存。
- 前端收到成功响应,发布完成。
用户感受到的“加载慢”,可能卡在上述任意一步。比如上传占满了带宽,后端 ffmpeg 同步转码,或者插入动态表时因为索引维护太多而变慢。
1.2 动态列表加载的完整时序
用户进入动态流页面时,前端会发起列表请求。后端的处理链路通常包括:
- 校验登录状态,确认用户身份。
- 查询当前用户可看的动态范围,例如好友动态或关注人动态。
- 根据用户 ID 查询动态主表,按创建时间倒序分页。
- 查询每条动态对应的用户昵称、头像、图片、视频资源。
- 拼接视频地址、封面地址、作者信息。
- 返回 JSON 给前端渲染。
这个流程如果没有缓存,每个请求都会实时查询 MySQL。动态表达到百万行后,一个缺少有效索引的分页查询就可能把数据库 CPU 打高,接口响应时间从几十毫秒变成几秒。
1.3 两条链路在哪里互相影响
两条链路表面上是独立的,但共享三类底层资源:
- 数据库连接池
- 数据库 CPU 和磁盘 IO
- 应用服务器带宽、对象存储和 CDN 带宽
当列表接口产生大量慢查询时,数据库连接会被占满,发布接口在获取连接时会等待,导致用户点击“发布”后迟迟没有响应。动态主表上的冗余索引越多,列表查询可能稍快一些,但每次发布插入都要维护所有二级索引,插入速度会明显下降。这就是“动态太多导致发布视频慢”的隐藏原因之一:优化没有从整条链路去考虑。
| 链路 | 主要操作 | 数据量增长后的典型表现 |
|---|---|---|
| 发布视频 | 上传、转码、写动态表、刷新缓存 | 上传慢、写库慢、超时 |
| 加载动态 | 查询动态、关联用户和资源、分页 | 列表响应慢、数据库连接紧张 |
2. 先确认瓶颈:不要急着删数据,先把问题量化
收到用户反馈后,最忌讳直接删除数据或盲目加缓存。应该先用监控工具和日志确认瓶颈到底在哪里,再决定优化方案。
2.1 从数据库连接池开始检查
如果发布和列表接口都变慢,首先要看数据库连接是否已经耗尽。进入 MySQL 命令行执行:
SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW GLOBAL STATUS LIKE 'Max_used_connections'; SHOW FULL PROCESSLIST;重点看两点:
Threads_connected是否接近max_connections。如果接近,说明连接池已经饱和。SHOW FULL PROCESSLIST中是否大量线程处于Query状态,并且执行的 SQL 都是查询t_post表的语句。
如果大量查询集中在同一张动态表,并且执行时间超过 1 秒,基本可以判断是动态列表查询拖垮了数据库,进而影响发布接口获取连接。
2.2 用慢查询日志定位耗时的 SQL
临时打开慢查询日志:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;注意:这个方式只适合在测试环境快速验证。生产环境应该把参数写入 MySQL 配置文件,避免重启失效。
开启后观察日志,典型慢查询如下:
# Query_time: 3.800000 Lock_time: 0.000000 Rows_sent: 20 Rows_examined: 500000 SELECT id, user_id, content, type, status, created_at FROM t_post WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20;关键是Rows_examined: 500000,说明查询扫描了 50 万行。继续用执行计划确认:
EXPLAIN SELECT id, user_id, content, type, status, created_at FROM t_post WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20;如果type列是ALL,说明发生了全表扫描;如果Extra列出现Using filesort,说明排序字段没有和索引匹配。这些都是动态列表变慢的直接原因。
2.3 最小数据模型示例
为了后续说明索引和归档方案,这里给出一个最小数据模型。实际业务可能还有社区 ID、好友关系、话题标签等字段,但核心结构是相通的。
CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `nickname` varchar(64) NOT NULL, `avatar_url` varchar(255) DEFAULT NULL, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_post` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `content` text, `type` tinyint NOT NULL DEFAULT '1' COMMENT '1 图文 2 视频', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0 发布中 1 成功 2 失败', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id_created_at` (`user_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_video` ( `id` bigint NOT NULL AUTO_INCREMENT, `post_id` bigint NOT NULL, `object_key` varchar(255) NOT NULL COMMENT '对象存储 key', `cover_key` varchar(255) DEFAULT NULL, `duration` int DEFAULT NULL COMMENT '时长,单位秒', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_post_id` (`post_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;t_post上的idx_user_id_created_at复合索引,是为了支持“查询某个用户的动态并按创建时间倒序”这个核心场景。t_video表通过post_id关联动态主表。
2.4 常见瓶颈速查表
在开始优化前,可以先对照下面的速查表判断大致方向:
| 问题现象 | 可能原因 | 排查方式 | 解决方向 |
|---|---|---|---|
| 点击发布后一直转圈 | 上传走应用服务器、同步转码、数据库连接池满 | 看应用日志、上传耗时、连接池监控 | 对象存储直传、异步转码、优化连接池 |
| 视频上传完成但发布不成功 | 后端同步转码、事务范围过大 | 观察接口响应时间、事务日志 | 引入消息队列,快速返回“发布中” |
| 动态列表加载越来越慢 | 全表扫描、深分页、N+1 查询 | 慢查询日志、EXPLAIN | 加索引、游标分页、批量查询 |
| 数据库 CPU 高 | 大量慢查询、缓存击穿 | 监控 CPU、processlist | 优化 SQL、加缓存、限流 |
| 发布成功后别人看不到 | 缓存未更新、数据异步任务失败 | 查缓存、查数据库记录 | 手动刷新缓存、查看消费者日志 |
3. 优化方向一:索引优化,既要查得快也不能让写入变慢
索引是动态列表查询最直接的优化手段,但索引不是越多越好。动态表既要支持查询,又要支持发布写入,索引设计必须平衡。
3.1 先理解 InnoDB 索引如何影响列表查询和写入
InnoDB 的主键索引是聚簇索引,叶子节点存放整行数据。二级索引的叶子节点存放的是主键值。查询时,如果使用二级索引,通常会先找到主键,再回表拿完整行数据。
如果动态表没有合适索引,WHERE user_id = ? ORDER BY created_at DESC就会全表扫描。有了(user_id, created_at)复合索引,MySQL 可以先根据user_id快速定位用户,再按created_at顺序读取,这样既过滤了用户,又避免了额外的排序操作。
3.2 动态流查询的索引设计
针对“查看某个用户的动态列表”场景,建议建立复合索引:
ALTER TABLE t_post ADD INDEX idx_user_created (user_id, created_at DESC);MySQL 8.0 支持降序索引,如果使用旧版本,也可以先按升序建索引,再在 SQL 层处理顺序。重要的是让过滤字段和排序字段同时出现在索引中。
执行计划检查示例:
EXPLAIN SELECT id, user_id, content, type, status, created_at FROM t_post WHERE user_id = 123 ORDER BY created_at DESC, id DESC LIMIT 20;如果索引生效,type列应该是ref或range,Extra列不应出现Using filesort。
3.3 发布写入为什么会被索引拖慢
插入一条动态记录时,InnoDB 不仅要写主键索引,还要写所有二级索引。如果t_post表上为每个列都建了索引,比如content前缀索引、type单独索引、status单独索引,那么每次发布插入都要额外写多棵 B+ 树。数据量大之后,这种写入成本会被放大。
检查表上的索引:
SHOW INDEX FROM t_post;删除无用索引:
ALTER TABLE t_post DROP INDEX idx_status;这里要强调:索引设计不是一劳永逸,而是在业务查询稳定后,根据EXPLAIN结果和慢查询日志反复调优。不要为了“看起来环境里每个查询都有索引”而把所有列都加上索引,那样会让发布写入变慢。
3.4 索引失效的常见写法
即使建了索引,错误写法也会让索引失效。下面列出动态场景里容易出现的几种:
- 对索引列使用函数,例如
WHERE YEAR(created_at) = 2025,应改为created_at >= '2025-01-01' AND created_at < '2026-01-01'。 - 隐式类型转换,例如
user_id是bigint,但查询写成WHERE user_id = '123',可能导致索引无法高效使用。 - 前缀模糊查询,例如
WHERE content LIKE '%关键词%',普通索引无法加速,需要考虑全文索引或搜索引擎。 - 使用
OR连接多个范围条件时,优化器可能放弃索引,可以拆成多条 SQL 或使用UNION合并结果。
每次发现列表变慢,都应该先看执行计划,确认索引是否真的被使用,再决定下一步做什么。
4. 优化方向二:把发布视频从同步流程改成异步流程
发布视频慢的另一个常见根因是同步流程太长。用户点击发布后,后端既要做上传、又要做 ffmpeg 转码、截封面、写库、刷新缓存,整个链路串行执行,用户只能一直等待。
4.1 为什么同步发布会让用户感觉“加载慢”
同步发布链路耗时等于上传耗时加上转码耗时加上写库耗时。视频转码和封面截取是 CPU 密集型操作,一段 30 秒的视频转码可能耗时几秒甚至更久。如果同时有多个用户发布,应用服务器线程被大量占用,最终会表现为发布接口响应越来越慢。
核心思路是:把必须同步做的事情压缩到最小,例如参数校验、文件路径生成、插入“发布中”状态;把耗时任务交给消息队列和后台消费者异步处理。
4.2 引入消息队列后的最小发布时序
引入 RabbitMQ 后,发布接口只负责两件事:写入status = 0的动态记录,发送一条异步消息,然后立即返回。
Spring Boot 项目引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>配置连接信息:
spring: rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest发送消息:
@Service public class PublishService { private final RabbitTemplate rabbitTemplate; public PublishService(RabbitTemplate rabbitTemplate) { this.rabbitTemplate = rabbitTemplate; } public Long publishVideo(VideoPublishRequest request) { // 1. 校验视频元信息 // 2. 插入动态记录,status = 0 Post post = new Post(); post.setUserId(request.getUserId()); post.setType(2); post.setStatus(0); postDao.insert(post); // 3. 发送异步消息 VideoPublishMessage message = new VideoPublishMessage(); message.setPostId(post.getId()); message.setVideoKey(request.getVideoKey()); rabbitTemplate.convertAndSend("video.exchange", "video.publish", message); return post.getId(); } }消费异步任务:
@Component public class VideoPublishConsumer { @RabbitListener(queues = "video.publish.queue") public void onMessage(VideoPublishMessage message) { // 1. 获取视频元信息 // 2. 截图封面、转码 // 3. 写入 t_video // 4. 更新 t_post.status = 1 // 5. 刷新动态缓存 } }示例省略了异常处理和重试机制。生产环境必须配置消息确认、消费失败重试、死信队列,否则消息丢失会导致动态永远停留在“发布中”。
4.3 状态机与前端反馈
异步发布后,前端不会立刻拿到最终结果,因此需要状态字段配合。可以用数字表示:
0:发布中1:发布成功2:发布失败
前端在拿到动态 ID 后,可以先展示“处理中”的占位卡片,再轮询状态接口:
GET /v1/posts/{postId}/status响应:
{ "postId": 123, "status": 0 }轮询间隔建议在 3 到 5 秒,避免请求太频繁。生产环境也可以使用 WebSocket 或 Server-Sent Events,把状态变更主动推送给客户端。
4.4 消息队列积压时怎么排查
异步化之后,可能出现的瓶颈会转移到消息队列。查看 RabbitMQ 队列积压情况:
rabbitmqctl list_queues name messages messages_ready messages_unacknowledged如果messages_ready持续上涨,说明消费者处理速度跟不上。常见原因有:
- 消费者实例数量不够。
- 转码服务本身成为瓶颈。
- 消费逻辑抛异常,消息被不断重试。
prefetch设置过小,消费者同时处理的消息太少。
排查时先看消费者日志,确认是资源不足还是代码异常,再决定扩容消费者、优化转码流程,还是修复重试逻辑。
5. 优化方向三:动态列表缓存与分页改造
发布链路优化完成后,下一个重点是动态列表加载。动态列表是高频读接口,不能每次都实时查询数据库并执行大量关联。
5.1 列表接口常见的 N+1 查询问题
很多动态列表慢的根因不是单条 SQL 慢,而是循环查询造成 N+1 次数据库请求。
错误写法示例:
List<Post> posts = postDao.selectPage(...); for (Post post : posts) { User user = userDao.selectById(post.getUserId()); // N 次查询 Video video = videoDao.selectByPostId(post.getId()); // N 次查询 }每页 20 条动态,至少会产生 40 次额外查询。优化方式是批量查询:
List<Post> posts = postDao.selectPage(...); List<Long> userIds = posts.stream() .map(Post::getUserId) .distinct() .toList(); Map<Long, User> userMap = userDao.selectByIds(userIds); List<Long> postIds = posts.stream() .map(Post::getId) .toList(); Map<Long, Video> videoMap = videoDao.selectByPostIds(postIds);先用一条 SQL 查出所有需要的用户和视频,再在内存中组装结果。IN查询的数量如果很大,需要分批查询,避免超过数据库参数限制。
5.2 用 Redis 缓存动态 ID 列表
动态列表的缓存策略不建议直接缓存完整对象,因为动态内容变化后缓存更新非常麻烦。更稳妥的方式是只缓存动态 ID 列表,再根据 ID 批量查询详情。
Redis Key 设计:
feed:user:{userId}写入流程:
List<Long> postIds = redisTemplate.opsForList().range("feed:user:" + userId, 0, -1); if (postIds == null || postIds.isEmpty()) { postIds = postDao.selectLatestIds(userId, 500); redisTemplate.opsForList().rightPushAll("feed:user:" + userId, postIds); redisTemplate.expire("feed:user:" + userId, Duration.ofMinutes(5)); }发布新动态后,最简单有效的做法是直接删除该用户的缓存 Key,让下一次请求重新加载。不要尝试在并发环境下同时修改列表和新增数据,容易产生短时间不一致。
这种缓存策略适合读多写少的动态流场景。缓存过期时间不要太长,5 分钟到 15 分钟之间比较合适。
5.3 游标分页替代页码分页
页码分页在数据量小的时候很直观,但数据量大后会出现深分页问题。OFFSET 1000000 LIMIT 20会扫描前面一百万行,消耗大量数据库 IO。
游标分页通过(created_at, id)组合条件定位,让扫描范围始终控制在一个小范围内。SQL 示例:
SELECT id, user_id, content, type, status, created_at FROM t_post WHERE user_id = 123 AND (created_at, id) < ('2025-01-01 00:00:00', 999999) ORDER BY created_at DESC, id DESC LIMIT 20;前提是表上存在(user_id, created_at, id)相关索引。游标值不要直接暴露数据库时间字符串,可以返回一个拼接后的字符串,由前端下次请求时原样传回。
5.4 视频地址的动态拼接
不要在数据库中保存完整的 CDN URL,因为域名变更和证书切换都会导致历史数据失效。更合理的做法是保存对象存储的object_key,返回给前端时再拼接 CDN 域名。
String videoUrl = cdnDomain + "/" + video.getObjectKey();如果视频是私有读权限,需要生成签名 URL:
String signedUrl = ossClient.generatePresignedUrl(bucket, objectKey, expiration).toString();签名 URL 有有效期,频繁生成会带来不必要的计算和网络开销。可以在第一次生成后缓存一段时间,比如 5 分钟,减少请求量。
6. 优化方向四:媒体文件与业务服务解耦
视频文件体积大,如果走业务服务器上传和转发,会占用大量网络带宽和磁盘空间。把媒体文件独立出来,是发布视频性能优化的关键一步。
6.1 为什么不能把视频放在业务服务器本地
业务服务器本地磁盘有几个明显问题:
- 磁盘容量有限,视频文件增长快。
- 业务服务重启或扩容时,本地文件不好同步。
- 上传和下载同时占用应用服务器带宽,影响接口响应。
- 日志、代码、临时文件和业务数据混在一起,管理混乱。
视频文件应该放到对象存储中,业务服务器只负责生成上传凭证、处理回调、写入元数据。
6.2 对象存储的最小接入流程
以 MinIO 为例,生成预签名上传 URL 的代码:
import io.minio.MinioClient; import io.minio.GetPresignedObjectUrlArgs; import io.minio.http.Method; MinioClient client = MinioClient.builder() .endpoint("http://127.0.0.1:9000") .credentials("admin", "admin123") .build(); String objectKey = "video/" + userId + "/" + System.currentTimeMillis() + ".mp4"; String uploadUrl = client.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.PUT) .bucket("media") .object(objectKey) .expiry(600) .build());前端拿到uploadUrl后,直接向对象存储发起 PUT 请求,后端不承接视频上传流量。上传完成后,前端再调用后端接口提交动态元信息,例如时长、封面图 key、视频 object key。
云厂商对象存储通常也有类似能力,例如阿里云 OSS、腾讯云 COS、华为云 OBS,接入方式大同小异。重点是架构上保持“客户端上传到存储,后端只做协调和元数据管理”。
6.3 封面和缩略图处理
视频封面和缩略图不能在前端生成,也不能在发布接口里同步执行。可以使用 ffmpeg 在异步消费者中截帧:
ffmpeg -i input.mp4 -ss 00:00:01 -vframes 1 cover.jpg ffmpeg -i input.mp4 -vf "scale=320:-1" thumb.jpg生产环境可以用容器化的转码 worker,也可以使用云厂商提供的视频处理服务。关键点在于:转码任务一定不能放在发布请求的同步链路里,否则大量用户同时发布时,应用服务器 CPU 会被瞬间打满。
6.4 前端加载媒体资源的常见优化
列表加载不仅依赖后端,前端也需要配合优化。
- 动态列表先返回封面图和视频地址,但视频默认不自动播放,用户点击后再加载真实播放地址。
- 图片和封面使用懒加载,减少首屏请求数量。
- 长列表使用虚拟滚动,只渲染可视区域内的动态。
- 视频标签使用
preload="metadata",避免一次性加载整个视频文件。
前后端配合优化后,动态列表的流量会明显下降,后端压力和用户等待时间也会同步减少。
7. 如果真的需要“删动态”,也要按归档策略操作
回到“朋友,请删掉一些动态吧”这句话。如果业务确实需要清理历史数据,不能直接在线上大表执行 DELETE,必须有归档策略。
7.1 直接 DELETE 的风险
直接删除大量数据会带来几个问题:
- 大事务执行 DELETE 时可能持有锁,影响正常写入。
- 删除产生的 binlog 量很大,会导致主从同步延迟。
- 物理删除后数据不可恢复,一旦误删很难补救。
- 如果没有带条件,可能全表删除,造成重大事故。
所以即使要删,也要设计成“分批迁移 + 延迟清理”。
7.2 软删除与归档表
一种稳妥方案是动态主表增加deleted字段:
ALTER TABLE t_post ADD COLUMN deleted tinyint NOT NULL DEFAULT 0 COMMENT '0 正常 1 删除';用户删除动态时,先执行软删除:
UPDATE t_post SET deleted = 1 WHERE id = ?;列表查询默认过滤已删除记录:
SELECT id, user_id, content, type, status, created_at FROM t_post WHERE user_id = 123 AND deleted = 0 ORDER BY created_at DESC LIMIT 20;定期归档时,将超过时间阈值的记录先复制到归档表t_post_archive,再从主表分批删除。分批删除示例:
DELETE FROM t_post WHERE created_at < '2025-01-01 00:00:00' AND id IN ( SELECT id FROM ( SELECT id FROM t_post WHERE created_at < '2025-01-01 00:00:00' LIMIT 1000 ) tmp );这个写法通过子查询先取 1000 条 ID,再删除,减少单次事务的锁范围。不同 MySQL 版本对DELETE的限制略有差异,落地前需要在测试环境验证。
7.3 清理后必须做的事
清理数据之后,有几件事不能省略:
- 删除或更新 Redis 缓存中的已归档动态 ID,避免用户仍然看到已经删除的内容。
- 低峰期执行
OPTIMIZE TABLE t_post;整理表空间。注意该操作可能锁表,生产环境需要安排在业务低峰期。 - 检查归档表数据量是否和主表删除前预期一致。
- 延迟清理对象存储中的视频文件,避免用户拿着旧的签名 URL 还在下载,等过期后再删除更安全。
7.4 归档策略参数表
| 参数 | 建议值 | 说明 |
|---|---|---|
| 归档时间阈值 | 动态保留 6 个月 | 根据业务实际确定 |
| 每批删除行数 | 200 到 1000 | 避免锁时间过长 |
| 执行时间 | 凌晨 2 点到 4 点 | 业务低峰期 |
| 归档表 | t_post_archive | 与主表结构一致,保留查询索引 |
| 缓存过期时间 | 5 分钟 | 避免列表长期不一致 |
8. 常见问题排查与最佳实践清单
8.1 常见问题表
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 发布视频按钮一直转圈 | 上传走应用服务器,带宽被占满 | 改为对象存储直传 |
| 动态列表打开很慢 | 全表扫描、无有效索引 | 使用 EXPLAIN 分析并加索引 |
| 发布接口很快但视频不出现 | 异步任务失败或消息队列积压 | 查看队列积压、消费者日志 |
| 数据清理后查询仍然很慢 | 统计信息未更新、索引碎片多 | 低峰期执行 OPTIMIZE TABLE |
| 同一时间大量用户发布视频 | 应用线程池满、数据库连接满 | 连接池监控、异步化、限流 |
8.2 从现象到根因的排错路径
遇到“动态多了之后发布视频变慢”的问题,按照下面的顺序排查:
- 确认变慢的是发布接口还是列表加载接口,或者两者都慢。
- 抓取应用日志,观察接口耗时集中在哪一段。
- 查看数据库连接数和慢查询日志。
- 对慢 SQL 执行
EXPLAIN,确认是否全表扫描。 - 查看消息队列积压量,判断异步消费者是否正常。
- 检查对象存储上传耗时和带宽占用。
- 检查前端是否一次性请求了过多媒体资源。
排错时不要跳步,尤其是在没有日志和监控数据的情况下,猜测只会让问题更难定位。
8.3 发布前性能检查清单
每次发布版本前,可以对照下面清单:
- 动态主表索引是否和核心查询匹配?
- 是否删除了不必要的二级索引,避免拖慢写入?
- 发布流程是否包含同步转码、封面生成等耗时操作?
- 消息队列是否已接入,并且有积压监控?
- 列表接口是否仍然存在 N+1 查询?
- 分页是否还是
OFFSET深分页? - 视频文件是否已经迁移到对象存储并配置 CDN?
- Redis 缓存是否设置过期时间和失效策略?
- 数据库连接池大小是否根据并发情况调整?
- 定时归档任务是否安排在业务低峰期?
8.4 学习环境与生产环境的差异
| 维度 | 学习/演示环境 | 生产环境 |
|---|---|---|
| 数据库 | 单机 MySQL,手动执行 SQL | 一主多从、监控、备份恢复 |
| 文件存储 | MinIO 单节点 | 云对象存储或分布式存储,跨可用区容灾 |
| 消息队列 | RabbitMQ 默认配置 | 集群、镜像队列、消息追踪、死信队列 |
| 缓存 | Redis 单机 | 哨兵或 Cluster,缓存击穿保护 |
| 发布流程 | 同步写库演示 | 分阶段异步、状态机、审计日志 |
| 数据清理 | 手动执行 DELETE | 定时任务、归档表、灰度分批 |
回到最初的问题,用户说“朋友,请删掉一些动态吧”,更像是对系统变慢的不满。真正有价值的思路是:先不要把责任推给数据量,而是把发布和加载的链路拆开,结合索引、异步化、缓存、对象存储和归档,让系统在数据增长时依然稳定。
架构改造最忌讳一步跨太大。建议先定位到具体瓶颈:发现列表查询慢,就先优化索引或分页;发现发布流程里 ffmpeg 同步转码卡住用户,就先异步化。每一步完成后都要用监控数据验证效果。对于刚接触这类问题的开发者,可以从最小示例开始:建一张动态表、写一个发布接口、模拟大量数据,然后用EXPLAIN观察查询计划,再动手加索引或引入 Redis 缓存。把这条链路走通后,对“数据量变大之后系统变慢”的理解会比只看理论深入很多。