校园社交平台这个选题,说实话我做过不止一次了,也帮别人从零搭过好几版。它不像电商项目对库存、支付、物流有硬性链路要求,也不像内容社区要处理推荐算法,它最核心的矛盾在于:功能要足够丰富以支撑“社交”的体量,同时开发成本又不能让一个学生团队或单人开发者失控。我最终定下来的技术方案很经典,就是 Python 后端起一个 Django/Flask 的 REST API,前端用 Vue 做单页应用,日常开发全部在 PyCharm 里完成。这套组合里的每一个零件都不算新奇,但当它们被组合成一个前后端分离的校园社交平台时,很多细节就不像教程里写的那般理所当然,这也是今天我打算认真聊聊的原因。
我想先明确一下这篇文章能给你带来什么:完整的功能规划思路、后端数据模型与接口设计方案、Vue 端的页面与状态管理结构、前后端联调时的经典坑,以及最终部署到服务器的一套相对稳妥的做法。无论你是准备把它当毕业设计、课设,还是给某高校社团做内部社区,照着这个路线走,基本能把一个可用的平台跑起来。
1. 校园社交平台的需求边界与技术选型:先想清楚要做多少功能
我在动手写代码之前,习惯先做减法。校园社交平台听起来范围很大,但如果真的把贴吧、微博、微信的功能都塞进去,一个 3 到 5 人的团队也得做上半年,个人开发者更是直接被拖垮。所以我通常把需求收敛成下面几个核心模块,这也是同类平台里用户感知最强、最值得先做出来的部分。
1.1 用户侧到底需要哪些场景
最基础的是账号系统,包括注册、登录、个人资料维护。这部分不需要短信验证码这种重型方案,邮箱验证或者简单的用户名加密码就能跑起来,关键是要保证登录状态的安全性,所以后续我会直接上 JWT 而不是传统的 Session 方案。
其次是内容模块。校园社交平台的灵魂在于动态流,用户可以在首页看到最新的帖子,可以发一段文字、带几张图片,其他用户可以点赞和评论。这个模块看起来简单,但它直接决定了信息流模型、评论树结构和点赞去重的方式,是整个项目里最容易“做着做着就乱了”的地方。
然后是关系链和私信。社交平台需要关注、粉丝的概念,用户之间可以私聊。私信模块在模型设计上尤其容易被忽略,等做到前端聊天界面时才发现后端没预留“未读消息”的字段,这个坑我在第一次做的时候踩得印象深刻。
最后是轻量工具类功能,比如失物招领、二手交易、校园活动通知。这些可以直接复用动态模块扩展一个类型字段,没有必要为每个功能单独建一套系统。
1.2 为什么我把后端锁在 Django 而不是 Flask
标题里同时出现了 django 和 flask,这确实是很多新手纠结的地方。我的建议非常明确:如果这是你第一次独立做完整项目,或者你要写得比较快,选 Django;如果你已经非常熟悉 Flask 并且确定不会用到太多管理后台能力,可以考虑 Flask。
原因很简单。Django 内置了 ORM、Admin 后台、用户认证体系、迁移机制,这些刚好覆盖了校园社交平台最重的几个部分。比如用户模型可以直接继承 Django 的 AbstractUser,很多现成的字段直接复用,省掉大量重复造轮子的时间。Django 的 ORM 在做涉及用户、动态、评论、点赞的复杂查询时,会比 Flask 搭配 SQLAlchemy 少写不少代码。而 Flask 的优势是轻量和灵活,但灵活性意味着你要自己组装方案,项目进入中后期很容易被零散的第三方扩展拖累节奏。
下面这个表是我整理的一版选型对照,供你在做技术决策时参考。
| 对比维度 | Django + DRF | Flask + Flask-RESTful |
|---|---|---|
| 开发速度 | 快,内置功能多 | 中等,需要手动组装 |
| ORM 与迁移 | 自带,迁移命令完善 | 需要 SQLAlchemy + Flask-Migrate |
| Admin 后台 | 自带 | 需要单独接入 |
| API 文档 | DRF 自动生成 Schema | 需要自己配 |
| 学习曲线 | 偏陡但线性 | 起步容易,深入要求高 |
| 适合场景 | 项目体量大、团队多人协作 | 原型验证、接口量少 |
值得一提的是,我最终在项目里使用的是 Django + Django REST Framework,因为 DRF 的视图集和序列化器可以大幅度压缩接口代码量。比如标准的数据模型,一个 ModelViewSet 就能把 list、create、retrieve、update、destroy 全部覆盖,配合自定义 action 增加关注、点赞等业务接口也很容易。
1.3 开发环境的安排:PyCharm 同时管前端和后端
用 PyCharm 做这种全栈项目有一点很舒服:可以一个窗口打开整个前后端代码目录,前端用内置终端跑 npm,后端用 Run Configuration 启动 Django Server。需要注意的是不要让 PyCharm 试图把 node_modules 索引进去,否则风扇会转得让你怀疑人生。我的做法是分别创建 frontend 和 backend 两个项目根目录,然后在 PyCharm 的 Project Structure 里把两个目录都挂进来,前端代码的依赖精细索引全部排除。
这里还要多说一句开发顺序。我见过不少同学先写完前端页面再写后端接口,最后联调时发现字段对不上,返工量极大。正确顺序应该是:先把数据模型和 API 文档定下来,然后用 Postman 或 PyCharm 自带的 HTTP Client 验证通了,再让 Vue 那边按确定好的 JSON 结构渲染页面。这个顺序能避免很多无谓的沟通和重写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端的数据建模与接口设计:社交关系是关键难点
后端这块是整个项目的骨架,数据模型设计得好不好,直接决定后面写 API 的时候是顺手还是处处别扭。这一章我会把核心模型和接口思路拆开讲,重点说说社交关系、点赞去重和评论数缓存这几个容易出问题的地方。
2.1 用户、动态、评论、点赞的模型设计
用户模型我直接复用了 Django 的 AbstractUser,然后额外加了一些校园场景需要的字段,比如年级、学院、个性签名和头像地址。这里要注意一个习惯问题:不要只往字段里堆需求,还要考虑哪些内容将来会有极高的查询频率。
python复制# backend/users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
avatar = models.URLField(blank=True, null=True) # 头像链接
bio = models.TextField(blank=True, default='') # 个性简介
grade = models.CharField(max_length=20, blank=True) # 年级
college = models.CharField(max_length=100, blank=True) # 学院
created_at = models.DateTimeField(auto_now_add=True)
动态模型需要思考的点比较多。除了作者和正文之外,我加了图片列表字段,这个字段用 JSONField 存储图片 URL 数组,而不是单独建一张图片表,原因是校园社区的图片量并不会大到需要独立的附件体系,这样设计可以显著简化查询逻辑。另外我预留了一个 type 字段,用来区分普通动态、失物招领、二手物品、活动通知,这样后续扩展时无需重建表结构。
python复制# backend/posts/models.py
from django.conf import settings
from django.db import models
class Post(models.Model):
author = models.ForeignKey(settings.AUTH_USER_MODEL,
related_name='posts',
on_delete=models.CASCADE)
content = models.TextField(max_length=2000)
images = models.JSONField(default=list, blank=True) # 支持多图
type = models.CharField(max_length=20, default='post') # post/lost/sale/activity
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
评论模型要关心的主要是父子关系,但最小可行版本只需要支持一级评论加楼层回复。我在设计时没有用自关联外键,而是直接通过一个 parent 字段指向评论自身,这样遇到“回复某条评论”的场景时依然可以支持,不需要一开始就上嵌套评论树。
python复制class Comment(models.Model):
post = models.ForeignKey(Post, related_name='comments',
on_delete=models.CASCADE)
author = models.ForeignKey(settings.AUTH_USER_MODEL,
related_name='comments',
on_delete=models.CASCADE)
content = models.TextField(max_length=500)
created_at = models.DateTimeField(auto_now_add=True)
点赞模型有一个特别容易踩的坑:很多新手把点赞设计成 Post 表上的一个 likes_count 整数字段,每次点击就在这个字段上加一,这样的确非常简单,但用户会重复点赞,接口幂等性完全无法保证。标准做法是单独建一张点赞关系表,用户与动态之间建立唯一约束。
python复制class Like(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
post = models.ForeignKey(Post, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'post')
点赞数量用 serializer 里的 SerializerMethodField 实时计算,然后通过 Django 的 annotate 一次聚合查出列表所需的数据。这里我建议不要物理维护一个整数计数,因为每次点赞都要走事务更新,容易出现并发覆盖,而在查询时用 count 加上基础索引,性能对校园网级别的并发完全足够。
2.2 关注关系与私信的消息模型
关注关系其实和点赞类似,本质上是一张 follower 指向 following 的关系表。真正需要设计好的是私信模块,它涉及会话与消息两个层次。
我先定义 Conversation 用来表示一个聊天会话,会话里有两个参与者 user_a 和 user_b。然后定义 Message 表示每一条消息,包含发送者、接收者、内容和已读状态。关键是不要为了方便直接在 Conversation 表里塞一个 last_message 字段,一旦需要查历史消息或未读计数,字段同步会非常难受。
python复制class Conversation(models.Model):
user_a = models.ForeignKey(settings.AUTH_USER_MODEL,
related_name='conv_a',
on_delete=models.CASCADE)
user_b = models.ForeignKey(settings.AUTH_USER_MODEL,
related_name='conv_b',
on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user_a', 'user_b')
class Message(models.Model):
conversation = models.ForeignKey(Conversation,
related_name='messages',
on_delete=models.CASCADE)
sender = models.ForeignKey(settings.AUTH_USER_MODEL,
related_name='sent_messages',
on_delete=models.CASCADE)
receiver = models.ForeignKey(settings.AUTH_USER_MODEL,
related_name='received_messages',
on_delete=models.CASCADE)
content = models.TextField(max_length=2000)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
这里有一个我实际用下来很爽的查询技巧:获取当前用户的会话列表时,先在 Message 表按 conversation 分组取最新的时间戳,再反过来去关联会话和对方资料,整个过程只需要两三条 SQL。
2.3 接口路径设计与返回格式的统一约定
接口设计我遵守一套约定,所有返回格式固定为 { "code": 0, "data": ..., "message": "ok" },这样前端的 axios 拦截器只需要根据 code 判断业务是否成功,不用为 HTTP 状态码和业务状态码做双重映射。
核心接口分工大概是这样:
| 模块 | 接口 | 说明 |
|---|---|---|
| 认证 | POST /api/auth/register/ | 注册,返回 token |
| 认证 | POST /api/auth/login/ | 登录,返回 token |
| 动态 | GET /api/posts/ | 信息流,支持分页 |
| 动态 | POST /api/posts/ | 发布动态 |
| 互动 | POST /api/posts/{id}/like/ | 点赞 |
| 互动 | POST /api/posts/{id}/unlike/ | 取消点赞 |
| 互动 | POST /api/posts/{id}/comments/ | 发布评论 |
| 关系 | POST /api/users/{id}/follow/ | 关注 |
| 消息 | POST /api/conversations/ | 发起会话 |
| 消息 | GET /api/conversations/{id}/messages/ | 会话消息列表 |
在 Django 的 urls.py 里,我一般不用手写全部接口,而是用 DRF 的 DefaultRouter 配合自定义 action。点赞和关注这种动作,直接在 PostViewSet 和 UserViewSet 上定义 action 方法,URL 的语义也清楚。
python复制# backend/posts/views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
permission_classes = [IsAuthenticated]
def perform_create(self, serializer):
serializer.save(author=self.request.user)
@action(detail=True, methods=['post'])
def like(self, request, pk=None):
post = self.get_object()
like, created = Like.objects.get_or_create(
user=request.user, post=post
)
return Response({'like': True})
@action(detail=True, methods=['post'])
def unlike(self, request, pk=None):
post = self.get_object()
Like.objects.filter(user=request.user, post=post).delete()
return Response({'like': False})
分页方面,我建议使用 DRF 的 PageNumberPagination,默认每页 10 条,前端按页数滚动加载。这里不需要上游标分页,数据量还没有大到那个程度。
3. Vue 前端的页面与状态设计:信息流要顺滑
前端我用的是 Vue 3 加 Vue Router 加 Pinia。这套组合在工程上足够现代,官方生态也比较稳定。很多人在 Vue 2 和 Vue 3 之间犹豫,我的态度很明确:新项目就直接 Vue 3,组合式 API 写起来比 Options API 顺手得多,尤其在做组件复用的时候,自定义组合函数可以把大量逻辑提取成独立模块。下面说一说页面结构和几个关键点。
3.1 路由与页面骨架
项目 src 目录下,我分了这些模块:pages 放页面级组件,components 放公共组件,api 放 axios 请求封装,stores 放 Pinia 状态。页面这边先做五个核心视图:登录注册页、首页信息流、动态详情页、个人主页、消息列表页。
router 的配置里需要加入登录守卫。没有 token 的用户跳转发动态、发评论、看私信时,通通重定向到登录页。这个逻辑不要在每个页面单独写,直接挂在全局前置守卫里。
javascript复制// frontend/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', name: 'Login', component: () => import('../pages/Login.vue') },
{ path: '/', name: 'Home', component: () => import('../pages/Home.vue'), meta: { requiresAuth: true } },
{ path: '/post/:id', name: 'PostDetail', component: () => import('../pages/PostDetail.vue'), meta: { requiresAuth: true } },
{ path: '/user/:id', name: 'Profile', component: () => import('../pages/Profile.vue') },
{ path: '/messages', name: 'Messages', component: () => import('../pages/MessageList.vue'), meta: { requiresAuth: true } },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('access_token')
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } })
} else {
next()
}
})
export default router
3.2 登录状态与 axios 拦截器的处理
登录态是这类前后端分离应用的基础设施。我的做法是用 Pinia 维护一个 auth store,登录成功后把 token 放进 store,同时持久化到 localStorage。之后所有请求都通过 axios 拦截器在请求头里追加 Authorization 字段。
javascript复制// frontend/src/api/request.js
import axios from 'axios'
import router from '../router'
const request = axios.create({
baseURL: '/api',
timeout: 15000,
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
request.interceptors.response.use(
response => response.data,
error => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('access_token')
router.push({ path: '/login' })
}
return Promise.reject(error)
}
)
export default request
这段拦截器是坏在业务逻辑里的关键防线。之前我遇到过用户 token 过期后,页面还继续用一串无效 token 发请求,后端每次返回 401,前端就在信息流里刷出一堆错误提示。加了这段拦截器,401 出现时自动清 token 并跳登录页,用户只需要重新登录一次就可以恢复正常使用。
3.3 信息流页面与无限滚动实现
首页信息流是用户停留时间最长的页面,我采用无限滚动的模式,而不是分页按钮。实现方式不复杂:维护一个 page 状态和一个 loading 状态,滚动到接近底部时触发加载下一页。
vue复制<!-- frontend/src/pages/Home.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import { getPosts } from '../api/post'
import PostCard from '../components/PostCard.vue'
const posts = ref([])
const page = ref(1)
const loading = ref(false)
const finished = ref(false)
async function loadPosts() {
if (loading.value || finished.value) return
loading.value = true
const data = await getPosts({ page: page.value })
posts.value.push(...data.results)
page.value += 1
if (data.next === null) {
finished.value = true
}
loading.value = false
}
function onScroll() {
const scrollHeight = document.documentElement.scrollHeight
const scrollTop = document.documentElement.scrollTop
const clientHeight = document.documentElement.clientHeight
if (scrollHeight - scrollTop - clientHeight < 100) {
loadPosts()
}
}
onMounted(() => {
window.addEventListener('scroll', onScroll)
})
</script>
这里有一点要提醒:不要把请求结果直接替换 posts 数组,而是用 push 追加,否则每次滚动加载都会把之前的数据震掉,用户会明显感觉到页面跳动。另外后端返回的分页数据一定要包含 next 字段,否则前端无法判断是否已经到底,我在第一次开发时后端没给 next,前端只能通过返回条数是否小于 pageSize 来猜,一旦某页正好满条就会出现无限请求的 bug。
3.4 动态卡片与点赞评论的本地更新策略
PostCard 是信息流页最核心的组件,它要显示作者的昵称头像、动态正文、多图、点赞数和评论数,以及点赞按钮和评论入口。点赞按钮交互上最忌讳的做法是点完就重新请求整条动态详情,那样会白屏闪烁。更舒服的做法是:点击后先精确调用点赞接口,等后端返回成功,再把组件内部的 likes_count 加一,同时把 is_liked 更新为 true。
如果之后要扩展评论数,也遵循同样的策略,发布评论后把 PostCard 的 comments_count 加一。这种把更新请求和展示分离的做法,在社交类应用里能显著提升体验流畅度。但要注意,组件内部维护的点赞状态在加载下一页时一定要从后端数据初始化,不然用户下拉一刷新,之前的临时状态就被清掉了。
4. 前后端联调中的隐藏坑:CORS、图片上传与token失效
这个章节一般是我在带人做项目时最想强调的部分。很多前后端项目单看前端和后端都一切正常,一旦联调就崩盘,十有八九是跨域、上传和认证三件事没有处理好。下面把这三个坑逐个说一遍。
4.1 CORS 跨域配置:本地开发绕不开的第一道坎
开发时前端跑在 localhost:8080,后端跑在 localhost:8000,两者端口不同,天然存在跨域问题。浏览器默认会把跨域请求拦截掉,所以后端必须要配 CORS。Django 这边用 django-cors-headers 这个库,配置就两处。
python复制# backend/settings.py
INSTALLED_APPS = [
...,
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
...,
]
CORS_ALLOWED_ORIGINS = [
'http://localhost:8080',
'http://127.0.0.1:8080',
]
一定要把 CORS 中间件放在尽量靠前的位置,而且注意是加在 MIDDLEWARE 列表的最上面附近,不是随便插在中间,否则容易在生产环境出现请求被其它中间件拦截导致 CORS 头没打上的问题。我见过一个团队花了大半天排查接口在浏览器里通不过、在 Postman 里却完全正常的问题,最后发现就是 CORS 头缺失导致的 OPTIONS 预检失败。
4.2 图片上传路径和 nginx 静态资源的配合
发布动态时的图片上传,前端用 FormData 提交,后端接收后保存到 MEDIA 目录。这里有两个常见麻烦。
第一是文件大小限制。Django 默认没有严格限制请求体大小,但部署到 nginx 后,nginx 默认 client_max_body_size 是 1M,超过 1M 的图片会直接返回 413。我的做法是在 nginx 配置里针对 /api/ 路径单独调大,同时前端在上传前压缩图片,限制单张不超过 2M。
第二是静态资源和媒体文件的路径。开发阶段,Django 用 static() 辅助函数来托管 MEDIA_URL;部署阶段,这些媒体文件通常放本机磁盘,然后由 nginx 的 location /media/ 转发到对应目录。
nginx复制# nginx 配置片段
location /media/ {
alias /var/www/web/media/;
expires 30d;
}
在前后端分离的架构下,图片 URL 返回时一定要存完整可访问的绝对路径,不要只返回 /media/xxx.jpg 这种相对路径。否则部署后前端的域名和 API 域名不同,图片就会全部裂掉。我在后端序列化时统一拼接了当前请求的域名前缀,前端拿到直接就能用。
4.3 token 过期策略:登录状态被突然清掉怎么办
JWT 的好处是无状态,但坏处是过期后体验不连贯,用户正刷着信息流,突然就被踢到登录页。有几个方案可以缓和这个矛盾。
最简单的做法是把 access token 的有效期设得长一些,比如 24 小时,然后前端在登录后把用户名和头像存在本地,被登出时至少还能显示缓存资料。稍好一点的方案是用 refresh token 机制,axios 拦截器在收到 401 时,先尝试用 refresh token 换新 access token,换成功则重发原请求,换失败才跳登录页。
javascript复制request.interceptors.response.use(
response => response.data,
async error => {
const originalRequest = error.config
if (error.response && error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const { refresh_token } = localStorage
const { data } = await axios.post('/api/auth/refresh/', { refresh: refresh_token })
localStorage.setItem('access_token', data.access)
originalRequest.headers.Authorization = `Bearer ${data.access}`
return request(originalRequest)
} catch (refreshError) {
localStorage.removeItem('access_token')
router.push({ path: '/login' })
}
}
return Promise.reject(error)
}
)
这套逻辑里有一个容易出问题的地方:刷新 token 的请求不能再走同一个拦截器,否则会在 401 之后再次触发拦截器,形成死循环。所以刷新请求必须用独立的 axios 实例,不走这个统一拦截逻辑。我在做项目时在这里被绕晕过一次,特地从拦截器里把 auth 相关的请求排除出去才解决。
5. 部署与后续优化:测试环境跑通不算完
最后一部分是部署落地。校园社交平台虽然功能不算重型,但面向真实用户时,服务器配置、静态资源、数据库迁移、进程守护,每个环节都会暴露问题。我会把部署过程拆成“构建、托管、守护”三个层面,再补充一些后续必要的优化方向。
5.1 后端准备:迁移、生产依赖与 gunicorn
部署第一步是准备后端环境。我用 pip freeze 导出 requirements.txt,然后在生产服务器创建虚拟环境安装依赖。数据库方面,如果只是单体应用,用 SQLite 也能跑起来,但面对多人并发读写的真实场景,我的建议是直接用 PostgreSQL,安装和迁移成本都不高。
接着需要把 Django 的 DEBUG 设为 False,把 ALLOWED_HOSTS 设置为服务器域名或 IP。这一步漏掉会让所有请求在部署后报 DisallowedHost,而且终端里不会有明显报错,很容易让人误以为是网络问题。
启动进程我首选 gunicorn。Uvicorn 虽然也支持 Django 的 ASGI,但我们的项目没有用到异步特性和 WebSocket,同步服务用 gunicorn 更稳定、内存占用也更可控。
bash复制gunicorn backend.wsgi:application \
--bind 0.0.0.0:8000 \
--workers 3 \
--timeout 60
workers 数量不是越大越好,一般按服务器 CPU 核心数的 2 到 4 倍设置。对于校园级别的访问量,3 个 worker 已经足够,不需要盲目调大。
5.2 前端构建与 nginx 托管
前端构建非常简单,在 frontend 目录执行 npm run build,就会生成 dist 目录。这个 dist 目录是纯静态文件,可以交给 nginx 托管,同时把 /api/ 下的请求反向代理到本机的 8000 端口。这样最终用户访问的是同一个域名,没有跨域,也不会暴露后端端口。
nginx复制server {
listen 80;
server_name demo.example.com;
root /var/www/web/dist;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /media/ {
alias /var/www/web/media/;
}
location / {
try_files $uri $uri/ /index.html;
}
}
try_files 这一行是为了让 Vue Router 的 history 模式在刷新页面时不会 404。如果你不做这一行,深度链接比如 /user/123 刷新一下,nginx 找不到对应的静态文件,就会直接 404。这是前后端分离部署里最容易漏掉的一步。
5.3 性能优化的优先级:先缓存信息流,再考虑异步
项目上线之后,最先出现的性能瓶颈往往在首页信息流。每个用户打开首页,后端都要执行一次包含作者、点赞数、评论数、是否点赞的复杂查询,数据库压力非常大。我的优化顺序是这样:
第一步,给信息流接口加 Redis 缓存,缓存整个首页第一页的动态列表 JSON,设置过期时间 30 秒到 1 分钟。新动态发布后主动删除对应缓存,让下一次请求重新生成。这套方案改动量极小,效果却最明显。
第二步,使用 django-debug-toolbar 看一下 SQL 查询数量,针对 N+1 查询用 select_related 和 prefetch_related 优化。这一点在开发环境可能看不出来,但在线上数据量上来后差距是数量级的。
第三步,才是引入 Celery 做异步任务。比如发通知邮件、推送新消息提醒,这些不需要同步返回结果的操作,放到任务队列里执行。如果在项目初期就直接加入 Celery,会增加 Redis、Worker 进程、任务失败监控等多重复杂度,对个人开发者并不友好,所以我建议把它排在缓存优化之后。
关于部署方式,如果你的服务器内存不足 1G,就不要同时跑 nginx、PostgreSQL、Redis 和一个多进程的 gunicorn,很可能触发 OOM。我在一台 1G 内存的机器上跑过完整方案,结果不定时宕机,后来用 Docker Compose 把服务拆分后也依然紧张。最终我把 SQLite 换回 PostgreSQL,同时把 Redis 缓存调小,gunicorn workers 降到 2 个,才算稳定下来。这告诉我一件事:线上环境的“稳定”不是靠配置华丽,而是靠每一层都留有余量。
如果真要说我多次做这类平台后最想提醒的一点,那就是内容治理。校园平台并不是写完功能就结束了,匿名墙这类带匿名性质的模块,如果把发布门槛设成零,很快就会被灌入垃圾内容和攻击性言论,最终把社区的信任感消耗殆尽。我的做法是匿名内容不直接显示,先进入后台待审核队列,触发词和敏感字段自动标记,哪怕审核效率低一点,也不让用户首屏看到明显不良信息。功能可以做减法,但信任机制的兜底必须从一开始就在模型里留出审核字段。具体怎么取舍,还得看你服务的社区文化和管理团队投入,希望这篇经验能让你少走几步弯路。
