用 Python + MySQL 做一套网页版的即时通系统,是我最近给自己安排的一个完整练手项目。所谓“即时通”,说白了就是界面交互整体参照微信网页版的聊天工具:左边是会话列表,中间是聊天气泡,右上角是联系人资料,支持账号密码登录、加好友、单聊、拉群、历史消息和未读角标。后端用 Python 提供 API 和 WebSocket 长连接,所有用户资料、好友关系、聊天记录都落在 MySQL 里。
这个项目适合谁?如果你已经学完了 Python 基础,能写简单的 Flask 接口,却还没有真正碰过 WebSocket,也没有认真设计过关系型数据库的表结构,那这个项目是很好的过渡。它让你在一周左右的时间里,亲手把一个从注册登录到消息推送的完整系统跑起来。网上仿微信的教程很多,但大多数只讲某个点:要么只做登录,要么只写了一个聊天室。而我想说清楚的是完整链路——从建表、接口、长连接,到消息落地、未读拉取、在线推送,每一环是怎么串起来的。本文里的代码都是简化后的可运行版本,基于我实际跑通的项目整理,我会把每个关键选型的理由和踩过的坑都写出来。
1. 为什么用 Python + MySQL 仿微信网页版:选型逻辑与整体架构
先讲选型。市面上做即时通讯,后端常见方案有 Node.js 的 Socket.IO、Go 的 WebSocket 库、Java 的 Netty,Python 这边一般提 Django Channels 或者 Flask-SocketIO。我自己选的是 Flask + Flask-SocketIO + MySQL,主要原因有三个。
第一,Python 在个人练手项目里的开发效率最高。Flask-SocketIO 把 HTTP 路由和 WebSocket 事件统一在一个框架里,不用像 Django Channels 那样额外理解 ASGI、channel layer 这一套抽象概念,学习曲线低很多。第二,MySQL 做存储最接近真实生产环境。SQLite 虽然零配置,但并发写入能力有限,而且没法让你练习连接池、事务、索引这些以后一定会用到的东西。第三,这个项目本质上要处理的不是“高并发”,而是“完整业务流程”,Python 完全扛得住。
整体架构其实很简单,浏览器访问页面后,先通过 HTTP 接口完成注册、登录、拉取好友列表,登录成功后前端和服务器建立一条 WebSocket 长连接。之后发消息走 WebSocket,服务器收到消息先写入 MySQL,再通过这条长连接推送给接收方。整个过程分成三层:
- 前端页面:负责展示会话列表、聊天窗口、好友管理,用原生 HTML + JavaScript 实现。
- 后端服务:Flask 提供 /api/login、/api/register、/api/friends 等接口,Flask-SocketIO 处理 connect、send_message、read_message 等实时事件。
- 数据存储:MySQL 保存用户、好友关系、会话、聊天消息,用 SQLAlchemy 做 ORM,连接池统一管理。
为什么推荐把 WebSocket 和 HTTP 混在一个服务里?对个人项目来说,两个服务分开会增加部署和调试成本,而且消息推送的场景没有大到需要单独起一个推送服务。Flask-SocketIO 本身跑在 eventlet 上,协程模式下几千个连接的压力对练手项目已经完全足够。
有一点需要提前想清楚:WebSocket 和 HTTP 是两套不同的会话体系。HTTP 登录成功后,服务端给前端发一个 token,前端建立 WebSocket 连接时把这个 token 带上,服务端在 connect 事件里校验身份,这样两边就能打通。token 方案比直接传用户名密码安全,后面我会专门讲这个部分。
2. MySQL 表结构设计:即时通讯最核心的六张表
很多人做聊天项目,第一反应是先写代码,结果写到消息记录的时候发现表结构一团糟。我的建议是开工前先把数据库表想清楚。仿微信网页版这个规模,六张表基本够用:用户表、好友关系表、会话表、会话成员表、消息表、离线消息表。
2.1 用户表与好友关系表
用户表是基础,字段包括用户 ID、用户名、密码哈希、昵称、头像 URL、个性签名、创建时间。密码绝对不能存明文,用 Werkzeug 的 generate_password_hash 生成哈希再入库。
CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL COMMENT '登录用户名,唯一', `password_hash` VARCHAR(128) NOT NULL, `nickname` VARCHAR(64) NOT NULL, `avatar_url` VARCHAR(256) DEFAULT '', `signature` VARCHAR(128) DEFAULT '', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;好友关系表要存的是“谁加了谁”,不能只存一条记录。A 添加 B 为好友,如果只存一条 userId=A、friendId=B,那 B 的好友列表里就查不到 A,除非你查询时统一做反向匹配。为简单起见,我选择添加好友时写入两条记录,一条正向一条反向。这样查询好友列表只需要一个以 user_id = 当前用户 的 WHERE 条件,SQL 写起来非常直观,代价是新增好友时多做一次插入,对个人项目来说完全可以接受。
CREATE TABLE `friendship` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL, `friend_id` INT UNSIGNED NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;好友申请和同意流程我没有单独建表,而是在原表上加了一个 status 字段,0 表示待验证,1 表示已同意。因为好友申请本质上就是“单向好友记录 + 待通过状态”,等对方同意后再补反向记录。如果想做得再细点,可以加一张 friend_request 表,但六张表版本的方案已经足够演示核心逻辑。
2.2 会话表与消息表
聊天不能直接拿“A 发给 B”这种二元关系硬套,因为还有群聊。所以需要引入会话(Conversation)概念。一个会话就是一个聊天窗口,单聊是两个人共享一个会话,群聊是多个人共享一个会话。这样设计的好处是:消息只挂在会话下面,不关心到底是单聊还是群聊,前端打开一个会话就能拉到完整聊天记录。
CREATE TABLE `conversation` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=单聊 2=群聊', `name` VARCHAR(64) DEFAULT '' COMMENT '群聊名称,单聊为空', `owner_id` INT UNSIGNED DEFAULT NULL COMMENT '群主ID,单聊为空', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `conversation_member` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `conversation_id` INT UNSIGNED NOT NULL, `user_id` INT UNSIGNED NOT NULL, `joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_conv_user` (`conversation_id`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;消息表是整个项目里最核心的表。字段包括消息 ID、会话 ID、发送者 ID、消息类型、消息内容、客户端消息 ID、创建时间。关键点有两个。第一,消息内容用 TEXT 就够了,单条消息一般不会超过几 KB,TEXT 类型最多存 64KB,聊天场景下绰绰有余。第二,必须加一个 client_msg_id 字段,这个字段是前端生成的 UUID,用来防止消息重发。网络抖动时前端没收到确认,会自动重发同一消息,服务端看到相同的 client_msg_id 就直接返回成功,不再重复入库。
CREATE TABLE `message` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `conversation_id` INT UNSIGNED NOT NULL, `sender_id` INT UNSIGNED NOT NULL, `msg_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=文本 2=图片 3=系统', `content` TEXT NOT NULL, `client_msg_id` VARCHAR(64) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_client_msg` (`sender_id`, `client_msg_id`), KEY `idx_conv_time` (`conversation_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;举一个会话查找的例子。A 要和 B 单聊,后端先查这两个用户的共同会话。SQL 可以这么写:先查出 A 参与的所有会话 ID,再查出 B 参与的所有会话 ID,取交集且会话类型为单聊。如果不存在就新建一个。这个查询频率很高,所以 conversation_member 表加了联合唯一索引,能保证数据不会重复。
2.3 离线消息表与已读标记
离线消息是即时通讯里绕不开的问题。用户 B 不在线时,A 发给 B 的消息不能丢,得先存起来等 B 上线后再推给他。我一开始的做法是单独建 offline_message 表,后来发现可以直接用消息表的已读字段代替。单聊场景下,消息表里加一个 receiver_id 和 is_read 字段,B 上线时执行“SELECT ... WHERE receiver_id=B AND is_read=0”,就能把离线期间的所有消息拉回来,再统一 UPDATE 成已读。
群聊场景稍微复杂一点,因为一条消息有多个接收人。如果每个人都在消息表里复制一条带独立已读标记的记录,数据量会爆炸。我的做法是群聊用一张独立的会话已读表(conversation_read)来记录每个用户最后已读的消息 ID。用户打开群聊窗口时,取“该会话中,消息 ID 大于自己最后已读 ID 的未读数量”,前端需要显示几秒就查哪条。这个方案数据量小、实现简单,就是查询时稍微绕一点。
对于单聊,我最终直接把 receiver_id 和 is_read 放进了消息表。因为单聊消息的接收人只有一个,离线拉取 SELECT 非常快,也不需要额外的 join。
3. WebSocket 连接管理与登录态打通:服务端代码的核心逻辑
数据库设计完,下一步是把 HTTP 登录态和 WebSocket 连接打通。这个问题不解决,后面发消息无从谈起。我用的方案是 token 认证,流程分成四步。
第一步,用户通过 POST /api/login 提交用户名和密码,服务端校验成功后,生成一个随机 token。这个 token 我用 uuid4 的 hex 值,同时保存在服务端内存字典里:key 是 user_id,value 是 token。第二步,前端拿到 token 存进 localStorage。第三步,前端创建 SocketIO 连接时把 token 放在连接参数里。第四步,服务端在 connect 事件里取出 token 和当前连接的 sid,校验通过后把 sid 记录到在线用户字典。
from flask import Flask, request, jsonify from flask_socketio import SocketIO, emit, join_room, leave_room from werkzeug.security import generate_password_hash, check_password_hash from models import db, User, Conversation app = Flask(__name__) app.config['SECRET_KEY'] = 'your-secret-key' socketio = SocketIO(app, cors_allowed_origins='*', async_mode='eventlet') # 内存态:生产环境建议用 Redis online_users = {} # user_id -> sid user_tokens = {} # user_id -> token sid_to_user = {} # sid -> user_id @app.post('/api/login') def login(): data = request.get_json() user = User.query.filter_by(username=data['username']).first() if user and check_password_hash(user.password_hash, data['password']): token = uuid.uuid4().hex user_tokens[user.id] = token return jsonify({'code': 0, 'token': token, 'user_id': user.id}) return jsonify({'code': 1, 'msg': '用户名或密码错误'}) @socketio.on('connect') def handle_connect(auth): token = auth.get('token') if auth else None user_id = find_user_by_token(token) if not user_id: return False # 如果该用户已在别处登录,顶掉旧连接 old_sid = online_users.get(user_id) if old_sid: socketio.emit('kicked', to=old_sid) leave_room(old_sid) online_users[user_id] = request.sid sid_to_user[request.sid] = user_id同账号多端登录的处理,参照微信网页版的交互:新连接建立后,旧连接收到 kicked 事件并断开。这个逻辑要在 connect 事件里做,不能放在连接建立之后,因为旧的连接还持有用户 ID 映射,如果不清理,会出现给旧连接发消息但对方早已不在线的情况。
心跳保活机制也很重要。WebSocket 虽然是长连接,但中间可能有路由器、交换机把空闲连接当作僵尸连接清理掉。Flask-SocketIO 自带 ping/pong 机制,只需在初始化时配置 ping_interval 和 ping_timeout:
socketio = SocketIO( app, cors_allowed_origins='*', async_mode='eventlet', ping_interval=25, ping_timeout=10 )意思是每 25 秒服务端发送一次 ping,如果 10 秒内没收到客户端的 pong 响应,就判定连接已断开,服务端自动清理 sid 映射。前端 SocketIO 客户端也有自动重连机制,断线后默认 1 秒开始重连,配合后端的断开清理,用户体验基本无感。
还有一个细节:登录和注册接口要限流。不加限制的话,登录接口很容易被暴力尝试。简单做法是用内存字典记录每个 IP 的失败次数,超过阈值直接拒绝。虽然个人项目不至于被攻击,但加上这个习惯,以后做正式项目不容易漏。
4. 消息收发链路:从输入框到对方屏幕的完整流程
消息收发是这个项目的心脏。整体链路可以概括为:前端 A 输入消息,通过 WebSocket 发送到服务端,服务端校验并写入 MySQL,然后查询接收方是否在线,在线则推给接收方,不在线则依赖离线消息表等接收方上线再拉取。下面拆开讲每一步。
4.1 单聊消息发送的事件处理
服务端定义 send_message 事件,接收三个关键参数:conversation_id、content、client_msg_id。收到事件后先根据 sender_id 和 client_msg_id 去重,如果已经存在则直接返回去重结果,避免前端重复提交导致两条相同消息。
@socketio.on('send_message') def handle_send_message(data): sender_id = sid_to_user.get(request.sid) if not sender_id: return emit('error', {'msg': '未登录'}) conv_id = data['conversation_id'] content = data['content'].strip() client_msg_id = data['client_msg_id'] if not content: return # 去重:之前已成功发送过相同 client_msg_id existing = Message.query.filter_by( sender_id=sender_id, client_msg_id=client_msg_id).first() if existing: return emit('message_ack', {'client_msg_id': client_msg_id}) msg = Message( conversation_id=conv_id, sender_id=sender_id, msg_type=1, content=content, client_msg_id=client_msg_id ) db.session.add(msg) db.session.commit() # 给发送方发送 ACK,通知前端可以停止重试 emit('message_ack', {'client_msg_id': client_msg_id, 'message_id': msg.id}) # 推送给会话内其他成员 members = ConversationMember.query.filter_by(conversation_id=conv_id).all() for m in members: if m.user_id == sender_id: continue target_sid = online_users.get(m.user_id) if target_sid: socketio.emit('new_message', { 'conversation_id': conv_id, 'sender_id': sender_id, 'content': content, 'client_msg_id': client_msg_id, 'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S') }, to=target_sid)这里有几个容易做错的点。第一,ACK 机制必须有。WebSocket 不保证消息 100% 送达,网络抖动时事件可能丢失。前端发送消息后如果没收到 message_ack,就自动重发,并且重发时携带相同的 client_msg_id,服务端靠这个字段去重。没有了这层机制,弱网环境下用户会看到重复消息。
第二,推送给接收方时,不能直接把 sender_id 当参数拼进事件里。我一开始偷懒,直接把 sender_id 从客户端传来的参数里取,结果发现任何人都可以伪造 sender_id 冒充别人发消息。正确做法是永远从 sid_to_user 映射里拿当前连接的 user_id,客户端传的 sender_id 一律不信任。这是所有聊天服务端最基本的安全底线。
第三,消息落库和推送的顺序必须是“先落库再推送”。如果先推给接收方再写数据库,接收方虽然能立刻看到消息,但刷新页面后消息可能还没入库,历史记录就丢了。先行写入数据库,即使推送失败,接收方下次上线也能通过离线消息拉回来。
4.2 离线消息的补拉策略
离线消息用两种方式配合实现。单聊:登录后前端主动调用 HTTP 接口 GET /api/messages/offline,服务端查询该用户所有 is_read=0 的单聊消息,返回后前端把它们渲染到对应会话,然后调用已读接口把这些消息标记为已读。群聊:前端获取每个群会话 member_ids,反向拼接出“哪些人是你自己”,再对每个会话查询最后已读 ID 之后的消息。
离线拉取的接口要支持批量和一个会话分页,前端进入一个会话时默认只加载最近 50 条,往上滚动时按 ID 倒序翻页。翻页查询需要注意排序问题:用 id 排序比用 created_at 排序更稳定,因为同一秒内可能插入多条消息,created_at 相同会导致排序不稳定。时间戳只用于显示,排序和游标都用 ID。
4.3 消息已读上报设计
已读上报的常见做法有两种。一种是快速上报:用户打开会话窗口且页面处于前台时,前端立即发送 read_message 事件,把所有该会话中接收人为自己且未读的消息标记已读。另一种是延迟合并:用户快速滑动多条消息时,可能 1 秒内触发几十次已读请求,为了减少数据库压力,前端做 300ms 的节流,只上报最后一条已读消息 ID,服务端把小于等于该 ID 的消息全部标记已读。
我建议用第二种,因为实现只比第一种多 10 行代码,但对数据库的压力成倍降低,而且后续做服务端性能优化时这是很重要的习惯。已读消息在 UI 上的体现,就是聊天气泡的右侧出现“已读”两个字,或者会话列表中未读角标消失。
5. 仿微信网页版界面的核心交互实现
前端是这个项目的门面。很多人只会写后端,前端零零散散拼不出一个能看的界面。我的做法是只用原生 HTML + CSS + JavaScript,外加 SocketIO 的浏览器客户端,不引入 Vue、React 这些框架。原因很简单:这个项目的前端复杂度没有高到需要框架,原生代码反而更容易让读者理解每一步在干什么。
5.1 页面布局与登录流程
页面整体分三栏。左侧是会话列表栏,从上到下是当前用户的头像和昵称,然后搜索框,下面是每个会话的摘要卡片,包括会话名称、最后一条消息内容和时间。中间是聊天栏,顶部显示当前会话名称,中间是消息气泡区,底部是输入框和发送按钮。右侧是好友信息栏,显示当前会话对象的头像、昵称、个性签名。移动端可以隐藏右侧栏,这里不展开。
登录页单独做一个页面,表单里只有用户名、密码两个输入框和一个登录按钮。登录成功后把 token 和 user_id 存进 localStorage,然后跳转到主页面。主页面加载时先检查 localStorage 是否有 token,没有就跳回登录页。刷新页面时 WebSocket 会短暂断开再重连,这时要重新拉取好友列表、会话列表和离线消息,保证断线重连后界面数据是完整的。
5.2 SocketIO 事件约定与消息渲染
前端连接 SocketIO 时,要把 token 放在 auth 字段里,之后所有事件都在这个连接上走。消息渲染是最容易写乱的部分。我定义了一个 render_bubble 函数,根据消息的 sender_id 是否等于当前登录用户来决定气泡靠左还是靠右。靠右的是自己发的,底色填充用高亮色;靠左的是对方发的,用白色背景。头像放在气泡外侧,自己的头像在右侧,对方的头像在左侧,这是一个非常成熟的聊天界面惯例。
const socket = io({ auth: { token: localStorage.getItem('token') } }); socket.on('new_message', (data) => { const isSelf = data.sender_id === currentUserId; const userName = isSelf ? '我' : data.sender_name; renderBubble({ align: isSelf ? 'right' : 'left', userName, content: data.content, time: data.created_at }); updateConversationList(data.conversation_id, data.content, data.created_at); }); function renderBubble({ align, userName, content, time }) { const bubble = document.createElement('div'); bubble.className = `bubble-wrap ${align === 'right' ? 'bubble-right' : 'bubble-left'}`; bubble.innerHTML = ` <div class="avatar">${userName[0]}</div> <div class="bubble-content"> <div class="bubble-name">${userName}</div> <div class="bubble-text">${escapeHtml(content)}</div> <div class="bubble-time">${time}</div> </div>`; document.querySelector('.message-area').appendChild(bubble); scrollToBottom(); }渲染时一定要做 HTML 转义,防止用户输入<script>之类的内容导致 XSS。我的 escapeHtml 函数会把<、>、&、"、'替换成实体字符。这是聊天项目里绝对不能省略的安全步骤。另一个细节是发送消息后输入框要立即清空,但是消息要显示成“发送中”状态,等服务端返回 message_ack 后再把状态改成“已发送”。如果 3 秒没收到 ACK,气泡状态变成“重试”,点击可以手动重发。
5.3 会话列表更新与新消息提醒
收到 new_message 事件时,除了在当前会话渲染气泡,还要更新左侧会话列表。会话列表项包含会话名称、最后一条消息内容、最后一条消息时间。如果新消息来自当前没有打开的会话,需要给该会话的未读计数加 1,并显示一个红色角标。打开会话时再清零角标,同时发送已读上报。
这里要注意,如果当前正在浏览的会话有新消息进来,不应该增加未读角标,因为用户已经看到这条消息了。所以前端需要维护一个 currentConversationId 变量,new_message 事件处理时判断 data.conversation_id 是否等于当前值,等于则不增加角标。
新消息提醒可以做一个浏览器标题栏闪烁。每隔 1 秒把 document.title 切换成“新消息”和原标题,用户在其他标签页停留时也能看到提示。这个功能虽然小,但对体验提升很明显。
6. 实测中遇到的那些坑:emoji 乱码、连接池耗尽、消息丢失
项目跑通以后,真正的学习才开始。我把自己遇到的高频问题整理一下,这些都是教程里不常写但很容易踩的坑。
6.1 emoji 存入 MySQL 报错
第一次测试给好友发“你好 👋”时,服务端打印了类似 “Incorrect string value: '\xF0\x9F...'” 的报错。原因是 MySQL 的 utf8 字符集实际上最多只支持 3 字节,而 emoji 是 4 字节的 UTF-8 编码,必须使用 utf8mb4 字符集。解决办法有三步:建库时指定 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;数据库连接串加上 charset=utf8mb4 参数;如果表已经建好了,单独把表改成 utf8mb4。
ALTER TABLE message CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;还有一个小坑是 MySQL 连接的超时设置。MySQL 默认 wait_timeout 是 8 小时,但数据库连接池里的连接如果长时间空闲,会被服务端断开。下次从连接池取出连接时,SQLAlchemy 还认为它有效,实际查询就会报 “Lost connection to MySQL server during query”。解决方法是把连接池的 pool_pre_ping 设置为 True,每次取连接前先做一次轻量 ping。
6.2 SQLAlchemy 连接池耗尽导致全站卡死
个人项目并发不高,但开发调试阶段很容易把连接池打满。原因通常是事务没有提交或回滚。比如我在发送消息的逻辑里,先查询了会话成员,再插入消息,最后 commit。如果中途因为字段校验失败抛出异常,异常没有被 catch 住,事务就卡在那里没有回滚,连接的占用没有释放。调试时反复触发异常,连接池 10 个连接全部耗尽,后续所有数据库操作都排队等待,表现为整个服务“假死”。
解决方法是:所有涉及数据库写操作的接口,必须用 try/except 包住,except 里执行 db.session.rollback()。这个习惯一开始就要养成,不要等项目出问题再补。另外 SQLAlchemy 连接池可以配置 pool_size=5, max_overflow=10, pool_recycle=3600,避免连接被 MySQL 服务端回收后仍被复用。
6.3 消息推送丢失与重发机制
WebSocket 推送消息时,偶尔会出现服务端明明调用了 socketio.emit,但接收方前端没有收到的事件。原因有很多:接收方页面在后台被浏览器挂起、网络切换导致 TCP 连接断开但没有立即触发 close 事件、或者推送时接收方的 sid 已经失效但 online_users 字典没有及时清理。
靠“调用了 emit 就认为推送成功”是不行的。我给每次推送加了一个服务端的等待确认机制:服务端推送 new_message 后,前端收到事件就回一个 message_received 事件;如果服务端 5 秒内没有收到确认,就把消息写入离线表。这个机制和 ACK 机制配合,逻辑上能保证消息不丢。当然对于个人项目,有点杀鸡用牛刀的感觉,但理解这个链路对以后做分布式系统很有帮助。
6.4 时间字段的时区混乱
我一开始直接用 datetime.now() 存时间,结果部署到另一台服务器时发现用户看到的时间比本地快了 8 小时。原因是 MySQL 会话时区和应用服务器时区不一致。统一的做法是:数据库连接串里加 init_command=--default-time-zone='+08:00',或者干脆全部用 UTC 存储,前端拿到时间戳后转本地时区显示。我选择了后者,因为前端转换更方便,而且不会因为服务器迁移导致数据混乱。
消息时间显示还有一个体验细节:如果是今天的消息,只显示“HH:MM”;如果是昨天,显示“昨天 HH:MM”;更早的显示“M月D日 HH:MM”。这个逻辑写一个 format_time 函数,别的项目里也能复用。
7. 做完这个项目的下一步:文件传输、语音视频与部署上线
跑通单聊之后,整个架构的骨架已经立起来了。往上面加功能,代码结构不需要大改,只需要增加事件类型和数据表字段。我列几个比较典型的方向。
第一个方向是图片和文件传输。消息表里已经预留了 msg_type 字段,2 代表图片,3 代表文件。前端发送文件时先把文件传到服务端的一个上传接口,返回一个 URL 和文件大小,再通过 WebSocket 发送一条 msg_type=2 的消息,内容里包含 URL。接收方渲染气泡时,根据 msg_type 决定是显示文本还是图片标签。头像和图片都属于用户生成内容,生产环境应该放到对象存储,只在 MySQL 里存 URL。
第二个方向是语音和视频通话。WebSocket 本身不适合传实时音视频流,标准做法是用 WebRTC 做 P2P 通话,WebSocket 只负责交换信令。信令的逻辑就是:A 调用服务端的 call_user 事件,服务端把它转发给 B,B 同意后把 answer 再通过服务端传给 A。这套逻辑写起来并不复杂,难的是 STUN/TURN 服务器的维护。个人项目可以用公共 STUN 服务器测试,NAT 穿透失败时再搭一个局域网 TURN 服务。
第三个方向是部署上线。Flask-SocketIO 的部署注意一点:不能用 Gunicorn 的默认 worker,因为默认 worker 不支持 WebSocket 长连接。正确的组合是 Nginx + Gunicorn + eventlet worker。配置示例如下:
gunicorn -k eventlet -w 1 -b 0.0.0.0:5000 server:appNginx 需要配置 Upgrade 头转发:
location /socket.io/ { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400; }注意 Gunicorn 的 worker 数要设为 1。eventlet 模式下每个 worker 是协程并发,不需要多 worker。如果开多个 worker,SocketIO 的内存状态(比如 online_users 字典)会分散在各个进程里,导致消息推不到正确的用户。这一点很多人上线时才发现。
我个人做完这个项目的最大体会是:聊天系统并不难,难的是把数据的流向想清楚。从数据库、接口、WebSocket 到前端渲染,每一层都有边界。你不需要一开始就用 Redis、消息队列这些重型组件,但必须理解“消息从 A 到 B 要经过哪几步、哪一步失败了怎么办”。把这些问题在纸上画一遍,代码写起来会顺手很多。如果有时间,我建议你把这个项目从头搭一遍,哪怕是简化版。踩过这些坑之后,你会发现自己对 Web 开发里“连接”和“数据一致性”的理解会比看十遍教程都深刻。