“作业weibo”这个名字,听起来像是一个随手起的工程代号,但它实际上是我和几个队友在一门软件工程课上从零做完的一个微型微博系统。为什么课程作业会选择微博?因为微博这个产品形态几乎把社交应用里的经典难题全占了一遍:用户体系、内容发布、关注关系、信息流聚合、评论点赞、消息通知,一学期能把这条链路完整走通,比写十个增删改查的后台管理系统都值。
这篇文章把我们组从需求拆解、技术选型、表结构设计,到两条核心业务链路的实现细节,以及最后踩过的几个深坑,整个过程做个完整复盘。如果你也在做类似的社交类课程项目,或者工作中要上手一个内容社区系统,这篇文章里提到的很多取舍和排查思路应该能帮你省下不少时间。尤其是那几个“搜不到关键词”的诡异问题,我会把排查链路写得尽量完整。
1. 项目立项:为什么课程作业选了微博
课程要求是“实现一个具备基础社交能力的Web应用”,给了三个月周期,期末要现场演示。我们组最开始列了一堆候选:二手交易平台、会议室预约系统、博客系统,后来讨论来讨论去,定了一个“精简版微博”。理由是它足够直观,评委老师一眼能看懂产品形态,同时技术覆盖面又足够广,能把一门软件工程课的知识点都串起来。
1.1 需求清单是怎么拆出来的
我们没急着写代码,第一周全部在拆需求。最终落成一张优先级表,这个表后来成了整个项目的开发排期依据:
| 模块 | 功能点 | 优先级 |
|---|---|---|
| 用户 | 注册、登录、编辑个人资料 | 高 |
| 内容 | 发文字动态、上传图片、删除自己的动态 | 高 |
| 关系 | 关注/取关、查看粉丝与关注列表 | 高 |
| 信息流 | 首页时间线、个人主页时间线 | 高 |
| 互动 | 评论、点赞、评论列表 | 中 |
| 通知 | 被关注、被点赞、被评论的消息提醒 | 低 |
高优先级的功能必须保交付,中低优先级的在时间不够时可以砍。做课程项目最容易犯的错就是把所有功能平均用力,最后哪个都没做好。这张表在几轮迭代里帮我们扛住了不少“再加个功能吧”的冲动。
1.2 工程代号与团队分工
工程代号是随手起的,本来想叫“MiniWeibo”,后来有人写成了“作业weibo”,大家觉得又直白又有梗,就沿用了,一直用到了答辩PPT里。三个人分工是:一个人主攻后端接口与数据库,一个人主攻前端页面与交互,第三个人负责部署、联调和写测试数据。身份认证和通知模块这种跨端功能,则按接口约定拆开,各做各的,最后联调时再碰。
2. 技术选型:哪些决定是当时最正确的
在技术选型上,我们组有过一轮比较激烈的争论。有人想用最新的前端框架加微服务架构,觉得简历上好看。我当时的立场是:课程作业的第一目标是稳定交付,不是炫技。考核的是你能不能把一条业务链路完整做出来,而不是你用了多新的技术名词。
2.1 后端与数据库的选择逻辑
后端选了Spring Boot配MySQL。理由很实在:Java是团队里最熟的语言,Spring Boot生态成熟,遇到问题搜一下基本都是现成答案。我们当时也认真考虑过Node.js和Python,但评估下来,团队里没人敢保证一周内吃透框架特性,课程作业经不起这种试错成本。
数据库用MySQL,原因是关系模型稳定,社交场景里的用户、内容、关系、互动都能用清晰的外键表达出来。Redis则用来扛信息流和未读计数这类读多写少的场景。整套组合没有什么“惊喜”,但胜在出问题时我们知道怎么查、怎么修。
2.2 前端方案与部署环境
前端走了“保守但稳妥”的路线:Vue 3配Element Plus组件库,构建工具直接用Vite。把精力留给业务逻辑,而不是手写样式和配置webpack。说实话,Element Plus的默认风格放在演示环境里并不难看,这对没时间抠视觉的课程项目来说很关键。
联调阶段我们组凑钱买了一台便宜的云服务器,用Docker Compose统一部署MySQL、Redis、后端、前端四个容器。这个决定后来被证明极其正确——四个人电脑环境各不相同,有人是Windows,有人是Mac,还有人的JDK版本不对,如果没有容器化,每次联调都是在“我这边是好的啊”的争吵里浪费时间。容器一拉起来,十分钟就能进入干净一致的开发状态。
3. 数据模型:六张表把微博画在纸上
我们组有个不成文的规矩:代码可以写得糙,表结构必须先画清楚。整个项目的表结构在纸上改了四版才动手建库,回头看,这个习惯至少帮我们避免了三次推倒重来。
3.1 用户、动态与图片表的边界
核心表一共六张:用户、动态、图片、关注、评论、点赞。用户表和评论表没什么特别,密码字段存的是BCrypt哈希,这是答辩时老师最爱问的点,千万别用MD5。
动态表是重点。每条动态需要记录作者、正文、发布时间、状态,以及关联的图片。我们前期犯过一个典型错误:把图片URL直接拼成一个字符串存在动态表字段里,用逗号分隔。一开始觉得很方便,后来要做到“删除某张图片”“调整图片顺序”时,这个字段就成了噩梦。最终老老实实拆成独立的图片表,动态和图片一对多,查询时一次JOIN拿全。
CREATE TABLE post ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, content TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1, INDEX idx_user_time (user_id, created_at DESC) );这里给created_at单独建索引是后来调优加上的,后面讲时间线SQL时会细说。
3.2 关注表的双向索引设计
关注表是另一个容易想歪的地方。这张表本质上只记录“谁关注了谁”,但业务上它要同时服务两个方向:查询我的关注列表,查询我的粉丝列表。所以我们建了follower_id和followee_id两列,并且两个字段都建索引。
有人会问为什么不拆成两张表,各管一个方向。拆表意味着要维护两分数据的一致性,关注、取关要写两次,出问题概率翻倍。一张表加双向索引,查询时按条件走对应索引,写操作只有一条记录,这是最简单可靠的方案。
CREATE TABLE follow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, follower_id BIGINT NOT NULL, followee_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_follow (follower_id, followee_id), INDEX idx_followee (followee_id) );联合唯一索引(follower_id, followee_id)保证了一个用户不能重复关注同一个人,这属于数据库层面的幂等约束,比在应用层用if判断可靠得多——再怎么说,业务代码的判断在并发下都可能出现缝隙,唯一索引不会。
4. 信息流:从发动态到刷新首页的完整链路
信息流是整个项目里技术含金量最高的部分,也是我们花时间最多的。说白了,信息流就是“用户登录后首页看到的那一列动态”,它必须按时间倒序、只包含关注的人的内容。
4.1 读扩散 vs 写扩散的取舍
实现信息流有两种经典方案:读扩散和写扩散。
读扩散的思路是:用户刷新首页时,先查出自己关注的所有人,再把这些人的动态按时间倒序聚合。实现简单直接,但关注人数多了之后,一次查询要扫描大量用户的动态,压力全部集中在数据库。
写扩散的思路是:有人发动态时,系统主动把这条动态写入所有粉丝的“收件箱”。用户刷新时只读自己的收件箱,查询极快,但发一条动态可能要批量写几千行,并且关注、取关都要同步维护收件箱数据,复杂度直接翻倍。
我们选了读扩散为主,理由非常务实:课程项目的数据量远没到读扩散扛不住的程度。先把链路跑通,再用Redis给每个人的首页时间线做整页缓存,TTL设60秒。这个方案允许数据有最多一分钟的延迟,在演示场景里完全够用,但代码复杂度比写扩散低了一个量级。
4.2 时间线SQL的调优过程
首页时间线的核心SQL长这样:
SELECT p.*, u.nickname, u.avatar FROM post p JOIN follow f ON f.followee_id = p.user_id JOIN user u ON u.id = p.user_id WHERE f.follower_id = #{userId} AND p.status = 1 ORDER BY p.created_at DESC LIMIT 20 OFFSET #{offset}刚写出来时,测试数据只有几百条,跑得飞快,我们还挺得意。后来为了演示效果灌了一批模拟数据,几千个用户每人几十条动态,这条SQL直接掉到几百毫秒。用EXPLAIN一看,问题出在ORDER BY created_at触发了文件排序,全表先排一遍再取20条,能不慢吗。
在post表的created_at上建了索引后,单次查询稳定在10毫秒内。另外我们把首次查询结果序列化后整页缓存进Redis,后续翻页直接走缓存,数据库的查询压力一下就下来了。演示的时候没人会发现数据延迟一分钟。
5. 互动模块:评论、点赞与通知的细节
互动模块在需求表里优先级是“中”和“低”,但我们后来发现,这三个功能加起来的工作量一点不比信息流少,而且坑最多。
5.1 点赞幂等与计数原子更新
点赞表的设计核心是一个联合唯一索引(user_id, post_id),数据库层面保证一个用户对同一条动态只能有一条点赞记录。这个约束非常重要,因为前端按钮即使置灰了,也拦不住别人用脚本直接调接口。
点赞数的存储我们选了在动态表冗余一个like_count字段,而不是每次请求都COUNT一遍。COUNT在数据量上来后代价很高,冗余字段则是空间换时间。关键在于更新时不能用“先查再改”:
UPDATE post SET like_count = like_count + 1 WHERE id = #{postId}“先查再改”在高并发下必丢数据——两个请求同时读到旧的点赞数,各加一写回去,最终只加了一次。用数据库的原子自增,这个竞争问题从根上就没了。
5.2 通知去重与未读计数
通知模块是“低优先级”里最耗时的部分。被关注、被点赞、被评论都会产生通知,如果不做去重,用户会收到大量重复提醒。
我们的方案是在通知表上加联合唯一索引(接收者ID、触发者ID、类型、动态ID)。同一个用户反复给你点赞,只有第一条能插入成功,后面的直接被数据库挡掉。代价是通知不会聚合成“XX等3人赞了你”这种形态,但课程作业完全能接受这种简化。
未读计数用Redis的Set实现:key是用户ID,产生通知时往Set里加一个通知ID。用户点开通知页后,把整个Set删除,未读角标清零。整个过程中未读数一次都没查过数据库,简单粗暴但有效。
6. 踩坑实录:排查了一周才解决的四个问题
这一节是我们最想分享的内容。很多坑不是搜不到答案,而是你根本不知道该搜什么关键词。下面四个问题,每一个都让我们至少耗掉一个晚上。
6.1 跨域请求里丢失的登录态
前端本地跑在8080端口,后端跑在8081端口,一调接口就报跨域错误。这个大家都会配CORS,配完后发现请求能过去了,但后端就是拿不到登录用户信息。
排查链路是这样的:先确认token有没有种到Cookie里,发现种了;再确认请求头里带没带Cookie,用浏览器开发者工具看,也带了;最后才发现是前端没开withCredentials,导致跨域请求默认不携带Cookie。
解决方案分两步:后端CORS配置里设置allowCredentials(true)并指定具体来源,不能写星号;前端axios请求统一设置withCredentials: true。两个条件缺一不可,缺一个就出现“接口通了但身份丢了”的诡异现象。
6.2 并发点赞导致的计数丢失
这个前面提过,最终用原子UPDATE解决了。但排查过程值得一说:最开始我们用的是“先查like_count再UPDATE成新值”,本地怎么测试都复现不了,因为并发量根本不够。后来写了个脚本开50个线程同时点赞,like_count从100变成了134而不是150,问题立刻实锤。
所以遇到并发问题别靠肉眼和手工测试,一定要写脚本去压。哪怕只是临时写个多线程循环,往往一次就能暴露问题。
6.3 深翻页的性能悬崖
时间线翻到第100页时,接口响应从10毫秒炸到3秒。原因是LIMIT 1000, 20这种写法,会让数据库先扫描前1000行再扔掉,页码越深代价越高。
我们改成了基于游标的翻页:前端传入当前页面最后一条动态的ID,后端用WHERE id < #{lastId}来找下一页,速度直接回到个位数毫秒。这也是真实社交产品里通用的做法,翻页不再依赖页码,而是依赖上一页的锚点。
6.4 图片上传方案的两次返工
图片上传最初图省事,前端把图片转成Base64字符串塞进JSON接口。一张2MB的图片编码后变成近3MB的字符串,接口体积暴涨,响应慢得离谱。
后来改成前端用FormData上传,后端用MultipartFile接收,图片文件先落本地磁盘,再通过静态资源映射对外提供访问。测试时特意在限速网络环境下验证,首屏图片加载时间从8秒降到2秒以内。这里有个容易被忽略的细节:静态资源映射的路径要放在安全配置里放行,否则图片URL会被拦截器当成未登录请求挡掉。
7. 复盘:答辩之后我想明白的事
答辩那天,评委老师问得最密集的问题集中在三块:为什么表结构这么设计、Redis缓存和数据库的一致性怎么保证、有没有考虑过真实场景的并发量级。我们当时的回答能应付过去,但复盘时自己也承认,课程作业阶段掌握的更多是“用什么工具”,而不是“为什么这么用”。
7.1 如果再给我三个月
如果时间重来,有三件事我会从一开始就做。
第一,把非功能性需求写清楚。比如“支撑多少用户”“页面响应时间目标是多少”,这些数字一旦定下来,技术方案的取舍就完全不一样了。当时我们没定这些,导致一些设计完全靠猜。
第二,提前引入压测工具。而不是等到答辩前才发现并发问题,临时补脚本。早一点用并发脚本去测接口,像点赞计数丢失这种问题,可能在第一轮迭代就暴露了。
第三,团队协作里更早用接口文档工具。前期我们靠口头约定接口格式,联调时一半时间在争论字段名到底叫userId还是uid。后面补了接口文档,争论立刻消失了。
7.2 可以继续深化的方向
做完这个作业,如果想往深走,有几个现成的方向:把读扩散升级成读写混合模式(大V写扩散、普通用户读扩散),给时间线加一个简单的热度排序算法,或者把通知模块升级成真正的多类型聚合。这些都是从“做完”走向“做好”的路径,也是真实社交产品里绕不开的问题。
最后分享一点我个人的体会:课程作业的价值从来不在于代码量多少,而在于让你完整走一遍“需求分析—设计—实现—测试—复盘”的闭环。这个闭环只要认真走通一次,以后再遇到任何项目,心里都会有一张地图。