简介:这是一套面向社交交友场景的开源即时通讯项目源码,定位为高学历人群的高品质交友应用,功能上对标《青藤之恋》,核心是双向喜欢解锁聊天的匹配机制。项目支持微信小程序、iOS/Android App 与 H5 三端通用,适合想学习多端社交软件架构、即时通讯实现或做二次开发的开发者参考。资源包共 512 个文件,约 2.01MB,以 252 个 js 业务逻辑与工具脚本、146 个 vue 页面组件为主,另有 80 个 png 界面素材、17 个 scss 样式、4 个 wxs 小程序脚本及若干 md、json 配置与 license 文件,结构完整便于按模块阅读。内容覆盖用户身份验证、WebSocket 实时消息传输、聊天记录存储与多端同步、双向匹配算法及隐私加密等关键技术点,可帮助读者理解社交软件从前端页面到后端逻辑的完整开发流程。目前已有 682 人学习下载,适合作为课程设计、毕业项目或社交产品原型的实践案例。1. 仿青藤之恋社交交友软件的三端通用架构:一套代码怎么同时喂饱小程序、App 和 H5做过社交产品的人都有一个共识:即时通讯是骨架,匹配推荐是灵魂,而多端一致是折磨。我最近半年一直在折腾一个仿青藤之恋形态的社交交友项目,核心诉求很明确——同一套业务逻辑,要能跑在微信小程序、原生 App 和 H5 三个端上,聊天消息还得实时同步。听起来像是老生常谈的跨端方案选型,但真正落地时你会发现,社交场景对即时通讯的要求远比普通工具类应用苛刻:消息不能丢、顺序不能乱、弱网不能崩、多端登录状态还得一致。这个标题拆开来看,其实包含三层技术栈:跨端框架选型(小程序 + App + H5 怎么统一)、即时通讯通道(WebSocket 长连接怎么管)、以及社交业务特有的匹配与关系链逻辑。适合谁看?如果你正在做或准备做社交类产品,尤其是需要快速覆盖多端、又不想维护三套代码的团队,这篇笔记里的路径和参数可以直接抄。我踩过的坑,你大概率也会遇到。2. 跨端选型:为什么我最终没选纯原生三端各写一套2.1 三端通用的真实成本账先算一笔账。如果小程序用原生 WXML + JS,App 用 Flutter 或 React Native,H5 用 Vue,三套代码意味着三套状态管理、三套网络层、三套 UI 适配逻辑。社交产品的迭代频率极高——今天加个“谁看过我”,明天改个“心动信号”的交互,三端同步改一遍,人力直接乘以三。更致命的是即时通讯层:WebSocket 的连接管理、心跳、重连、消息去重,三端各写一遍,bug 也是三份。所以跨端方案不是“要不要”,而是“选哪个”。我评估过三条路:一是 Taro/Uni-app 这类编译型框架,一套代码编译到小程序和 H5,App 端用原生壳套 WebView 或 React Native 混合;二是 Flutter 全端,但小程序支持一直是硬伤;三是 React Native + 小程序转译,生态割裂严重。最终我选了 Uni-app 为主、App 端用原生插件补即时通讯能力的方式。原因很简单:社交产品的 UI 复杂度中等,但即时通讯的底层能力必须贴近原生,Uni-app 的插件市场里有现成的原生 WebSocket 模块,省去了自己写桥接的功夫。这里有个关键决策点:即时通讯的长连接到底放在哪一层?如果放在 JS 层,小程序切后台 5 秒后连接就可能被系统回收,App 端稍微好一点但也不稳定。我的做法是——小程序端用微信自带的 WebSocket API 维持前台连接,App 端通过原生插件在后台维持一个轻量级长连接,H5 端则依赖浏览器 WebSocket + 页面可见性 API 做降级。三端连接策略不同,但消息协议统一。2.2 即时通讯通道的协议设计消息协议我定了一个极简的 JSON 结构,所有端共用:{ "cmd": "msg", "seq": 1024, "from": "user_1001", "to": "user_2002", "type": "text", "body": { "content": "你好" }, "ts": 1710000000000, "ack": false }cmd区分消息类型(msg/ack/heartbeat/sync),seq是客户端自增序列号用于去重和排序,ts是服务端时间戳用于最终排序。为什么不用消息 ID 而用 seq?因为社交场景里消息顺序比唯一性更敏感,seq 在客户端生成,服务端只做校验和转发,能减少一次往返延迟。心跳策略我调了三版才稳定:小程序端 25 秒一次,App 端 30 秒一次,H5 端 20 秒一次。为什么不一样?小程序的前台生命周期受微信管控,太频繁会被限流;App 端原生层可以稍微放宽;H5 端浏览器对 WebSocket 的回收更激进,必须更勤快地保活。心跳包体只发{"cmd":"heartbeat"},服务端回{"cmd":"heartbeat_ack"},连续两次没收到 ack 就触发重连。2.3 消息可靠性的最小闭环消息不丢的核心在于“客户端确认 + 服务端补拉”。我设计的流程是:客户端发消息后本地先落库(IndexedDB 或小程序 Storage),标记ack: false;服务端收到后回 ack,客户端更新为ack: true;如果 3 秒内没收到 ack,重发一次,最多重试 3 次;重试仍失败则标记为“发送失败”,等下次连接恢复时通过sync命令补拉。服务端侧,每个用户维护一个last_seq游标。客户端重连后发送{"cmd":"sync","last_seq":1020},服务端把 1020 之后的消息批量推回来。这个机制解决了我遇到的最头疼的问题:用户在地铁里断网,出了地铁重连,中间漏掉的消息必须一条不差地补上。没有这个 sync 机制,社交产品的消息丢失就是灾难。3. 聊天模块落地:从消息表设计到前端渲染的完整链路3.1 消息存储的三张表与索引策略即时通讯的存储设计直接决定查询性能。我用了三张表:message存全量消息,conversation存会话列表,user_cursor存每个用户的已读游标。CREATE TABLE message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seq BIGINT NOT NULL, from_uid VARCHAR(32) NOT NULL, to_uid VARCHAR(32) NOT NULL, conv_id VARCHAR(64) NOT NULL, type TINYINT DEFAULT 0, body JSON, ts BIGINT NOT NULL, INDEX idx_conv_ts (conv_id, ts), INDEX idx_to_seq (to_uid, seq) ); CREATE TABLE conversation ( conv_id VARCHAR(64) PRIMARY KEY, uid_a VARCHAR(32) NOT NULL, uid_b VARCHAR(32) NOT NULL, last_msg_id BIGINT, last_ts BIGINT, unread_a INT DEFAULT 0, unread_b INT DEFAULT 0, INDEX idx_uid_a (uid_a, last_ts), INDEX idx_uid_b (uid_b, last_ts) ); CREATE TABLE user_cursor ( uid VARCHAR(32) PRIMARY KEY, last_read_seq BIGINT DEFAULT 0, updated_at BIGINT );conv_id的生成规则是min(uid_a, uid_b) + '_' + max(uid_a, uid_b),保证两人之间只有一个会话 ID,避免重复会话。message表的idx_conv_ts索引支撑聊天记录分页查询,idx_to_seq支撑 sync 补拉。conversation表的last_ts索引支撑会话列表按时间倒序排列。这里有个血泪经验:不要用from_uid + to_uid直接查消息,一定要走conv_id。早期我没建conv_id,查询两人聊天记录时用(from_uid=A AND to_uid=B) OR (from_uid=B AND to_uid=A),数据量到百万级后查询直接超时。加上conv_id并建索引后,查询从 2 秒降到 20 毫秒。3.2 前端消息列表的虚拟滚动与增量渲染社交产品的聊天记录可能上千条,一次性渲染必卡。我用的是“虚拟滚动 + 增量渲染”策略:只渲染可视区域内的 20 条消息,上下各缓冲 10 条。滚动到顶部时加载更早的历史消息,滚动到底部时自动滚到最新。// 简化的虚拟滚动逻辑 const ITEM_HEIGHT = 80; // 每条消息预估高度 const BUFFER = 10; function getVisibleRange(scrollTop, viewportHeight, totalCount) { const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER); const end = Math.min( totalCount, Math.ceil((scrollTop + viewportHeight) / ITEM_HEIGHT) + BUFFER ); return { start, end }; } // 渲染时只取 start 到 end 之间的消息 const { start, end } = getVisibleRange(scrollTop, viewportHeight, messages.length); const visibleMessages = messages.slice(start, end);ITEM_HEIGHT是预估值,实际中消息高度不固定(图片、长文本会撑高),所以我加了一个ResizeObserver动态测量每条消息的真实高度,维护一个高度缓存数组。滚动时用缓存高度计算偏移量,避免跳动。这个方案在小程序和 H5 上都能跑,App 端用原生列表组件性能更好,但逻辑一致。增量渲染的关键是“新消息到达时只追加不重排”。我用一个pendingMessages队列缓存新到达的消息,每 100 毫秒批量合并一次到主列表,避免频繁触发渲染。如果用户当前在列表底部,自动滚到最新;如果用户正在往上翻历史消息,则不打断,只在底部显示一个“有新消息”的提示条。3.3 未读数与已读回执的同步逻辑未读数同步是社交产品最容易出 bug 的地方。我的方案是:服务端在conversation表维护unread_a和unread_b,每次发消息时给接收方未读数 +1;客户端进入聊天窗口时发送{"cmd":"read","conv_id":"xxx","last_seq":1024},服务端把对应未读数清零并更新user_cursor。多端同步的难点在于:用户在 App 上读了消息,H5 端的未读数也要实时更新。我的做法是服务端在已读操作后,向该用户的所有在线连接推送{"cmd":"read_sync","conv_id":"xxx","unread":0}。客户端收到后更新本地会话列表的未读数。如果用户不在线,下次登录时通过sync命令拉取最新的未读状态。这里有个坑:小程序和 App 同时在线时,消息推送会重复。我在服务端加了一个device_id字段,每个连接绑定一个设备标识,推送时排除发起已读操作的设备。这样 App 上读了消息,小程序端收到read_sync后更新未读数,但不会重复弹通知。4. 匹配与关系链:社交产品的核心逻辑怎么在三端保持一致4.1 用户画像与匹配算法的轻量实现仿青藤之恋这类产品的匹配逻辑,核心是“标签 + 行为”双驱动。用户画像我存了两类数据:静态标签(年龄、城市、兴趣)和动态行为(浏览、点赞、聊天时长)。匹配分数用一个加权公式:def match_score(user_a, user_b): # 静态标签相似度,权重 0.4 tag_sim = jaccard(user_a.tags, user_b.tags) # 行为互补度,权重 0.3 behavior_sim = cosine(user_a.behavior_vec, user_b.behavior_vec) # 活跃度衰减,权重 0.2 active_score = 1.0 / (1 + abs(user_a.last_active - user_b.last_active) / 86400) # 地理距离,权重 0.1 geo_score = 1.0 / (1 + distance(user_a.location, user_b.location) / 10) return 0.4 * tag_sim + 0.3 * behavior_sim + 0.2 * active_score + 0.1 * geo_score这个公式不复杂,但胜在可解释、可调参。jaccard算标签交集,cosine算行为向量余弦相似度,active_score让活跃时间接近的人更容易匹配,geo_score用距离衰减。权重是我根据小规模测试调的,0.4/0.3/0.2/0.1 这组参数在冷启动阶段效果比较均衡。三端一致的关键是:匹配计算全部放在服务端,客户端只负责展示和交互。这样无论用户从哪个端进来,看到的推荐列表都是同一套逻辑算出来的。客户端本地只缓存最近一次的推荐结果,减少重复请求。4.2 关系链的存储与查询优化关系链我用了两张表:follow存关注关系,friend存双向好友关系。社交产品里“关注”是单向的,“好友”是双向的,分开存更清晰。CREATE TABLE follow ( from_uid VARCHAR(32) NOT NULL, to_uid VARCHAR(32) NOT NULL, created_at BIGINT, PRIMARY KEY (from_uid, to_uid), INDEX idx_to (to_uid) ); CREATE TABLE friend ( uid_a VARCHAR(32) NOT NULL, uid_b VARCHAR(32) NOT NULL, created_at BIGINT, PRIMARY KEY (uid_a, uid_b) );follow表用联合主键防重,idx_to索引支撑“谁关注了我”的查询。friend表用uid_a < uid_b的规则保证每对好友只存一条记录。查询“我的好友列表”时,SELECT * FROM friend WHERE uid_a = ? OR uid_b = ?,虽然用了 OR 但数据量可控,实测在十万级关系下响应时间在 50 毫秒以内。如果关系链再大,可以考虑用 Redis 的 Set 结构做缓存,但我的经验是:社交产品早期没必要上 Redis,MySQL 加好索引足够撑到日活十万。过早引入缓存反而增加一致性维护成本。4.3 三端登录态与用户数据的同步三端通用的另一个挑战是登录态同步。小程序用微信 code 换 session,App 用手机号 + 验证码,H5 用微信 OAuth 或手机号。三种登录方式最终都归一到一个uid,服务端签发统一的 JWT token。// 登录后统一签发 token const token = jwt.sign( { uid: user.uid, device: deviceId, exp: Math.floor(Date.now() / 1000) + 7 * 86400 }, SECRET_KEY );device字段标识登录设备,用于多端推送去重。token 有效期 7 天,客户端每次启动时检查过期时间,剩余不足 1 天时静默刷新。刷新接口用旧 token 换新 token,旧 token 在 5 分钟宽限期内仍可用,避免刷新瞬间的请求失败。用户数据同步方面,我把“用户资料”和“用户设置”分开存。资料(昵称、头像、简介)变更频率低,客户端启动时拉一次全量;设置(通知开关、隐私选项)变更频率高,每次修改后立即同步到服务端,并通过 WebSocket 推送到其他在线端。这样 App 上改了“不接受陌生人消息”,小程序端立刻生效。5. 避坑与排查:三端即时通讯最容易翻车的五个地方5.1 小程序切后台后 WebSocket 被回收现象:用户从小程序切到微信聊天,再切回来,发现消息不更新了,要手动下拉刷新才恢复。原因:微信小程序在切后台 5 秒后会挂起 JS 线程,WebSocket 连接被系统回收,但小程序不会主动触发onClose回调,导致前端以为连接还在。解决:监听onHide和onShow生命周期。onHide时主动关闭连接并记录last_seq,onShow时重新建立连接并发送sync补拉。不要依赖 WebSocket 的onClose,它不可靠。5.2 App 端后台长连接被系统杀死现象:App 退到后台一段时间后,消息推送延迟严重,有时完全收不到。原因:iOS 和 Android 都会在内存紧张时回收后台进程,原生 WebSocket 连接随之断开。解决:App 端不要只依赖 WebSocket,要接入系统级推送通道作为兜底。WebSocket 负责前台实时消息,推送通道负责后台通知。用户点击通知后,App 唤醒并重新建立 WebSocket,再通过sync补拉消息。这样即使长连接被杀死,消息也不会丢。5.3 H5 端浏览器 WebSocket 频繁断连现象:H5 页面在 Chrome 上跑得好好的,在部分安卓 WebView 里几分钟就断一次。原因:不同浏览器和 WebView 对 WebSocket 的空闲超时策略不同,有的 30 秒没数据就断,有的 60 秒。解决:把心跳间隔调到 20 秒,并且心跳包要带一点业务数据(比如当前时间戳),让代理和浏览器认为连接是活跃的。另外监听visibilitychange事件,页面切到后台时降低心跳频率,切回前台时立即发一次心跳并检查连接状态。5.4 消息顺序错乱现象:聊天记录里偶尔出现“后发的消息排在前面”。原因:客户端用本地时间排序,但不同设备时钟不一致;或者服务端多线程处理时写入顺序和接收顺序不一致。解决:统一用服务端时间戳ts排序,客户端本地时间只用于展示“刚刚”“几分钟前”。服务端写入时用单队列按conv_id哈希到固定线程,保证同一会话的消息顺序写入。客户端收到消息后按ts排序插入,如果ts相同则按seq排序。5.5 多端登录导致消息重复推送现象:用户在 App 和小程序同时登录,收到一条消息时两个端都弹通知。原因:服务端向该用户的所有在线连接广播了消息。解决:服务端维护每个uid的设备列表,推送时根据device_id判断。如果用户在 App 上活跃(最近 30 秒有心跳),则只推 App;如果 App 不活跃,推送到所有在线设备。同时客户端收到消息后检查本地是否已存在相同seq的消息,存在则忽略。6. 进阶技巧:用消息队列削峰与离线消息的冷热分离当社交产品日活上去之后,即时通讯的第一个瓶颈不是 WebSocket 连接数,而是消息写入的数据库压力。我遇到过一次线上问题:晚上 9 点到 10 点的高峰期,消息表写入延迟从 10 毫秒飙升到 500 毫秒,导致大量消息 ack 超时,客户端疯狂重发。后来我加了一层消息队列做削峰。客户端发消息后,服务端先写入 Kafka(或类似的消息队列),立即返回 ack;消费者从队列里批量取出消息,每 100 条一批写入 MySQL。这样写入压力被队列缓冲,数据库不再被瞬时流量打垮。# 生产者:收到消息后立即入队并回 ack def on_message_received(msg): kafka_producer.send('chat_messages', msg) return {"cmd": "ack", "seq": msg["seq"]} # 消费者:批量写入数据库 def consume_and_store(): batch = [] for msg in kafka_consumer: batch.append(msg) if len(batch) >= 100: db.bulk_insert('message', batch) batch = []离线消息的冷热分离是另一个优化点。最近 7 天的消息存 MySQL,7 天以前的归档到对象存储或冷库。用户拉取历史消息时,先查热库,热库没有再去冷库查。这样热库的数据量可控,查询性能稳定。冷热分离的触发条件我设的是“消息创建时间超过 7 天且会话双方都超过 3 天未活跃”。归档任务每天凌晨跑一次,把符合条件的消息批量迁移。迁移时保留conv_id和ts索引,冷库查询走同样的接口,对客户端透明。最后说一个我自己的习惯:每次上线新的即时通讯功能前,我一定会用弱网模拟工具把网络延迟调到 500 毫秒、丢包率调到 10%,跑一遍完整流程。社交产品的用户网络环境千差万别,实验室里跑得再顺,弱网下翻车才是常态。这个习惯帮我提前发现了至少三次重连逻辑的 bug。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一套面向社交交友场景的开源即时通讯项目源码,定位为高学历人群的高品质交友应用,功能上对标《青藤之恋》,核心是双向喜欢解锁聊天的匹配机制。项目支持微信小程序、iOS/Android App 与 H5 三端通用,适合想学习多端社交软件架构、即时通讯实现或做二次开发的开发者参考。资源包共 512 个文件,约 2.01MB,以 252 个 js 业务逻辑与工具脚本、146 个 vue 页面组件为主,另有 80 个 png 界面素材、17 个 scss 样式、4 个 wxs 小程序脚本及若干 md、json 配置与 license 文件,结构完整便于按模块阅读。内容覆盖用户身份验证、WebSocket 实时消息传输、聊天记录存储与多端同步、双向匹配算法及隐私加密等关键技术点,可帮助读者理解社交软件从前端页面到后端逻辑的完整开发流程。目前已有 682 人学习下载,适合作为课程设计、毕业项目或社交产品原型的实践案例。1. 仿青藤之恋社交交友软件的三端通用架构:一套代码怎么同时喂饱小程序、App 和 H5做过社交产品的人都有一个共识:即时通讯是骨架,匹配推荐是灵魂,而多端一致是折磨。我最近半年一直在折腾一个仿青藤之恋形态的社交交友项目,核心诉求很明确——同一套业务逻辑,要能跑在微信小程序、原生 App 和 H5 三个端上,聊天消息还得实时同步。听起来像是老生常谈的跨端方案选型,但真正落地时你会发现,社交场景对即时通讯的要求远比普通工具类应用苛刻:消息不能丢、顺序不能乱、弱网不能崩、多端登录状态还得一致。这个标题拆开来看,其实包含三层技术栈:跨端框架选型(小程序 + App + H5 怎么统一)、即时通讯通道(WebSocket 长连接怎么管)、以及社交业务特有的匹配与关系链逻辑。适合谁看?如果你正在做或准备做社交类产品,尤其是需要快速覆盖多端、又不想维护三套代码的团队,这篇笔记里的路径和参数可以直接抄。我踩过的坑,你大概率也会遇到。2. 跨端选型:为什么我最终没选纯原生三端各写一套2.1 三端通用的真实成本账先算一笔账。如果小程序用原生 WXML + JS,App 用 Flutter 或 React Native,H5 用 Vue,三套代码意味着三套状态管理、三套网络层、三套 UI 适配逻辑。社交产品的迭代频率极高——今天加个“谁看过我”,明天改个“心动信号”的交互,三端同步改一遍,人力直接乘以三。更致命的是即时通讯层:WebSocket 的连接管理、心跳、重连、消息去重,三端各写一遍,bug 也是三份。所以跨端方案不是“要不要”,而是“选哪个”。我评估过三条路:一是 Taro/Uni-app 这类编译型框架,一套代码编译到小程序和 H5,App 端用原生壳套 WebView 或 React Native 混合;二是 Flutter 全端,但小程序支持一直是硬伤;三是 React Native + 小程序转译,生态割裂严重。最终我选了 Uni-app 为主、App 端用原生插件补即时通讯能力的方式。原因很简单:社交产品的 UI 复杂度中等,但即时通讯的底层能力必须贴近原生,Uni-app 的插件市场里有现成的原生 WebSocket 模块,省去了自己写桥接的功夫。这里有个关键决策点:即时通讯的长连接到底放在哪一层?如果放在 JS 层,小程序切后台 5 秒后连接就可能被系统回收,App 端稍微好一点但也不稳定。我的做法是——小程序端用微信自带的 WebSocket API 维持前台连接,App 端通过原生插件在后台维持一个轻量级长连接,H5 端则依赖浏览器 WebSocket + 页面可见性 API 做降级。三端连接策略不同,但消息协议统一。2.2 即时通讯通道的协议设计消息协议我定了一个极简的 JSON 结构,所有端共用:{ "cmd": "msg", "seq": 1024, "from": "user_1001", "to": "user_2002", "type": "text", "body": { "content": "你好" }, "ts": 1710000000000, "ack": false }cmd区分消息类型(msg/ack/heartbeat/sync),seq是客户端自增序列号用于去重和排序,ts是服务端时间戳用于最终排序。为什么不用消息 ID 而用 seq?因为社交场景里消息顺序比唯一性更敏感,seq 在客户端生成,服务端只做校验和转发,能减少一次往返延迟。心跳策略我调了三版才稳定:小程序端 25 秒一次,App 端 30 秒一次,H5 端 20 秒一次。为什么不一样?小程序的前台生命周期受微信管控,太频繁会被限流;App 端原生层可以稍微放宽;H5 端浏览器对 WebSocket 的回收更激进,必须更勤快地保活。心跳包体只发{"cmd":"heartbeat"},服务端回{"cmd":"heartbeat_ack"},连续两次没收到 ack 就触发重连。2.3 消息可靠性的最小闭环消息不丢的核心在于“客户端确认 + 服务端补拉”。我设计的流程是:客户端发消息后本地先落库(IndexedDB 或小程序 Storage),标记ack: false;服务端收到后回 ack,客户端更新为ack: true;如果 3 秒内没收到 ack,重发一次,最多重试 3 次;重试仍失败则标记为“发送失败”,等下次连接恢复时通过sync命令补拉。服务端侧,每个用户维护一个last_seq游标。客户端重连后发送{"cmd":"sync","last_seq":1020},服务端把 1020 之后的消息批量推回来。这个机制解决了我遇到的最头疼的问题:用户在地铁里断网,出了地铁重连,中间漏掉的消息必须一条不差地补上。没有这个 sync 机制,社交产品的消息丢失就是灾难。3. 聊天模块落地:从消息表设计到前端渲染的完整链路3.1 消息存储的三张表与索引策略即时通讯的存储设计直接决定查询性能。我用了三张表:message存全量消息,conversation存会话列表,user_cursor存每个用户的已读游标。CREATE TABLE message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seq BIGINT NOT NULL, from_uid VARCHAR(32) NOT NULL, to_uid VARCHAR(32) NOT NULL, conv_id VARCHAR(64) NOT NULL, type TINYINT DEFAULT 0, body JSON, ts BIGINT NOT NULL, INDEX idx_conv_ts (conv_id, ts), INDEX idx_to_seq (to_uid, seq) ); CREATE TABLE conversation ( conv_id VARCHAR(64) PRIMARY KEY, uid_a VARCHAR(32) NOT NULL, uid_b VARCHAR(32) NOT NULL, last_msg_id BIGINT, last_ts BIGINT, unread_a INT DEFAULT 0, unread_b INT DEFAULT 0, INDEX idx_uid_a (uid_a, last_ts), INDEX idx_uid_b (uid_b, last_ts) ); CREATE TABLE user_cursor ( uid VARCHAR(32) PRIMARY KEY, last_read_seq BIGINT DEFAULT 0, updated_at BIGINT );conv_id的生成规则是min(uid_a, uid_b) + '_' + max(uid_a, uid_b),保证两人之间只有一个会话 ID,避免重复会话。message表的idx_conv_ts索引支撑聊天记录分页查询,idx_to_seq支撑 sync 补拉。conversation表的last_ts索引支撑会话列表按时间倒序排列。这里有个血泪经验:不要用from_uid + to_uid直接查消息,一定要走conv_id。早期我没建conv_id,查询两人聊天记录时用(from_uid=A AND to_uid=B) OR (from_uid=B AND to_uid=A),数据量到百万级后查询直接超时。加上conv_id并建索引后,查询从 2 秒降到 20 毫秒。3.2 前端消息列表的虚拟滚动与增量渲染社交产品的聊天记录可能上千条,一次性渲染必卡。我用的是“虚拟滚动 + 增量渲染”策略:只渲染可视区域内的 20 条消息,上下各缓冲 10 条。滚动到顶部时加载更早的历史消息,滚动到底部时自动滚到最新。// 简化的虚拟滚动逻辑 const ITEM_HEIGHT = 80; // 每条消息预估高度 const BUFFER = 10; function getVisibleRange(scrollTop, viewportHeight, totalCount) { const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER); const end = Math.min( totalCount, Math.ceil((scrollTop + viewportHeight) / ITEM_HEIGHT) + BUFFER ); return { start, end }; } // 渲染时只取 start 到 end 之间的消息 const { start, end } = getVisibleRange(scrollTop, viewportHeight, messages.length); const visibleMessages = messages.slice(start, end);ITEM_HEIGHT是预估值,实际中消息高度不固定(图片、长文本会撑高),所以我加了一个ResizeObserver动态测量每条消息的真实高度,维护一个高度缓存数组。滚动时用缓存高度计算偏移量,避免跳动。这个方案在小程序和 H5 上都能跑,App 端用原生列表组件性能更好,但逻辑一致。增量渲染的关键是“新消息到达时只追加不重排”。我用一个pendingMessages队列缓存新到达的消息,每 100 毫秒批量合并一次到主列表,避免频繁触发渲染。如果用户当前在列表底部,自动滚到最新;如果用户正在往上翻历史消息,则不打断,只在底部显示一个“有新消息”的提示条。3.3 未读数与已读回执的同步逻辑未读数同步是社交产品最容易出 bug 的地方。我的方案是:服务端在conversation表维护unread_a和unread_b,每次发消息时给接收方未读数 +1;客户端进入聊天窗口时发送{"cmd":"read","conv_id":"xxx","last_seq":1024},服务端把对应未读数清零并更新user_cursor。多端同步的难点在于:用户在 App 上读了消息,H5 端的未读数也要实时更新。我的做法是服务端在已读操作后,向该用户的所有在线连接推送{"cmd":"read_sync","conv_id":"xxx","unread":0}。客户端收到后更新本地会话列表的未读数。如果用户不在线,下次登录时通过sync命令拉取最新的未读状态。这里有个坑:小程序和 App 同时在线时,消息推送会重复。我在服务端加了一个device_id字段,每个连接绑定一个设备标识,推送时排除发起已读操作的设备。这样 App 上读了消息,小程序端收到read_sync后更新未读数,但不会重复弹通知。4. 匹配与关系链:社交产品的核心逻辑怎么在三端保持一致4.1 用户画像与匹配算法的轻量实现仿青藤之恋这类产品的匹配逻辑,核心是“标签 + 行为”双驱动。用户画像我存了两类数据:静态标签(年龄、城市、兴趣)和动态行为(浏览、点赞、聊天时长)。匹配分数用一个加权公式:def match_score(user_a, user_b): # 静态标签相似度,权重 0.4 tag_sim = jaccard(user_a.tags, user_b.tags) # 行为互补度,权重 0.3 behavior_sim = cosine(user_a.behavior_vec, user_b.behavior_vec) # 活跃度衰减,权重 0.2 active_score = 1.0 / (1 + abs(user_a.last_active - user_b.last_active) / 86400) # 地理距离,权重 0.1 geo_score = 1.0 / (1 + distance(user_a.location, user_b.location) / 10) return 0.4 * tag_sim + 0.3 * behavior_sim + 0.2 * active_score + 0.1 * geo_score这个公式不复杂,但胜在可解释、可调参。jaccard算标签交集,cosine算行为向量余弦相似度,active_score让活跃时间接近的人更容易匹配,geo_score用距离衰减。权重是我根据小规模测试调的,0.4/0.3/0.2/0.1 这组参数在冷启动阶段效果比较均衡。三端一致的关键是:匹配计算全部放在服务端,客户端只负责展示和交互。这样无论用户从哪个端进来,看到的推荐列表都是同一套逻辑算出来的。客户端本地只缓存最近一次的推荐结果,减少重复请求。4.2 关系链的存储与查询优化关系链我用了两张表:follow存关注关系,friend存双向好友关系。社交产品里“关注”是单向的,“好友”是双向的,分开存更清晰。CREATE TABLE follow ( from_uid VARCHAR(32) NOT NULL, to_uid VARCHAR(32) NOT NULL, created_at BIGINT, PRIMARY KEY (from_uid, to_uid), INDEX idx_to (to_uid) ); CREATE TABLE friend ( uid_a VARCHAR(32) NOT NULL, uid_b VARCHAR(32) NOT NULL, created_at BIGINT, PRIMARY KEY (uid_a, uid_b) );follow表用联合主键防重,idx_to索引支撑“谁关注了我”的查询。friend表用uid_a < uid_b的规则保证每对好友只存一条记录。查询“我的好友列表”时,SELECT * FROM friend WHERE uid_a = ? OR uid_b = ?,虽然用了 OR 但数据量可控,实测在十万级关系下响应时间在 50 毫秒以内。如果关系链再大,可以考虑用 Redis 的 Set 结构做缓存,但我的经验是:社交产品早期没必要上 Redis,MySQL 加好索引足够撑到日活十万。过早引入缓存反而增加一致性维护成本。4.3 三端登录态与用户数据的同步三端通用的另一个挑战是登录态同步。小程序用微信 code 换 session,App 用手机号 + 验证码,H5 用微信 OAuth 或手机号。三种登录方式最终都归一到一个uid,服务端签发统一的 JWT token。// 登录后统一签发 token const token = jwt.sign( { uid: user.uid, device: deviceId, exp: Math.floor(Date.now() / 1000) + 7 * 86400 }, SECRET_KEY );device字段标识登录设备,用于多端推送去重。token 有效期 7 天,客户端每次启动时检查过期时间,剩余不足 1 天时静默刷新。刷新接口用旧 token 换新 token,旧 token 在 5 分钟宽限期内仍可用,避免刷新瞬间的请求失败。用户数据同步方面,我把“用户资料”和“用户设置”分开存。资料(昵称、头像、简介)变更频率低,客户端启动时拉一次全量;设置(通知开关、隐私选项)变更频率高,每次修改后立即同步到服务端,并通过 WebSocket 推送到其他在线端。这样 App 上改了“不接受陌生人消息”,小程序端立刻生效。5. 避坑与排查:三端即时通讯最容易翻车的五个地方5.1 小程序切后台后 WebSocket 被回收现象:用户从小程序切到微信聊天,再切回来,发现消息不更新了,要手动下拉刷新才恢复。原因:微信小程序在切后台 5 秒后会挂起 JS 线程,WebSocket 连接被系统回收,但小程序不会主动触发onClose回调,导致前端以为连接还在。解决:监听onHide和onShow生命周期。onHide时主动关闭连接并记录last_seq,onShow时重新建立连接并发送sync补拉。不要依赖 WebSocket 的onClose,它不可靠。5.2 App 端后台长连接被系统杀死现象:App 退到后台一段时间后,消息推送延迟严重,有时完全收不到。原因:iOS 和 Android 都会在内存紧张时回收后台进程,原生 WebSocket 连接随之断开。解决:App 端不要只依赖 WebSocket,要接入系统级推送通道作为兜底。WebSocket 负责前台实时消息,推送通道负责后台通知。用户点击通知后,App 唤醒并重新建立 WebSocket,再通过sync补拉消息。这样即使长连接被杀死,消息也不会丢。5.3 H5 端浏览器 WebSocket 频繁断连现象:H5 页面在 Chrome 上跑得好好的,在部分安卓 WebView 里几分钟就断一次。原因:不同浏览器和 WebView 对 WebSocket 的空闲超时策略不同,有的 30 秒没数据就断,有的 60 秒。解决:把心跳间隔调到 20 秒,并且心跳包要带一点业务数据(比如当前时间戳),让代理和浏览器认为连接是活跃的。另外监听visibilitychange事件,页面切到后台时降低心跳频率,切回前台时立即发一次心跳并检查连接状态。5.4 消息顺序错乱现象:聊天记录里偶尔出现“后发的消息排在前面”。原因:客户端用本地时间排序,但不同设备时钟不一致;或者服务端多线程处理时写入顺序和接收顺序不一致。解决:统一用服务端时间戳ts排序,客户端本地时间只用于展示“刚刚”“几分钟前”。服务端写入时用单队列按conv_id哈希到固定线程,保证同一会话的消息顺序写入。客户端收到消息后按ts排序插入,如果ts相同则按seq排序。5.5 多端登录导致消息重复推送现象:用户在 App 和小程序同时登录,收到一条消息时两个端都弹通知。原因:服务端向该用户的所有在线连接广播了消息。解决:服务端维护每个uid的设备列表,推送时根据device_id判断。如果用户在 App 上活跃(最近 30 秒有心跳),则只推 App;如果 App 不活跃,推送到所有在线设备。同时客户端收到消息后检查本地是否已存在相同seq的消息,存在则忽略。6. 进阶技巧:用消息队列削峰与离线消息的冷热分离当社交产品日活上去之后,即时通讯的第一个瓶颈不是 WebSocket 连接数,而是消息写入的数据库压力。我遇到过一次线上问题:晚上 9 点到 10 点的高峰期,消息表写入延迟从 10 毫秒飙升到 500 毫秒,导致大量消息 ack 超时,客户端疯狂重发。后来我加了一层消息队列做削峰。客户端发消息后,服务端先写入 Kafka(或类似的消息队列),立即返回 ack;消费者从队列里批量取出消息,每 100 条一批写入 MySQL。这样写入压力被队列缓冲,数据库不再被瞬时流量打垮。# 生产者:收到消息后立即入队并回 ack def on_message_received(msg): kafka_producer.send('chat_messages', msg) return {"cmd": "ack", "seq": msg["seq"]} # 消费者:批量写入数据库 def consume_and_store(): batch = [] for msg in kafka_consumer: batch.append(msg) if len(batch) >= 100: db.bulk_insert('message', batch) batch = []离线消息的冷热分离是另一个优化点。最近 7 天的消息存 MySQL,7 天以前的归档到对象存储或冷库。用户拉取历史消息时,先查热库,热库没有再去冷库查。这样热库的数据量可控,查询性能稳定。冷热分离的触发条件我设的是“消息创建时间超过 7 天且会话双方都超过 3 天未活跃”。归档任务每天凌晨跑一次,把符合条件的消息批量迁移。迁移时保留conv_id和ts索引,冷库查询走同样的接口,对客户端透明。最后说一个我自己的习惯:每次上线新的即时通讯功能前,我一定会用弱网模拟工具把网络延迟调到 500 毫秒、丢包率调到 10%,跑一遍完整流程。社交产品的用户网络环境千差万别,实验室里跑得再顺,弱网下翻车才是常态。这个习惯帮我提前发现了至少三次重连逻辑的 bug。希望帮到你。本文还有配套的精品资源,点击获取