用户服务模块是即时通讯系统里最容易被低估、却又最容易出问题的一环。很多人一上来就闷头写登录注册、写好友关系,结果等消息模块、长连接网关都部署好了,才发现用户服务要么扛不住冲击,要么数据乱成一锅粥,被迫推倒重来。这篇文章我会把这几年在真实项目里打磨用户服务模块的经验完整拆出来,讲清楚功能边界、表结构设计、Token 体系、好友关系、在线状态推送,以及线上压测和排查故障的具体方法。无论你是正在搭建自己的即时通讯应用,还是主要负责服务端这块,你都用得上。
1. 即时通讯用户服务在整个项目里的定位
1.1 为什么单聊、群聊三天能写出来,用户服务却要单独成项目
刚接触即时通讯的人很容易问一个问题:用户服务不就是注册、登录、查资料吗,跟普通的 Web 后台有什么区别?实际上区别大得很。普通后台系统的用户模型往往是孤立的——用户登录后,它只是数据库里的一行记录,跟别的数据没有强关联。但在即时通讯系统里,用户的“存在形态”贯穿整个业务链路:登录成功后要上报在线状态,加好友要触发关系链变更,修改头像要通知所有参与会话的客户端刷新,账号被顶下线要立刻踢掉旧连接。用户服务如果做得太薄,其他模块就得各自维护一套用户数据副本,最终只能靠定时任务去对账,早晚出乱子。
更重要的是,用户服务是即时通讯系统的“入口关卡”。客户端发起的每一次操作,几乎都要先经过账号体系认证。如果这一步没做好,后面消息服务做得再花哨,也扛不住恶意注册、批量刷消息、伪造身份这类攻击。所以在真实的工程实践里,用户服务从来不是一个简单的 CRUD 项目,而是一个集账号安全、状态管理、关系链存储、多端同步策略于一体的核心基础服务。
有些团队也走过另一种极端:把用户相关逻辑全部塞进 API 网关或者消息服务里,觉得反正是内部系统,拆那么细没必要。等到要单独做运营后台的封号功能,或者要对接外部 OAuth 登录时,才发现代码互相牵扯,根本抽不出来。这就是我坚持在项目里把 UserServer 做成独立服务的根本原因——它能独立演进,也能独立扩容。登录、注册接口压力大了,单独给用户服务加机器就行,完全不干扰消息链路。
1.2 UserServer 的职责边界与上下游协作关系
要画清 UserServer 的边界,核心动作其实就三个:账号生命周期管理、关系链管理、状态同步。账号生命周期管理包括注册、登录、注销、密码找回、设备管理;关系链管理包括好友申请、好友列表、黑名单、分组标签;状态同步包括在线状态维护、登录端信息记录、被顶号通知。除此之外的一切,比如消息收发、离线消息存储、推送通道调度,都不属于用户服务的范畴,应该交给消息服务和其他专业组件去处理。
在实际架构里,UserServer 与 API 网关、消息服务、长连接网关是协作关系,不是父子关系。客户端请求进入系统后,先由 API 网关做基础鉴权,拿到合法的用户身份后,再根据请求类型分发到用户服务或消息服务。长连接网关在客户端建立连接时,会向 UserServer 校验 Token 并订阅用户的在线状态变更事件。当用户断线重连、切换网络、在其他设备登录时,长连接网关会收到 UserServer 发来的状态通知,从而决定是否释放旧连接。
这里有一个很容易踩坑的设计决策:好友关系数据到底放在用户服务里,还是单独做关系链服务?根据我的经验,早期团队用一个用户服务把关系链一起做了,完全没问题。但当联系人数量级上来、社交关系复杂化(群分组、特别关注、关联备注)之后,关系链的存储压力会迅速增长,而且它的读写模式跟用户基本信息完全不同——用户资料是低频读、低频写,关系链却是高频读、频繁变更。到这个阶段再去拆分,成本就很高了。所以设计时要把关系链的表结构、分表策略预留好,即使物理上不拆服务,逻辑上也要拆清楚。
1.3 从单机到微服务:用户服务的演进路线
一个小型即时通讯应用,刚开始可能只需要一个单体服务:用户表、好友表、消息表全在一个数据库里,部署在一台机器上。这个阶段运行得挺好,日均活跃用户几千人,一切游刃有余。但当用户量增长到数十万,你会陆续遇到几个问题:注册登录接口的 QPS 变高,数据库连接池被打满;用户基础信息与好友关系耦合在一起,导致慢查询拖垮整体性能;状态推送依赖定时轮询,实时性差且浪费带宽。
演进的第一步是把用户信息缓存到 Redis。注册、登录、查资料都先走缓存,缓存未命中再查数据库。第二步是把用户服务从单体中抽出来,形成独立部署单元,与消息服务、长连接网关解耦。第三步是引入消息队列,把状态变更、关系链变更等事件异步通知给其他模块,避免服务间同步调用过长导致雪崩。走到这一步,UserServer 就已经是一个成熟的微服务了。需要注意的是,拆服务不是为了炫技,单纯为了“微服务”而拆只会增加部署和排查的复杂度。每拆一个服务,你就要回答一个问题:这个服务的独立价值能不能抵消网络开销和运维成本?
2. 用户服务核心模块设计与表结构拆解
2.1 核心功能模块清单与关键取舍
结合我在这个项目里的实际整理,UserServer 主要包含下面这几个功能模块:
- 注册登录模块:手机号验证码注册、密码登录、Token 刷新、设备绑定。
- 资料管理模块:头像、昵称、个性签名、扩展字段的读取与更新。
- 关系链模块:好友申请、添加/删除好友、黑名单、好友分组、备注管理。
- 在线状态模块:上线事件上报、心跳保活、多端在线管理、状态变更广播。
- 安全模块:密码加密存储、登录风控、Token 校验与刷新、敏感操作二次验证。
初版设计里,资料管理模块和在线状态模块最容易放错位置。有人会把在线状态直接写进用户表,用一个 status 字段表示在线或离线。这在只有 Web 单端的小项目里勉强能用,但一旦支持 App、Web、PC 多端同时在线,“在线”就不再是一个简单的布尔值,而是一个集合——一个用户可能同时有多条活跃连接,每条连接有自己的状态。把在线状态跟用户基本信息混在一起,既会让用户表更新极其频繁,又会引发状态同步的并发问题。正确做法是把在线状态单独建模,一条连接就是一条状态记录,用户处于“在线”状态当且仅当存在至少一条活跃连接。
2.2 用户信息表、认证表、设备表的设计方案
用户信息表(user_profile)是用户服务最核心的一张表,它存储的是“不经常变化的信息”:用户名、昵称、头像 URL、性别、个性签名、注册时间、账户状态。为什么用户信息表要刻意做得“窄”?因为它会被大量读取,且写入场景相对稀少,越窄意味着单行数据越小,InnoDB 一页能容纳的行数越多,查询效率越高。那些容易变化的业务属性,比如最后登录时间、在线时长统计、偏好设置,不该塞进这张表。
认证信息需要单独拆表。登录凭据(手机号、邮箱、密码哈希)与用户资料分离,可以避免一个误操作把敏感字段暴露出去。密码字段只保存哈希值,加盐处理,推荐使用 bcrypt 或者 scrypt 算法。系统在任何时候都不应该能反向解析出明文密码,这是安全底线。
设备表(user_device)在即时通讯里非常关键。一条记录对应一个端:设备 ID、设备类型(iOS、Android、Web、PC)、推送 Token、最后活跃时间。客户端登录时,用户服务检查设备表中是否已有该设备的合法记录,有则复用,没有则新建。这里的设计细节影响推送成功率:推送 Token 绑定的是设备,不是用户。用户在旧手机上退出登录并清除 Token,另一个设备依然能正常收到推送。如果把设备 Token 跟用户表的字段混在一起写,换设备登录时的逻辑会非常难受。
2.3 好友关系与黑名单的存储模式对比
好友关系的主流设计有两种:一种是行存储,一行记录一个双向关系(user_id, friend_id, created_at),查询某个人的好友列表时要扫描所有行;另一种是列存储思维,一张表里存我关注了谁,另一张表里存谁关注了我。对即时通讯场景来说,好友关系本质上是强双向的,但加好友的动作却是由单向申请触发的,所以存储上我习惯用三张表:好友申请表、好友关系表、黑名单表。
好友申请表是临时数据,状态有 pending、accepted、rejected、expired。申请方添加记录,接收方处理申请时修改状态。一旦接受,就写入好友关系表,同时删除申请记录。好友关系表的核心字段是 user_id 和 friend_id,外加一个关系分组字段。查询好友列表时,如果数据量不大,直接用 WHERE user_id = ? 全量查出来,内存里做组装;数据量上去后,引入 Redis 的 set 结构缓存好友 ID 集合,定期与数据库做对齐。
黑名单表比较特殊,它的判断是单向的——我拉黑了你,你的消息不能发给我。但在存储上,拉黑动作发生在我的用户空间里,所以数据要落到我的 user_id 维度下。消息模块在处理发送逻辑时,需要先查目标用户是否把发送方拉黑了。这一步的性能敏感度极高,必须做缓存,而且要注意缓存更新的事件驱动机制,防止黑名单失效不及时导致骚扰消息漏过门槛。
2.4 扩展用户字段的优雅处理
业务方总会提出一些文档里没提到的需求,比如“给用户增加一个地区字段”“增加一个等级字段”。直接在 user_profile 表上 ALTER TABLE 加列,在数据库规模小的时候没问题,但表大了以后每次 DDL 锁表都让人提心吊胆。更麻烦的是,不同版本客户端提交的资料字段不一致,服务端做兼容很痛苦。
我的方案是预置一个扩展字段表(user_ext),主键是 user_id,value 字段是 JSON 类型,用几个命名空间区分业务域,比如 base、privacy、settings。读取时一次性取出 JSON,解析到对应的结构体里;写入时按业务域差异化更新。这个方案牺牲了一点查询灵活性,换来了无限扩展的可能。如果后续某个扩字段的读取频率变得非常高,再把它提升到独立字段或缓存里,形成“冷热分离”。
3. 登录鉴权机制与多端会话管理
3.1 Token 的生成、校验与刷新机制
即时通讯系统里,Token 是一次会话的凭证,它的生命周期设计决定了用户体验和安全风险。短 Token(比如 2 小时过期)更安全,但用户需要频繁重新登录;长 Token(比如 30 天)用户体验好,但一旦泄露风险较大。折中方案是双 Token 机制:访问 Token(access token)短时效,一般 2 到 4 小时;刷新 Token(refresh token)长时效,7 到 30 天不等。客户端在访问 Token 快过期时,用刷新 Token 向服务端换取新的访问 Token。登录接口只负责颁发这两个 Token,业务接口统一只认访问 Token。
Token 本身建议用不透明字符串而非 JWT。JWT 看起来方便,可以在不查库的情况下直接解析用户 ID 和过期时间,但它有一个致命问题:无法在服务端主动失效。用户被拉黑、账号被封禁、用户修改密码后,旧 Token 依然可能有效,我们不得不额外维护一个黑名单缓存。不透明 Token(比如 UUID 或随机字符串)可以完全以 Redis 为存储介质,key 是 Token 值,value 是用户 ID 和会话信息,校验时只需一次 GET 操作,要失效时直接 DEL。性能并不比 JWT 差,安全性和可控性反而更好。
刷新 Token 的刷新逻辑也有讲究。客户端提交刷新 Token 时,服务端不仅要返回新的访问 Token,还要同时旋转刷新 Token 本身,然后让旧的刷新 Token 失效。这个机制能有效防止刷新 Token 被截获后反复使用。还有一种常见攻击是“会话固定”:攻击者诱导用户使用某个已知 Token 登录,然后窃取后续流量。防御手段是登录成功后强制更换设备内部的 Token,不只更新过期时间。
3.2 多端在线、互踢与设备列表管理
多端在线是即时通讯用户服务的难点之一。初版系统如果只支持单一登录,客户端上线时直接顶掉旧连接,逻辑非常简单。但现代用户几乎人手一台手机一台电脑,强行互踢会让用户崩溃。多端在线设计的核心是允许同一个账号在同一时刻有多条活跃连接,但限制“同类型端”只允许一个在线。比如手机端 A 登录后,手机端 B 登录会把 A 挤下线,电脑端 C 却不受影响。这样既尊重用户多设备使用的习惯,又保持了同一端的会话唯一性。
在具体实现上,会话表(user_session)是关键。每次登录,服务端创建一条会话记录,包含用户 ID、端类型、设备 ID、Token、连接状态、登录 IP、登录时间。长连接网关建立连接时,会带着会话 ID 来注册通道。在线状态服务维护一个“用户 ID -> 会话 ID 列表”的映射,当收到某端的重复登录事件时,把旧会话标记为离线,并通过推送通道通知旧连接所在的客户端执行本地登出。
这里有一个容易忽略的细节:被顶下线的客户端不能只靠本地弹窗提示,服务端必须主动断开旧连接,而不是等待客户端自己处理。原因是客户端可能在弱网环境下收不到服务端推送,一直停留在假在线状态。服务端负责断开后,用户的下一次心跳就会发现自己已经不在频道里,从而能干净地重连并刷新会话。
3.3 密码加密、验证码与风控设计
密码存储的底线是绝不能保存明文,甚至绝不能保存可逆密文。推荐 bcrypt,虽然它在高并发下有点慢,但天然带盐,抗彩虹表能力强。如果你担心 bcrypt 慢,可以先用一个快速的哈希算法做预校验,再用 bcrypt 做正式比对。短信验证码的逻辑重点不在生成,而在于防刷:同一手机号 60 秒内不能重复发送;单 IP 每天的发送次数要有上限;同一设备更换多个手机号注册要触发人工审核。所有验证码都应该是短时效的,5 分钟内有效,而且只能使用一次。
风控没有固定公式,但可以用一些启发式规则。比如注册时检验设备指纹,同一设备在短时间内注册超过 3 个账号就标记为异常;登录时检验 IP 归属地与历史登录地是否匹配,不匹配则要求二次验证;接口层面对未登录请求、异常 Token 请求做速率限制。这些规则需要在早期就埋点,不然后面补会非常痛苦。
4. 架构落地:高并发下的读写策略与缓存设计
4.1 本地缓存与 Redis 缓存的分层协作
用户服务的读取特点是二八定律:80% 的请求都在读 20% 的热点数据。用户的头像、昵称、在线状态就是典型热点。全部打到数据库,纯属浪费资源。常规做法是加一层 Redis 缓存,降低数据库压力。但 Redis 也不是万能的,热点 key 集中时(比如大 V 用户被大量关注者同时查询),Redis 的单线程模型也会成为瓶颈。更进一步的方案是在用户服务进程内再加一层本地缓存(如 Caffeine),进程内直接返回。
本地缓存与 Redis 有数据一致性问题。进程 A 更新了用户昵称,进程 B 的本地缓存还留着旧值。解决方式有两种:一种是通过 Redis Pub/Sub 广播缓存失效事件,让所有实例删除本地缓存;另一种是设置较短的本地缓存过期时间,比如 30 秒,在这期间接受略微不一致的数据。通信类产品对这个短暂不一致的容忍度较高,大多数场景是够用的。核心用户资料则以 Redis 为准,数据库为最终数据源。
4.2 缓存穿透、击穿与雪崩的应对方案
缓存穿透指请求查询一个不存在的用户 ID,缓存里没有,数据库里也没有,导致每次请求都打到数据库。简单方案是把“查无此人”也缓存起来,设置短过期时间,比如 5 分钟。稍微复杂但更可靠的是用布隆过滤器,在请求进来之前先判断用户 ID 是否可能存在,可能才继续查询。
缓存击穿指某个热点 key 在过期瞬间被大量请求同时打到数据库。解决办法是互斥锁:当缓存未命中时,只有一个线程允许去查数据库,其他线程等待或者返回默认值。这里不要把锁粒度设太大,否则高并发下会出现线程堆积。缓存雪崩则是大量 key 同一时刻失效,导致数据库瞬时压力暴增。预防手段是给缓存的过期时间加一个随机偏移量,避免集体失效。
4.3 数据库分表策略与读写分离
用户表到 500 万行以后,单表查询依然可行,但写入性能已经开始下滑。分表的策略要提前想好,最常用的是按用户 ID 哈希分表,比如拆成 16 张表,用 user_id % 16 定位目标表。这样单表数据量降下来,写入和查询都能维持在一个可控的范围内。但同时要意识到:分表之后跨表查询就做不了了。比如运营后台要做全量用户搜索,就不能直接对分表执行高效的 SQL,需要借助搜索引擎或者按手机号索引单独建一张映射表。
读写分离在这个场景下的收益容易被高估。用户服务的读多写少,但写入的绝对量并不大。当缓存已经承担了绝大部分读流量时,数据库的读压力并不突出,这时候额外引入主从复制反而增加了主从延迟带来的数据一致性问题。我的建议是先把缓存用好,分表分到位,当数据库读负载依然高时,再考虑读写分离。工程上每一步变更都要有明确的收益依据,不能盲目堆组件。
5. 在线状态推送链路与长连接网关协作
5.1 在线状态的语义与状态变更事件流转
在线状态在即时通讯里的语义不只是“在/不在”。用户可能在聊天框里活跃,也可能只是在后台挂着推送通道。为了不过度打扰,业界通常把状态分几档:在线、离线、忙碌、离开。但这些状态的计算不能全依靠客户端上报,因为客户端可以被杀掉,如果服务端只信客户端的“我要下线”消息,就会留下大量幽灵在线用户。
合理的流转逻辑是这样:长连接网关负责上报一条连接是否处于活跃状态,判定依据是心跳包是否按时到达。用户服务维护所有连接的活跃时间,当某连接超过阈值未心跳,就把它标记为超时离线。状态变更事件通过消息队列广播给订阅方,比如好友服务要更新好友列表里的在线状态展示,推送服务要决定是否发离线推送。事件流转的关键是不丢不重:生产者发送事件时标记一个全局递增的序号,消费方通过去重表保障幂等性。
5.2 心跳机制与超时判定策略
心跳包可以从两个方向理解:客户端证明自己还活着,服务端也在用同样的包测量链路质量。常见心跳间隔是 30 到 60 秒,考虑到移动网络的高延迟和弱网抖动,服务端的超时阈值通常设置为心跳间隔的 2 到 3 倍以上。比如客户端 30 秒发一个心跳,服务端 90 秒没收到才判定离线。这个宽严程度要平衡:太严导致频繁把活跃用户误判离线,消息发不出去;太松导致用户明明已经退出,好友还看到他在线,体验同样恶劣。
服务端判定离线以后,不应该立即广播离线状态。因为用户很可能只是在电梯里短暂失去网络,30 秒内就会重连。一个相对成熟的方案是离线延迟通知:连接断开后启动一个 60 秒的定时器,如果在这段时间内用户重新建立了新连接,就取消离线广播;如果超时仍未有新连接,才把状态改为离线并推给相关方。这个机制大幅减少了状态抖动带来的无效推送。
5.3 从“推全量”到“按需拉取”的事件订阅模型
早期做一个简单的 Web 聊天室,用户上线时直接把好友列表全量推给客户端,简单粗暴。但用户量一大、好友数量动辄上千,全量推送既浪费带宽也拖慢登录速度。更好的模型是分层处理:用户登录时,客户端先发出拉取请求,只拉取在线好友列表和最近聊天的联系人状态;对于长期不上线的联系人,不必实时更新其状态,客户端在点开聊天窗口时才去查询目标状态。服务端这边,则把状态更新事件交付给 Redis 的发布订阅频道,长连接网关只订阅自己负责的那部分用户的频道,避免广播风暴。
6. 实操记录:从零搭建 UserServer 微服务
6.1 环境选型与项目初始化
本次项目选型为:Java 17 + Spring Boot 3.x,数据库使用 MySQL 8.0,缓存使用 Redis 7.x,服务注册发现使用 Nacos,RPC 框架使用 Dubbo,消息队列使用 RocketMQ。选择这套组合的原因是它们都属于成熟稳定的开源组件,社区资料齐全,排错成本低。
初始化阶段,我先把工程拆成了四个 Maven 模块:user-api 模块存放对外暴露的接口定义和 DTO,user-server 模块存放业务实现,user-dao 模块存放数据库访问层,user-common 模块存放工具类和常量。api 与 server 分离的意义在于,将来其他服务(比如消息服务)只需要依赖 user-api 模块即可完成接口调用,不需要暴露数据库实体和内部实现。
6.2 核心表结构 DDL 与索引设计
用户信息表:核心字段是 user_id(BIGINT 自增主键)、手机号(唯一索引)、昵称、头像、账户状态、注册 IP、注册时间。为了防止手机号变更频繁导致主键和索引耦合,实际工程中我会把 user_id 设为逻辑主键,手机号单独建唯一索引。
设备表:device_id 是逻辑主键,user_id 建立普通索引,device_type 与 push_token 建立联合索引。每次用户登录,先通过 user_id + device_id 维度查设备记录,存在则更新 token,不存在则插入。
好友关系表:把 user_id 和 friend_id 建立联合唯一索引,同时在 friend_id 上单独建索引。联合索引支撑查询“我的好友列表”,friend_id 索引支撑查询“谁把我加了好友”。这个设计在数据量较大时能避免全表扫描。
6.3 核心业务代码流程拆解
登录接口的逻辑流程是最值得打磨的代码之一。完整流程图可以分为以下步骤:
客户端提交手机号与验证码(或密码)后,接口首先走风控规则校验:手机号当天发送验证码次数是否超限,IP 是否命中黑名单,设备指纹是否异常。校验通过后,核对验证码或者验证密码哈希。密码正确后,加载用户完整信息,检查账号状态是否正常(是否被封禁、注销)。状态正常则生成访问 Token 和刷新 Token,创建或更新设备记录,写入会话表。最后一步是向消息队列发送“用户登录成功”事件,触发好友在线状态更新和最近联系人列表刷新。每一步的失败都要返回明确的业务码,方便客户端定位问题。
好友添加的流程则要处理一个边界场景:A 添加 B,B 已经添加了 A,双方处于双向好友关系。如果 A 再次发起申请,服务端应该直接返回“你们已经是好友了”,而不是再生成一条重复申请。另一种边界场景是 A 先拉黑了 B,再添加 B 为好友。系统应该拒绝这条添加并提示先从黑名单移除,避免关系链状态互相矛盾。
6.4 服务部署与上线前的压测数据
服务开发完成后,我习惯在执行任何压测前先做一次代码走查,重点看有没有明显的阻塞点,比如同步调用 Redis 后又查数据库、循环内做网络请求等。这些点不仅影响性能,更影响后续排查问题的效率。
压测环境用 4 台 4 核 8G 的虚拟机部署 UserServer,Redis 单独一台,MySQL 单独一台。压测工具使用 JMeter 或自研脚本,模拟 5000 个并发用户同时执行登录、拉取好友列表、心跳上报三个主要操作。目标指标是登录接口 P99 小于 200 毫秒,好友列表 P99 小于 150 毫秒,心跳上报 P99 小于 100 毫秒。首轮压测发现问题集中在数据库连接池过小,导致连接等待时间过长;调大连接池并开启缓存后,P99 下降到目标区间内。另一个高频问题是无状态接口被热点用户打满:Redis 的单 key 读取达到瓶颈,把热点用户资料做了本地缓存处理后,性能显著改善。
7. 上线后的稳定性保障与调优策略
7.1 监控指标与告警规则
监控是线上系统的眼睛。用户服务至少要覆盖四个维度:接口层指标(QPS、P99、P95、错误率)、缓存层指标(Redis 命中率、连接数、慢查询)、数据库指标(连接池使用率、慢 SQL 数量、主从延迟)、队列积压指标(MQ 消费延迟、堆积数量)。告警阈值不能拍脑袋,要基于一段时间的历史数据定基线。比如缓存命中率基线 95%,低于 90% 就触发告警,说明可能存在缓存穿透或者失效策略问题。
7.2 日志规范与链路追踪落地
分布式环境排错离不开链路追踪。在 UserServer 中接入 tracing 中间件,每个请求携带全局 Trace ID,内部调用会在日志中输出 Span ID,这样一次完整的登录请求横跨网关、用户服务、Redis、MQ,最终可以通过 Trace ID 把全部日志串联起来。日志规范方面,重要的业务动作要输出结构化的 JSON 日志,字段包括用户 ID、请求路径、业务码、耗时。不要试图把完整的请求参数都打印出来,会导致磁盘写入压力过大的问题。
7.3 全链路压测与故障演练
上线前不光要做单服务压测,全链路压测更重要。模拟真实用户行为,从登录到发消息到收消息端到端走一遍,才能发现跨服务调用时的超时和排队问题。故障演练同样需要提前做:比如把 Redis 杀掉看服务是否还能降级运行,把 MQ 停掉看消息是否存在积压和丢失,把某个 UserServer 实例摘除看负载均衡是否踢掉异常节点。经历过这些演练,线上真出问题时不至于手足无措。
8. 常见问题排查与避坑经验
8.1 登录后偶发性的被强制下线
这个问题的根因往往是多端互踢逻辑误伤。同一端类型(比如 Web 端)的两次登录确实应该互踢,但某些客户端在断线重连时创建了新的会话 ID,又没有主动退出旧会话,导致同一端出现了两条活跃会话,新会话把旧会话踢掉,用户感知就是莫名其妙掉线。排查思路是通过设备 ID 和会话 ID 联合查询在线状态记录,检查是否存在重复会话;复现方式是在弱网环境下手动触发断线重连。规避措施是在重连时复用原有会话 ID,只有明确退出时才创建新会话。
8.2 Redis 缓存与数据库的数据一致性
用户修改头像或昵称后,好友端有时还能看到旧头像。这是因为更新数据库后,缓存未及时失效。标准做法是先更新数据库,再删除缓存,然后通过消息队列发送一个延迟的缓存删除任务作为兜底,防止删除缓存失败导致永久不一致。另一个坑是 Redis 的持久化策略没配置好,重启后缓存全部丢失,数据库瞬时负载上升,系统性雪崩。这种情况要合理配置 AOF 持久化,并提前做好缓存预热。
8.3 消息队列积压导致状态通知延迟
当系统瞬时流量过大,状态变更事件全部写入 RocketMQ,如果消费端处理不过来,积压会让用户看到过期状态。排查时要先看消费组日志中是否有大量重试的异常记录,其次看消费者线程数量和数据库连接池是否足够。短期扩容消费者实例可以缓解,长期还是要优化消费逻辑,把不必要的同步操作改为异步批量处理。
8.4 数据库连接池耗尽与慢 SQL 治理
连接池耗尽的表现是接口频繁超时,数据库负载并不高,但应用层拿不到连接。这种时候先查连接池配置,再看 SQL 有没有做“大事务”。典型的错误是在一个事务里做了好友列表查询、缓存更新、消息推送等一堆操作,导致连接长时间被占用。治理手段是严格规范事务边界,读操作用只读事务,写操作尽量拆小事务。慢 SQL 治理则要靠慢查询日志与 explain 分析索引,及时为高频查询补充联合索引。
8.5 用户状态脏数据导致的黑名单失效
黑名单判断出现漏网之鱼,通常不是判断逻辑写错,而是缓存里的黑名单集合没有及时同步。比如 A 拉黑 B 之后,消息服务还在用旧的黑名单缓存做校验。规避办法是为黑名单变更建立事件通知机制,让消息服务主动感知并更新本地缓存。同时,黑名单的校验要有主备双路径:缓存失效时必须回源数据库,不能因缓存故障就放行所有消息。
9. 常见问题速查表
| 故障现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 登录后频繁被挤下线 | 同端会话重复创建 | 查询会话表与在线状态记录 | 重连时复用旧会话 ID |
| 部分消息未送达 | 在线状态误判离线 | 查看长连接网关心跳日志 | 调优心跳超时与离线延迟广播 |
| 好友看到旧头像 | 缓存未及时失效 | 观察缓存更新链路日志 | 先更新库再删缓存,加上兜底延迟删除 |
| 接口频繁超时 | 连接池耗尽 | 查看连接池使用率与活动连接数 | 缩小事务粒度,扩大连接池容量 |
| 状态通知延迟严重 | MQ 消费积压 | 检查消费组积压数量与消费耗时 | 扩展消费者,异步化非关键逻辑 |
10. 经验总结与后续演进方向
10.1 设计前置:先定边界再写代码
即时通讯的用户服务,功能边界远比其他后台服务模糊。最容易犯的错误是一开始就把用户资料、在线状态、好友关系、推送设置全堆在一个 Service 里,等到后续要加新功能,代码已经拧成一团乱麻。用“事件驱动”的思路从第一天就设计模块边界,状态变更、关系变更都通过事件解耦,各模块只关心自己订阅的事件,后续无论加多少新模块,都不会把原来的结构扯散。
10.2 快速迭代阶段不要过度设计
如果你只是做一个几千人使用的内部工具,完全不需要一上来就拆十几个微服务、接消息队列、做分库分表。过早引入分布式组件,只会拖慢开发节奏、增加排错难度。我的建议是:先用单体 + Redis 把业务跑通,当用户量突破 10 万后,再按文中的演进步骤逐步拆分。每一步拆分都必须带着明确的性能指标或维护痛点去推进,否则就是自找麻烦。
10.3 后续可以扩展的方向
用户服务后续可以演进的方向还很多。一是开放平台能力:把注册、登录、关系链以 SDK 或者 OpenAPI 的方式提供给第三方开发者,成为一个独立的用户中心。二是支持更多第三方登录源:微信、手机号一键登录、邮箱登录等,做成可插拔的认证策略。三是引入更智能的风控模型:根据用户行为序列做异常检测,而不只是依赖固定规则。四是支持数据跨境合规的存储策略:不同地域的用户数据按合规要求存放在不同区域,这对服务架构的数据路由能力是一个新的挑战。
我自己在实际构建这个 UserServer 的过程中,最深的体会是“用户服务”这四个字听着基础,但它牵连的模块比任何业务功能都多。很多问题只有等系统上了量才会暴露,提前做好监控、预留好扩展位、保持模块边界清晰,是我写这篇文章最想让你带走的东西。