做这个“python + mysql”的即时通讯项目,我自己是挺有感触的。当年刚把Python基础语法啃完,想找点东西练手,数据库不会、Web框架也不熟,就硬着头皮选了“仿微信网页版”这个题目。结果一路做下来,从一条SQL都不会写,到能跑通注册、登录、加好友、发消息的完整流程,这个项目带给我的东西其实比想象中多得多。它能帮你把Python语法、MySQL操作、HTTP接口、前端交互这些散装知识点,全部串成一条主线。
这篇文章,我就拿这个“即时通”Demo来复盘。我会把整个项目拆开讲清楚:数据库的表应该怎么设计、后端接口怎么组织、前端怎么把消息刷出来,以及我在实际开发中踩过的坑和排查思路。不管你是刚学完Python基础想找个综合项目巩固,还是想快速搭一个局域网内可用的聊天工具,这篇文章应该都能给你一些参考。
1. 项目要做成什么样:核心需求与功能拆解
动手写第一行代码之前,我先把需求想明白了。很多人做项目喜欢一上来就写代码,写到中途发现这里缺个表、那里少个字段,回头再改就非常痛苦。这个项目虽然是个Demo,但“麻雀虽小五脏俱全”,功能边界得先在脑子里过一遍。
1.1 功能清单:从登录到聊天的完整闭环
我当时给自己定的核心功能就四个:用户能注册账号,能登录进去,能看到自己的好友列表,能跟某个好友收发消息。
看起来简单,但仔细拆一下,每个功能背后都有隐藏需求。比如注册,你得考虑密码怎么存才安全、用户名重复了怎么提示、注册成功之后是自动登录还是跳回登录页。比如登录,你要不要记住登录状态?刷新页面之后用户还在不在线?再比如好友列表,你登录之后是每次刷新页面都要重新查一次数据库,还是启动的时候就拉一次存到前端变量里?
我不建议在这个阶段把需求盖得太复杂,什么群聊、语音、图片传输、朋友圈,全是坑。Demo阶段就把“一对一文字聊天”跑通,让用户能A登录、B登录,然后A发消息B能收,这个闭环就够了。把消息收发这个链路打通,后续加群聊、加文件传输,都是在一个已经验证过的骨架上做加法。
1.2 技术选型:为什么是Python + MySQL,而不是别的组合
选Python做后端,是因为它对新手最友好——不是因为它最牛。市面上Python的Web框架选Flask还是FastAPI,我最后用了Flask,原因在于Flask足够轻,路由写起来直观,没有太多“魔法”需要去理解,教程也遍地都是。FastAPI性能更好、自带接口文档,但它的异步特性、Pydantic模型校验这些概念对还没入门的我来说,会增加额外认知负担。做第一个综合项目,应该把复杂度控制在“刚好能理解”的程度,而不是追逐技术热度。
数据库选MySQL,主要是因为当时想学点真正在生产环境里用得上的东西。SQLite当然更简单,文件直接存本地,但它是文件型数据库,并发写锁、权限控制、网络访问都不如MySQL成熟。这个项目做完之后我想把它部署到一台Linux服务器上给宿舍同学用,MySQL在服务端的管理、备份、远程连接上都比SQLite省心得多。
技术栈就是:Python 3 + Flask + PyMySQL + 原生JavaScript + MySQL 8.x。HTML和CSS用最简单的写法,前端不引框架,因为引了框架你就得学框架的语法,而项目的重心应当放在“数据是怎么流动的”上面。
1.3 项目目录结构与代码组织
代码组织这块,我踩过教训。一开始我把所有代码写在一个文件里,路由、数据库操作、HTML模板全揉在一起,写完登录功能想去改一个查好友的SQL,在那个上千行的文件里来回拖着找。后来痛定思痛,按功能拆成了这样:
im_project/ ├── app.py # Flask应用入口,路由注册 ├── db.py # 数据库连接与公共查询函数 ├── models/ │ ├── __init__.py │ ├── user.py # 用户模块的SQL操作 │ ├── friend.py # 好友模块的SQL操作 │ └── message.py # 消息模块的SQL操作 ├── static/ │ ├── css/ │ │ └── style.css # 页面样式 │ └── js/ │ ├── login.js # 登录注册页面逻辑 │ └── chat.js # 聊天主界面逻辑 └── templates/ ├── login.html # 登录/注册页 └── chat.html # 聊天主页面在这个结构里,app.py只负责定义接口和调用models里的函数,具体SQL怎么拼、数据怎么组织,都下沉到models层。前端和后端通过JSON格式的接口数据对接。这样拆完之后的体会是,改一个模块的代码时不用小心翼翼担心碰到另一个模块,这就是分层带来的安全感。
2. 数据库设计:系统的大头全在表结构里
聊天的核心无非是“谁跟谁是好友”“谁给谁发了什么消息”。这两句话落到MySQL里,就是三张核心表:用户表、好友关系表、消息表。我最初设计的时候还加了一张“会话列表表”,后来发现它可以通过消息表动态查出来,就砍掉了,这也算是做减法的一个经验——表不是越多越好,能通过查询逻辑解决的,就不要用额外的表去冗余存储。
2.1 三张核心表:用户、好友、消息
用户表是最基础的一张表。用户名要唯一,密码不能存明文,注册时间记录下来以后可能要做排序或者统计。好友关系表是典型的“多对多关系”的中间表,存的是“谁的ID加谁的ID成为了好友”,注意这不是单向的,A加B成功,那B的好友列表里也得能看到A。消息表负责记录每一次发送的内容,发送者、接收者、消息正文、发送时间、是否已读,这些字段是聊天系统的命根子。
这三张表的关系可以这样理解:用户表是字典里的词条,好友关系表记录词条之间“互相认识”的关系,消息表则是“互相认识”的人之间说的话的流水账。任何聊天系统,不论做得多复杂,底层都逃不开这三个概念。
2.2 建表SQL与关键字段的取舍说明
我在MySQL里执行的建表SQL大致如下。有些细节值得单独拎出来说说。
CREATE DATABASE IF NOT EXISTS im_system DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE im_system; CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(64) NOT NULL, salt VARCHAR(32) NOT NULL, nickname VARCHAR(50) DEFAULT '', avatar_url VARCHAR(200) DEFAULT '', status TINYINT DEFAULT 1 COMMENT '1在线 0离线', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_friendship ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, friend_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_friend (user_id, friend_id), KEY idx_friend_id (friend_id), CONSTRAINT fk_friend_user FOREIGN KEY (user_id) REFERENCES t_user(id), CONSTRAINT fk_friend_friend FOREIGN KEY (friend_id) REFERENCES t_user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, sender_id INT NOT NULL, receiver_id INT NOT NULL, content TEXT NOT NULL, msg_type TINYINT DEFAULT 1 COMMENT '1文本 2图片', is_read TINYINT DEFAULT 0 COMMENT '0未读 1已读', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_sender_receiver (sender_id, receiver_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个关键选择我得解释解释。第一,数据库和所有表都统一用utf8mb4字符集,而不是大多数教材里常见的utf8。为什么?因为utf8mb4才是完整的UTF-8编码,它不光支持中文,还支持emoji表情。如果你用了utf8,用户发一个笑脸表情过来,SQL插入直接报错,那一瞬间你会怀疑人生。第二,消息表的主键用了BIGINT,因为消息量起来之后INT不够用,一张表存几十亿条消息对INT来说不是危言耸听。第三,好友关系表里加了一个UNIQUE约束保证同一对好友不会重复记录,程序层也加了判断,双保险防止脏数据。
2.3 数据库连接层的写法与字符集设置
光建好表不够,Python程序连上去如果字符集不对,中文读写全是乱码。连接数据库时,我习惯把charset显式指定为utf8mb4,并在连接后执行SET NAMES utf8mb4。这一步很多时候是排查乱码问题的最后一块拼图。
# db.py import pymysql DB_CONFIG = { "host": "127.0.0.1", "port": 3306, "user": "root", "password": "your_password", "database": "im_system", "charset": "utf8mb4" } def get_conn(): conn = pymysql.connect( cursorclass=pymysql.cursors.DictCursor, **DB_CONFIG ) return conn关于连接管理,我一开始是每执行一次查询就创建一个新连接,用完就关。这个方案低并发下没什么问题,但在轮询机制下,每个前端用户每2秒就会请求一次接口,连接反复创建销毁,MySQL的资源消耗很明显。后来我引入了简单的连接复用机制:把连接对象放在一个全局变量里,发现连接断开就重新创建,同时设置wait_timeout,避免空闲连接被MySQL服务端强行关闭。这个体验贯穿了整个开发过程,后面排查问题的章节里我再详细展开。
3. 后端接口:聊天系统的逻辑骨架
设计后端接口时,我给自己定了一个原则:所有接口返回统一格式的JSON,结构是{"code": 0, "msg": "...", "data": {...}}。code为0表示成功,非0表示各种错误。这样前端只需要判断code,不用去解析各种奇怪的返回体,写起来非常省心。
3.1 注册登录与密码安全:不能明文存密码
注册登录是一个Web系统的门户,密码处理是安全底线。我虽然清楚这个Demo不会真的被攻击,但还是用了“加盐哈希”的方式存密码,而不是明文。具体做法:用户注册时,用Python的secrets模块生成一个32位的随机字符串作为盐,将密码字符串拼上盐,用SHA256哈希得到64位的哈希值,把这两者都存进数据库。登录验证时,取出该用户的盐,与输入密码拼在一起哈希,比对结果是否一致。
import hashlib import secrets def generate_salt(): return secrets.token_hex(16) def hash_password(password, salt): return hashlib.sha256((password + salt).encode("utf-8")).hexdigest()为什么不直接用MD5或SHA256把密码哈希一下再存?因为同样的密码会得到同样的哈希值,攻击者可以用彩虹表直接反查。加了随机盐之后,每一条密码的哈希都不同,防的是低成本的批量破解。我这里没有用更高级的加密库,是因为这个Demo的目标是理解原理,用hashlib手写一次加盐哈希的过程,比引一个库然后稀里糊涂地调用更能建立安全意识。
3.2 好友模块:关系是双向的,别只存一条
加好友的逻辑,看似是“A向B发送好友申请,B同意”。但我的Demo砍掉了申请流程,直接做成“A输入B的用户名,添加成功即互为好友”。问题就来了:如果我往t_friendship表里插一条(user_id=1, friend_id=2),那用户1的好友里能看到2,但用户2查询他好友的时候,如果用“user_id=2”去查,这条记录是不会出现的。这就是典型的单向存储坑。
解决方式是在业务层做一个“镜像插入”:加好友时同时插入两条记录:(1,2)和(2,1)。这样无论从哪一侧查询,都能正确拿到好友列表。你可能会问,为什么不查询时用OR条件一次查出来?比如SELECT * FROM t_friendship WHERE user_id=2 OR friend_id=2,这样也是可以的,但需要处理“谁是对方”的字段映射问题,写起来绕且容易出错。做镜像插入,牺牲了一倍的存储空间,换来了查询逻辑的简单直观,对于这个量级的应用来说完全值得。
但要注意,镜像插入必须放在同一个数据库事务里。如果第一条插进去了第二条失败了,好友关系就变成了单向的,会出现灵异现象——“我能看到他,他看不到我”。Flask里用pymysql实现事务很简单:获取连接后,先conn.begin(),两条insert执行完后conn.commit(),任何一条失败就conn.rollback()。
3.3 消息收发逻辑:Demo选轮询,为什么不做WebSocket
聊天的核心消息收发,有两条技术路线。一条是WebSocket,服务端可以主动把新消息推给客户端,真正意义上的实时通信,但实现复杂度较高。另一条是HTTP轮询:前端每隔几秒发一个Ajax请求,问“有没有新消息?”,有就取回来。轮询看起来笨,但它的优点是逻辑极其直观,而且完全基于HTTP,不依赖额外的协议支持。
我当时选了轮询,绝不是因为WebSocket不好,而是因为对比之下,轮询让这个项目的门槛大幅降低。WebSocket涉及长连接管理、心跳机制、断线重连,任何一个点出错都很难调试。轮询的上限不高,但对于一个学习项目、一个最多几十人同时在线的小工具来说,完全够用。
实现方式是:前端聊天页启动一个setInterval,每2秒调用一次/poll接口,把当前接收者ID传过去,后端查出该用户发给当前登录用户、且未读的消息,返回给前端。前端拿到新消息后,再调一个标记已读的接口。等以后想升级WebSocket,轮询部分的业务逻辑完全不用改,只需要把传输层换掉而已。
3.4 核心接口代码逐段拆解
我写几个核心接口的代码,方便你理解整个逻辑的落地方式。
注册接口。
@app.route("/api/register", methods=["POST"]) def register(): data = request.get_json() username = data.get("username", "").strip() password = data.get("password", "") if not username or not password: return jsonify({"code": 1, "msg": "用户名和密码不能为空"}) conn = get_conn() try: with conn.cursor() as cursor: cursor.execute("SELECT id FROM t_user WHERE username=%s", (username,)) if cursor.fetchone(): return jsonify({"code": 1, "msg": "用户名已存在"}) salt = generate_salt() pwd_hash = hash_password(password, salt) cursor.execute( "INSERT INTO t_user (username, password_hash, salt, nickname) VALUES (%s, %s, %s, %s)", (username, pwd_hash, salt, username) ) conn.commit() return jsonify({"code": 0, "msg": "注册成功"}) except Exception as e: conn.rollback() return jsonify({"code": 1, "msg": f"注册失败: {str(e)}"}) finally: conn.close()登录接口。登录成功之后,我用Flask自带的session机制来维护状态,把user_id和username放进session。这里有一个细节,Flask的session是需要SECRET_KEY的,我配置了一个随机字符串,用于给session签名的cookie加密。
@app.route("/api/login", methods=["POST"]) def login(): data = request.get_json() username = data.get("username", "") password = data.get("password", "") conn = get_conn() try: with conn.cursor() as cursor: cursor.execute("SELECT * FROM t_user WHERE username=%s", (username,)) user = cursor.fetchone() if not user: return jsonify({"code": 1, "msg": "用户不存在"}) pwd_hash = hash_password(password, user["salt"]) if pwd_hash != user["password_hash"]: return jsonify({"code": 1, "msg": "密码错误"}) session["user_id"] = user["id"] session["username"] = user["username"] return jsonify({"code": 0, "msg": "登录成功", "data": {"id": user["id"], "nickname": user["nickname"]}}) finally: conn.close()发送消息接口。这里我觉得特别值得留意的是,消息表里的sender_id和receiver_id都是从session和请求参数里取的,一定要从会话中获取当前登录用户的身份,不能相信前端传过来“我是谁”。前端是可以伪造的,如果信任它,就会出现用户A伪造成用户B给别人发消息的安全漏洞。
@app.route("/api/send", methods=["POST"]) def send_message(): user_id = session.get("user_id") if not user_id: return jsonify({"code": 1, "msg": "未登录"}) data = request.get_json() receiver_id = int(data.get("receiver_id", 0)) content = data.get("content", "").strip() if not receiver_id or not content: return jsonify({"code": 1, "msg": "参数不完整"}) conn = get_conn() try: with conn.cursor() as cursor: cursor.execute( "INSERT INTO t_message (sender_id, receiver_id, content) VALUES (%s, %s, %s)", (user_id, receiver_id, content) ) conn.commit() return jsonify({"code": 0, "msg": "发送成功"}) finally: conn.close()轮询新消息接口。这里做的事情是查询所有发给当前登录用户、且未读的消息,查出之后立刻把它们标记为已读。有同学可能会问,为什么不返回之后由前端调一个单独的道标记已读接口?两条接口分开的好处是语义清晰,但坏处是多一次请求、存在又标记不到的时窗口。我选择在查询接口直接标记已读,因为在这个Demo里,查出来的消息默认就是“客户端已经收到了”。
@app.route("/api/poll") def poll_messages(): user_id = session.get("user_id") if not user_id: return jsonify({"code": 1, "msg": "未登录"}) # 可选参数:只看某个好友发来的消息 friend_id = request.args.get("friend_id", type=int) conn = get_conn() try: with conn.cursor() as cursor: if friend_id: sql = """ SELECT id, sender_id, receiver_id, content, created_at FROM t_message WHERE receiver_id=%s AND sender_id=%s AND is_read=0 ORDER BY id ASC """ params = (user_id, friend_id) else: sql = """ SELECT id, sender_id, receiver_id, content, created_at FROM t_message WHERE receiver_id=%s AND is_read=0 ORDER BY id ASC """ params = (user_id,) cursor.execute(sql, params) messages = cursor.fetchall() ids = [m["id"] for m in messages] if ids: cursor.execute( "UPDATE t_message SET is_read=1 WHERE id IN ({})".format(",".join(["%s"] * len(ids))), ids ) conn.commit() return jsonify({"code": 0, "data": messages}) finally: conn.close()好友列表接口
@app.route("/api/friends") def friend_list(): user_id = session.get("user_id") if not user_id: return jsonify({"code": 1, "msg": "未登录"}) conn = get_conn() try: with conn.cursor() as cursor: cursor.execute(""" SELECT u.id, u.username, u.nickname, u.status FROM t_friendship f JOIN t_user u ON f.friend_id = u.id WHERE f.user_id = %s """, (user_id,)) friends = cursor.fetchall() return jsonify({"code": 0, "data": friends}) finally: conn.close()这个接口要用JOIN把好友ID转化为用户信息,因为好友关系表里存的是数字ID,前端列表要显示的是用户名和昵称。JOIN是SQL里一个绕不过去的概念,这个项目正好是个活教材——不写这个JOIN,你就得在Python里循环查用户表,效率差一个数量级。
4. 前端页面:把后端能力变成能点的界面
前端部分我尽量做到“够用就行”,但有几个体验细节不能省。仿微信网页版的布局,核心是左侧一个竖条好友列表,右侧一个聊天窗口。底部是输入框和发送按钮。
4.1 页面骨架:仿微信网页版的左右布局
登录页和聊天页是两个独立的HTML。登录页内容少,居中放一个表单就行,用户名、密码、登录按钮、注册按钮各占一行。这里有个用户体验细节:默认回车焦点在登录按钮上,用户输入完密码直接回车就能登录,不用去点鼠标。聊天页的骨架用Flex布局,左侧固定宽度280px,右侧自适应。左侧上半部分放当前登录用户信息,下半部分滚动的好友列表。右侧从上往下是聊天头部(显示当前聊天对象)、消息区域(滚动)、输入区域。
消息区域的滚动有个小技巧:默认滚动条定位到底部,每次新消息插入后,通过scrollTop = scrollHeight把滚动条拉到底。特别坑的问题是,如果旧消息比较长,用户想往上翻历史记录,这个无脑拉底策略就会反复打断。我的解决方式是加一个判断:如果用户滚动条距离底部小于30px才自动拉底,否则不干预。
4.2 登录注册页的关键交互
登录和注册共用同一个表单,但有两个状态。点击“去注册”时,按钮文字变为“注册”,再点“去登录”切回。这个交互本身不复杂,关键是表单提交要用fetch异步发送,而不是form表单原生提交。因为原生提交会刷新页面,而我们需要根据后端返回的code来判断成功还是失败,错误时要显示提示信息而不是跳转。
登录成功之后,把后端返回的用户昵称和ID存到sessionStorage里,然后前端跳转到chat.html。这里存在一个安全隐患我提一下:页面跳转时,session里已经有了登录状态,chat.html加载后第一个请求就是拉好友列表,这个请求会携带cookie,后端因此能识别身份。千万不要把用户ID明文放在URL参数里,URL会留在浏览器历史里,别人用一下历史记录就能拿到你的登录态。
4.3 聊天主界面:消息列表、好友列表、输入框三件套
聊天主界面三个核心模块,全部由JavaScript动态生成,HTML里只放空容器。加载chat.html后,立即执行两个请求:拉好友列表、拉所有未读消息。好友列表渲染成左侧一列,每条好友项显示头像占位符和昵称。点击某个好友后,右侧聊天头部切换为这个好友的名字,消息区域清空,然后从数据库拉取“当前登录用户与这个好友之间的历史聊天记录”。
历史消息的查询接口和轮询接口是分开的。轮询只查未读消息,历史消息查所有。如果不分开,历史记录接口会越来越多地返回已读消息,浪费带宽不说,消息顺序也不好控制。
4.4 轮询脚本:前端最核心的一段逻辑
轮询是前端最核心的一段逻辑。我把它放在一个独立的函数里,启动时调用一次,然后用setInterval每2秒重复一次。请求带上friend_id参数,这样只拉当前聊天好友发来的新消息。
let currentFriendId = null; let timer = null; function startPolling() { timer = setInterval(pollNewMessages, 2000); } function pollNewMessages() { if (!currentFriendId) return; fetch("/api/poll?friend_id=" + currentFriendId) .then(res => res.json()) .then(data => { if (data.code === 0 && data.data.length > 0) { const msgArea = document.getElementById("message-area"); data.data.forEach(msg => { appendMessage({fromMe: false, content: msg.content, time: msg.created_at}); }); msgArea.scrollTop = msgArea.scrollHeight; } }) .catch(err => console.error("poll error:", err)); }发送消息的Ajax,发送成功后不等待轮询,而是直接把消息插入消息区域,形成一种“发送即显示”的即时反馈。这一点很关键,因为轮询周期是2秒,如果发一条消息要等2秒才出现在自己聊天框里,体验非常糟糕,给人的感觉就像系统卡了。
消息气泡样式上,我用左右两列来区分自己和对方。自己发的消息靠右,灰色气泡,对方发来的靠左,白色气泡。如果连续两条消息来自同一个人,就把气泡的上圆角稍微收小一点,视觉上形成分组感,这样聊天记录看起来不会像Excel表格那么生硬。这个效果用CSS的first-child和last-child实现,不复杂但很提升观感。
5. 联调测试与避坑实录
整个项目从零到跑通,我前后折腾了很久。如果让我重新走一遍,环境搭建比我想象中更考验耐心,因为雷基本都埋在“你以为很简单”的地方。
5.1 环境搭建与启动全流程
我的环境是Windows本机加一个自己装的MySQL 8.x,Python 3.10。装Python依赖其实就两个核心包:Flask和PyMySQL。先建虚拟环境再装依赖,这是管理项目依赖的好习惯,不然装多了就会跟我第一次一样,后面想迁移机器时发现依赖清单根本理不清。
python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install flask pymysql python app.pyFlask默认跑在127.0.0.1:5000,本机测试没问题。如果想让局域网内的其他设备访问,把app.run(host="0.0.0.0", port=5000)监听所有网卡,同时关闭本机的防火墙端口屏蔽。这个过程中有一点需要注意,MySQL默认只监听localhost,如果数据库和Web应用不在同一台机器,必须在MySQL配置里调整bind-address,并给应用单独创建一个账号,授以最小权限。千万别用root远程连接,生产环境里这是大忌。
5.2 踩过的坑一:中文乱码
中文乱码是我遇到的第一个拦路虎。注册一个用户名为“张三”,数据库里存出来是“???”。排查路径是这样的:先看MySQL控制台里执行insert语句,中文正常,说明数据库字符集没问题。再看Python里print查出来的数据,打印正常,说明Python和MySQL交互时也没问题。那问题出在哪?后来发现是连接参数里没加charset="utf8mb4"。pymysql和MySQL之间,连接时如果没有指定字符集,就用MySQL默认的latin1,中文经过一次错误的字符集转换就变成了问号。把charset参数加上之后,乱码问题就解决了。
这个案例给我的体会是,排查乱码问题时要分层次:数据库本身的字符集、表字符集、连接字符集、HTTP响应字符集,一层层确认。如果只是数库表建错了,改表比改连接更麻烦;但大多数时候问题出在连接这一层。
5.3 踩过的坑二:MySQL连接超时与连接数问题
MySQL有一个wait_timeout配置,默认是8小时。这意味着一个连接如果8小时没有活动,服务器端会主动断开它。Python这边全局复用的连接对象还傻傻地认为它是活的,一执行查询就收到一个已关闭连接的错误。
我的解决思路是,在执行查询之前先ping一下连接,MySQL的ping命令会探测连接是否可用,如果抛异常就重新创建连接。封装一个get_cursor上下文管理器,把无效连接的重建过程隐藏起来,业务代码里完全不用关心底层连接是否失效。
def query(sql, params=None): conn = get_conn() try: with conn.cursor() as cursor: cursor.execute(sql, params) return cursor.fetchall() except pymysql.err.OperationalError as e: if e.args[0] == 2006: # MySQL server has gone away conn = get_conn() with conn.cursor() as cursor: cursor.execute(sql, params) return cursor.fetchall()另外,每来的请求都开一个新的MySQL连接,在高并发轮询下会迅速打满连接数。Flask的debug模式里每台浏览器每次变化都可能发起多个并发请求,再加上我自己多个页面同时测试,连接数瞬间飙到几百。在连接使用完必须及时发现的问题之外,连接池是最优解,但Demo阶段注意“及时关闭连接”这个习惯,也是一种可行的思路。连接到机器的吃紧提醒了我,写代码时不要图省事在代码里创建连接后不关。
5.4 踩过的坑三:消息重复与轮询时序问题
轮询机制下,消息重复是一个典型的时序Bug。场景是这样的:使用者的前端脚本发出poll请求,请求还没返回时,定时器又触发了第二次poll请求。服务器把同一批未读消息返回给了这两个请求。由于第一次返回之后,前端已经调用标记已读接口,但如果在标记已读的commit还没完成时第二个查询已经执行了,它仍然会查到那些未读消息,于是前端就把这些消息插入了两次。
我解决的办法有三个层面。第一,标记已读不在查询接口外通过单独的接口执行,而是直接放在poll接口里查询后立即更新。让查和改在一个事务里完成,从根上消除这种竞态。第二,前端插入消息时加一个消息ID的set集合去重,重复的ID直接跳过。第三,渲染消息时后端返回者返回一个global_msg_seq字段,前端发现消息序号不比本地最新序号大就丢弃。三层保险下来,实际测试中消息重复的问题基本消失了。
6. 后续扩展思路:从Demo到能用的小产品
项目做到这里,基本闭环已经跑通。但我要诚实地说,这个Demo距离“能用”,中间还有一段路要走。
第一个该做的扩展是消息历史分页。现在所有历史消息一次查出来渲染,消息一多页面就卡,滚动也越来越迟钝。正确做法是进入聊天界面时先查最近20条,用户滚动到顶部时再请求更早的消息,这也就是所谓“上拉加载”模式。SQL改为以id为游标,每次查询小于当前最小id的20条,按id倒序取再反转为正序。
第二个该做的扩展是用户在线状态的实时更新。目前t_user表里有一个status字段,但它的变化是靠登录和登出时写入,登出时如果直接关浏览器,session未必来得及执行登出逻辑,status就停留在“在线”了。可以引入心跳机制,用户浏览器每隔30秒发一次心跳请求,后端记录last_active_time,查询好友时把“最近2分钟有活跃”标记为“在线”,否则为“离线”。这比依赖主动登出要靠谱得多,微信网页版本质上也是这个套路。
第三个是密码加密的升级。SHA256加盐对于这个Demo足够,但放到实际环境里不够。可以升级为bcrypt或argon2,它们内置盐值且在计算上有意设计得慢,让暴力破解的成本大幅增加。把hash_password换成bcrypt.hashpw,代码改动量很小,安全等级提升却很明显。
第四个是界面和交互的打磨。消息时间显示,如果是当天显示“14:30”,如果是昨天显示“昨天 14:30”,更早的显示“2024-12-01”。未读消息数在好友列表右侧用红点显示。输入框支持Ctrl+Enter发送。这些细节不会出现在教科书写出的功能清单里,但它们决定了一个Demo是“玩具”还是“作品”。
这个项目教会我的,远远不止Python和MySQL的语法。它让我理解了前后端如何通过HTTP协议沟通,数据库如何用事务保证一致性,也让我第一次意识到,一个看似简单的聊天功能背后,藏着密码学、并发控制、网络通信这么多值得深挖的技术分支。做完这个项目之后,我再去看任何“XX系统”的项目,脑子里都会自动浮现它的数据库表结构和接口列表。这个能力不是看书看来的,是调了无数个Bug之后长出来的。如果你也在学完基础上找不到练手的项目,可以考虑试试这个方向——从零开始搭一个能跑通的聊天系统,收获绝对值得你付出的时间。