
做这个“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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个关键选择我得解释解释。第一数据库和所有表都统一用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( cursorclasspymysql.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_id1, friend_id2)那用户1的好友里能看到2但用户2查询他好友的时候如果用“user_id2”去查这条记录是不会出现的。这就是典型的单向存储坑。解决方式是在业务层做一个“镜像插入”加好友时同时插入两条记录(1,2)和(2,1)。这样无论从哪一侧查询都能正确拿到好友列表。你可能会问为什么不查询时用OR条件一次查出来比如SELECT * FROM t_friendship WHERE user_id2 OR friend_id2这样也是可以的但需要处理“谁是对方”的字段映射问题写起来绕且容易出错。做镜像插入牺牲了一倍的存储空间换来了查询逻辑的简单直观对于这个量级的应用来说完全值得。但要注意镜像插入必须放在同一个数据库事务里。如果第一条插进去了第二条失败了好友关系就变成了单向的会出现灵异现象——“我能看到他他看不到我”。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, typeint) 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_read0 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_read0 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_read1 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.xPython 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(host0.0.0.0, port5000)监听所有网卡同时关闭本机的防火墙端口屏蔽。这个过程中有一点需要注意MySQL默认只监听localhost如果数据库和Web应用不在同一台机器必须在MySQL配置里调整bind-address并给应用单独创建一个账号授以最小权限。千万别用root远程连接生产环境里这是大忌。5.2 踩过的坑一中文乱码中文乱码是我遇到的第一个拦路虎。注册一个用户名为“张三”数据库里存出来是“???”。排查路径是这样的先看MySQL控制台里执行insert语句中文正常说明数据库字符集没问题。再看Python里print查出来的数据打印正常说明Python和MySQL交互时也没问题。那问题出在哪后来发现是连接参数里没加charsetutf8mb4。pymysql和MySQL之间连接时如果没有指定字符集就用MySQL默认的latin1中文经过一次错误的字符集转换就变成了问号。把charset参数加上之后乱码问题就解决了。这个案例给我的体会是排查乱码问题时要分层次数据库本身的字符集、表字符集、连接字符集、HTTP响应字符集一层层确认。如果只是数库表建错了改表比改连接更麻烦但大多数时候问题出在连接这一层。5.3 踩过的坑二MySQL连接超时与连接数问题MySQL有一个wait_timeout配置默认是8小时。这意味着一个连接如果8小时没有活动服务器端会主动断开它。Python这边全局复用的连接对象还傻傻地认为它是活的一执行查询就收到一个已关闭连接的错误。我的解决思路是在执行查询之前先ping一下连接MySQL的ping命令会探测连接是否可用如果抛异常就重新创建连接。封装一个get_cursor上下文管理器把无效连接的重建过程隐藏起来业务代码里完全不用关心底层连接是否失效。def query(sql, paramsNone): 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”。未读消息数在好友列表右侧用红点显示。输入框支持CtrlEnter发送。这些细节不会出现在教科书写出的功能清单里但它们决定了一个Demo是“玩具”还是“作品”。这个项目教会我的远远不止Python和MySQL的语法。它让我理解了前后端如何通过HTTP协议沟通数据库如何用事务保证一致性也让我第一次意识到一个看似简单的聊天功能背后藏着密码学、并发控制、网络通信这么多值得深挖的技术分支。做完这个项目之后我再去看任何“XX系统”的项目脑子里都会自动浮现它的数据库表结构和接口列表。这个能力不是看书看来的是调了无数个Bug之后长出来的。如果你也在学完基础上找不到练手的项目可以考虑试试这个方向——从零开始搭一个能跑通的聊天系统收获绝对值得你付出的时间。