做IM系统用户管理子服务往往是看起来最简单、坑却最多的那一块。很多人一开始会觉得无非就是注册、登录、改个昵称、拉个好友列表有什么好写的但真正上手做才发现一个IM系统里最基础的用户服务牵扯到数据一致性、在线状态、增量同步、token安全、热点缓存一堆问题任何一个环节没设计好后面消息推送和会话模块全都跟着遭殃。这篇文章把我在实际开发用户管理子服务过程中涉及的架构设计、表结构、缓存策略、接口实现和踩坑实录都整理出来适合正在做IM项目、或者准备把用户模块独立成微服务的朋友参考。1. 项目背景与总体设计思路1.1 IM系统里为什么用户服务必须独立拆出来很多单体项目里用户模块就是一个UserController加几张表但在IM项目里用户服务几乎是整个系统的心脏。你想想看每一次消息发送都要查用户关系、每一个会话列表都要拉取用户资料、每一次好友操作都要变更关系链数据如果这些逻辑全部跟消息模块耦合在一起后期几乎无法维护。我参与的这个IM项目团队在一开始就确定了微服务拆分方向。用户管理子服务作为最底层的基础服务独立负责账号体系、用户资料、好友关系、在线状态和通信录同步其他所有服务都在它之上工作。这样的好处非常明显首先是解耦消息服务只管消息推送服务只管推送用户服务只管用户出了问题各查各的日志其次是弹性伸缩比如做活动拉新的时候注册量暴涨只需要单独扩容用户服务不必把整个系统都带起来再有就是故障隔离用户服务挂了已经在线的人还能继续收发消息不会导致全盘崩溃。不过独立拆服务也带来一个必须正视的问题跨服务的用户数据访问怎么办。不可能每个服务都直连用户数据库那样连接数先撑不住。所以实际架构里消息服务、会话服务都要通过用户服务提供的接口获取信息同时用Redis缓存扛住热点流量。这也是这次项目架构设计里反复权衡的一个点。1.2 用户管理子服务的职责边界先把这个服务到底管什么理清楚。我梳理下来用户管理子服务大概覆盖六个核心域账号注册与登录、用户资料管理昵称、头像、个性签名、好友关系链管理添加、删除、拉黑、在线状态管理、用户设置管理消息免打扰、加好友验证方式、增量同步多端登录时的资料同步。边界之外的东西坚决不往里放。比如消息推送那是推送服务的事文件存储那是对象存储服务的事敏感词过滤和审核那是内容安全服务的事。这个边界一开始就要在团队内部达成共识不然每个人都在用户服务里顺手加接口最后变成一个“大杂烩”。尤其是头像上传这个功能最容易被放在用户服务里。我的建议是头像上传尽量走独立的文件服务用户服务只保存一个头像URL和更新版本号。道理很简单文件上传涉及大流量和存储扩容如果图片流量把用户服务的网络带宽占满正常的查询接口就直接变慢了。1.3 整体技术架构与数据流向用户管理子服务的技术栈选型比较常规但组合起来效果很稳定。后端用的是Java栈Spring Boot对外提供接口内部通过OpenFeign给其他微服务调用数据库用MySQL存主数据Redis用来扛缓存和在线状态消息中间件用的RocketMQ来处理注册成功之后的异步事件比如初始化默认设置、创建通信录目录、发送欢迎消息等。数据流向分两条线。一条是写链路客户端请求进来先经过网关鉴权然后到用户服务接口层落库MySQL同时同步更新Redis缓存再发MQ事件给其他下游服务。另一条是读链路优先查Redis缓存没命中的时候查MySQL然后回填缓存。这套读写分离加上缓存兜底的模式在百万用户级别撑住日常流量问题不大。有一个细节值得说用户服务作为基础服务很多时候不是面对最终用户而是面对其他微服务。比如消息服务要判断两个用户是不是好友关系、会话服务要拉取群成员的昵称头像这些内部调用接口在设计时需要更高可用性不能因为某一瞬间的流量波峰就雪崩所以接口层做了统一熔断和降级处理下游服务依赖用户服务的时候也要做好容错。2. 技术选型与数据模型设计2.1 存储选型MySQL加Redis加对象存储的搭配用户数据的存储架构我最终定下来的是MySQL做持久化主存储Redis做缓存和在线状态存储头像和文件类的二进制数据放在对象存储里MySQL里只存元信息。把这个组合拆开说。MySQL的选择几乎没什么悬念成熟稳定事务支持好适合存储用户基础资料和好友关系这类强一致性要求高的数据。表引擎统一用InnoDB支持行锁和事务好友关系变更这种高频操作不会有锁表问题。Redis则是整个服务性能的主力注册用户资料、热点用户信息、好友关系列表、用户在线状态全都在Redis里有对应缓存层。对象存储解决的是头像这类大文件的存储问题下载走CDN数据库不会因为静态资源被打爆。很多人会问为什么不用MongoDB或者PostgreSQL代替MySQL说实话IM系统的用户数据量没有大到需要引入太复杂的存储方案MySQL在互联网行业的成熟运维经验最丰富出问题能找到的解决方案也最多。技术上够用就好不必为了新而新。2.2 核心表结构设计用户基础表和好友关系表是用户管理子服务最重要的两张表设计上花了很多功夫。用户基础表第一版设计的时候字段尽量精简核心就用户ID、昵称、头像URL、性别、个性签名、手机号或邮箱作为登录凭证。有一个很容易踩的坑是用户ID到底用自增ID还是雪花ID。IM系统里用户ID会出现在消息内容、分享链接、好友添加等对外场景用自增ID容易被遍历抓取而且MySQL自增主键在分库分表场景下会冲突。所以最终用户ID用了雪花算法生成的长整型对外展示和内部存储都用同一个ID避免了两套ID映射的麻烦。登录凭证字段的设计也值得注意。手机号和邮箱都可以作为登录凭证如果分开建两个字段并都允许为空会出现一个用户既有手机号又有邮箱时数据冗余的情况。实际实现时建了一张登录凭证表用户ID、凭证类型、凭证值三个字段一个用户多条记录灵活扩展且查询效率不差。好友关系表的细节更多。首先是双向好友关系处理A加B为好友成功后关系链里同时存在A对B、B对A两条记录每条记录都有一个状态字段区分正常、已删除、拉黑中。其次是好友备注名A给B备注的名字和B给A备注的名字可以不一样这两个字段必须分别保存不能只存一条关系一个备注名。最后是索引设计关系表上建了用户ID加状态的联合索引查询好友列表的语句走这个索引实测全表百万级数据量下响应时间稳定在几十毫秒。用户设置表同样是独立的一张表消息免打扰开关、好友验证方式允许任何人添加、需要验证、不允许添加、被拉黑之后是否可见在线状态等都放这张表里和用户基础表分开的好处是不会因为设置项频繁更新导致基础表的缓存频繁失效。2.3 缓存设计Key规范与数据结构选择缓存设计直接决定用户服务的查询性能这块的规范在项目里是强制执行的。Redis缓存的Key命名统一采用“服务名:业务域:用户ID:内容类型”的格式比如用户资料缓存是user:profile:10001好友关系缓存是user:friends:10001:list在线状态是user:status:10001。统一规范看着是小事但等后面缓存几十个业务域的时候排查问题和避免Key冲突会方便太多。数据结构的选择上用户资料缓存用Hash比用JSON字符串更合适。Hash类型可以单独修改某个字段而不需要整体重新序列化比如用户只是改了个昵称直接hset一个字段就够了其他字段的缓存不受影响。我之前用JSON字符串存整个用户资料用户修改昵称之后要对整个对象做读写和缓存更新在高频更新场景下浪费了不少不必要的序列化开销。好友关系列表则用ZSet存储把好友ID作为member成为好友的时间戳作为score这样拉取好友列表天然按时间排序也能方便地做分页。缓存过期时间也做过一个统一约定。用户基础资料这种变更频率不高但又需要高可用的数据设置为24小时过期并主动更新双写在线状态这种强时效数据不设置过期时间完全依赖状态上报来覆盖。项目里的原则是缓存必须有过期兜底防止极端情况下缓存和数据库不一致时永远无法自愈。3. 核心接口与实操实现3.1 注册流程服务端怎么防重复、防漏洞注册接口是整个用户服务流量最大的接口之一也是最容易被人搞的接口。我在实现注册流程时把重点放在三个环节合法性校验、防重复提交、数据一致性。客户端传入手机号和验证码之后先做格式校验然后用验证码确认手机号是真实在用的。服务端再根据手机号查登录凭证表判断这个手机号是否已经注册过这里要用唯一索引兜底因为哪怕代码逻辑里判断了不存在高并发下两个人同时注册同一个手机号还是可能插入两条记录唯一索引能保证数据库层面不会出现脏数据。注册时数据写入用了事务同时更新用户基础表、登录凭证表和用户设置表任何一个操作失败都会回滚。注册成功之后把新用户ID和手机号的映射写入Redis方便后续登录时快速反查同时发送一个注册成功的MQ事件下游服务收到事件后初始化用户通信录目录、发送好友推荐等。这里典型的坑是如果MQ发送失败会导致下游服务收不到通知所以发送事件要失败重试并记录日志重试几次仍然失败就要走定时任务对账补偿。3.2 登录与Token方案双Token为什么是标配登录流程在IM项目里和普通网站登录不一样不只是为了拿到一个身份凭证更重要的是保证多端登录互不影响、消息推送能定位到具体设备。每个设备独立登录所以登录成功后生成的是设备级的会话凭证而不是用户级。Token方案最终用了双Token设计短期TokenAccess Token有效期两小时用于实际接口调用的身份认证长期TokenRefresh Token有效期三十天用于短期Token过期后无感刷新。客户端拿Refresh Token换新的Access Token这样即使用户长期没打开App只要三十天内打开过一次就能自动续期体验上不会出现频繁要求重新登录的情况。Access Token采用了JWT格式签名密钥单独配置并且定期轮换JWT里只放用户ID、设备ID、会话类型几个必要字段不放任何敏感信息。Refresh Token则不是JWT而是服务端生成的随机串存Redis设置有效期并关联设备ID每次刷新使用后轮换一次旧Token作废发新Token。之所以不对称处理是因为JWT无法服务端主动作废而Refresh Token需要支持踢人下线、强制注销等操作必须能被服务端控制。登出操作要做的就是删除Redis里的Refresh Token、清理设备在线状态、发送下线通知给消息服务。这里有一个很容易被忽视的细节用户账号在别的设备上登录或者被管理员封禁时需要做到强制下线那就得把所有设备对应的Refresh Token全部删除同时维护一个用户维度的Token版本号存在Redis里Access Token校验时会检查这个版本号一旦版本号变了旧Access Token就算没过期也直接失效。3.3 增量同步与在线状态管理多端登录场景下一个手机端的用户改了昵称另一个平板端要尽快看到更新。如果每次改完立刻通知所有在线端会制造大量推送如果完全依赖客户端主动拉取数据又不及时。最终的方案是版本号加增量同步机制。用户基础表加一个数据版本号字段任何资料变更都让版本号加一。客户端批量请求增量同步接口时带上本地版本号服务端拉取更高级别的数据变化返回给客户端。Redis里用一个ZSet保存用户数据的版本历史member存字段IDscore存版本号这样断线重连后只需要同步缺失的那一段版本不需要全量拉取传输数据量小受网络波动影响也小。在线状态管理的设计经历了两次迭代。最初是用推模式用户上线上线都通过消息服务实时推给所有好友这样做的好处是好友用户看到在线状态最及时但代价是消息量大——一个千万用户级别的系统如果一个百万粉丝用户频繁上下线光状态推送就能把消息服务打爆。后来改成拉模式用户在线状态保存在Redis里状态变更只写Redis并通知自己的所有在线设备好友列表页面加载或刷新时才主动拉取好友的在线状态。这样把推送的扇出量降下来了代价是状态展示有延迟但对于绝大多数IM使用场景这个延迟完全能接受。在线状态的数据模型用的是Redis StringKey是用户IDValue是设备列表加最新心跳时间的JSON结构客户端每隔60秒发一次心跳服务端记录心跳时间。判断下线的逻辑是如果某个设备超过90秒没有心跳认为该设备离线从设备列表里移除一个用户所有设备都离线了整个用户才标记为离线。心跳报文越频繁状态越精准但服务端压力越大60秒心跳加90秒超时这个参数组合在线上跑下来是性能和准确率的平衡点。4. 常见问题与排查实录4.1 用户资料在不同端不一致改完昵称之后手机端显示新昵称电脑端还是旧昵称这个问题我们在灰度测试阶段复现过很多次。排查下来无非两种原因一种是某个端的本地缓存太久没有过期没有及时触发增量同步另一种是服务端缓存更新失败导致旧数据又返回给了客户端。服务端缓存更新失败这个原因比较隐蔽。当时的实现是先更新MySQL再删除缓存结果MySQL更新成功、删除缓存时Redis刚好发生主从切换旧缓存没被清掉后续读请求又把旧数据回填了。解决方案是引入更新缓存时的版本号比对写入缓存的用户资料版本号如果小于当前版本就丢弃同时修复了缓存删除失败的重试机制。排查这个问题时用了一个取巧的方法直接对比用户在MySQL里的数据版本号和客户端拿到的最新版本号版本号对不上顺着链路查下去很快就能定位。4.2 登录态突然失效用户反映App用着用着突然被退出登录这类问题的排查方向应该放在Refresh Token的刷新流程上。最容易出问题的是并发刷新场景客户端多个请求同时发现Access Token过期同时拿同一个Refresh Token去刷新后发起的刷新请求把先发起的旧Refresh Token覆盖掉导致其中一个请求拿到一个已经作废的Token然后用户被判定为异常登录强制下线。解决方式也简单Refresh Token的刷新接口要做并发控制同一个用户同一个设备的刷新请求串行化处理Redis里用分布式锁保证同一时间只有一个刷新请求在处理其他请求稍等一下复用同一个刷新结果就行。另外客户端也做了配合在Token过期时全局只触发一次刷新流程其他接口等待刷新完成后再重放业务请求从源头避免了并发刷新的可能性。4.3 在线状态误判与抖动用户明明在线好友那边显示离线或者刚显示在线过一会又变灰了过几分钟又亮了。这种抖动的常见原因是客户端网络切换——比如WiFi切换到5G网络时TCP连接断开重建心跳包发出去了但中间有一个短暂的断档超过90秒超时阈值后服务端误判离线。针对这个情况做了两方面优化一是客户端心跳不依赖网络连接状态每次进后台和回前台的时候主动补发一次心跳二是服务端收到心跳时如果发现用户之前已被标记为离线恢复在线状态时不要立刻向好友端广播上线通知而是延迟30秒等心跳稳定后再广播避免网络切换导致的上线、下线通知风暴。有一次线上观察到某个用户的好友端疯狂收到上下线通知原因就是这个用户在地铁里频繁进出隧道导致网络反复切换加上延迟广播机制后这个现象基本消除。4.4 缓存穿透与热点用户问题缓存穿透问题在用户服务里很容易被忽略因为大部分用户数据是存在的但偏偏注册、查询陌生人资料这些场景会频繁查一个不存在的用户ID。攻击者可以伪造一批不存在的用户ID批量请求资料接口请求全部穿透到MySQL。解决方法很直接用户查询接口缓存空结果空值也缓存5分钟同时用布隆过滤器做第一层拦截尽快把不存在的ID挡在前面。热点用户问题的典型场景是系统里某些达人账号粉丝量特别大好友状态被大量用户同时轮询一个ZSet缓存被高频读取单线程的Redis在这个Key上就成了瓶颈。临时方案是热点Key做多副本把同一个逻辑Key的数据复制到多个物理Key上读请求通过hash分散到不同副本上能在不改变整体架构的情况下把单Key压力降下来。更长期的方案是把这个读取逻辑移出同步链路走异步批量聚合接口一次查询批量合并多个热点用户的资料返回减少客户端单用户逐个请求的次数。4.5 好友关系数据不一致A给B发消息被提示“您还不是对方好友”但两边的好友列表里明明都显示对方。这类问题多半是好友关系表的分库分表策略和消息服务判断好友关系时用的索引不一致造成的。我们的好友关系表是按用户ID取模分片的消息服务判断好友关系时调用用户服务接口如果接口内部查询时没有带全分片键就会落到错误的库上把关系查丢了。排查下来发现是接口实现时在内部方法调用加了一层防重逻辑把好友关系查询参数做了缓存Key归一化归一化后丢失了分片键信息导致路由错库。去掉这层错误的缓存归一化强制所有好友关系查询必须携带用户ID全文路由后问题解决。这里也提醒我微服务接口对外暴露的入参必须携带足够的路由信息任何缓存Key的归一化逻辑都要仔细评审不然很容易在这个环节上埋雷。4.6 发布与灰度用户管理子服务这种基础服务发布的时候稍微有点问题就会影响全站。我们的做法是灰度发布分三步走先在灰度环境的网关层只放行内部测试账号的流量观察半小时日志和错误率然后把线上5%的流量切到新版本重点关注注册、登录、资料更新三个核心链路的错误率和耗时曲线稳定运行一小时后逐步放量到20%、50%、100%。每个阶段都有关联告警任何一步指标异常就自动回滚到旧版本。上线前还会做一个核对任务把灰度时段内用户基础表和好友关系表的变更数据导出和旧版本服务处理的结果做对账确保两边的数据完全一致。这项工作多花半小时但能挡住很多潜在的逻辑错误。有一次就是通过这个方法发现新版本在好友备注名更新时丢失了源备注字段全量发布前拦了下来。5. 项目复盘几个值得坚持的决策和踩坑总结做完这个用户管理子服务有几个决策我觉得坚持得对第一是用户基础表和登录凭证表分开设计这让后来增加邮箱登录、第三方登录都只需要在凭证表加记录改动成本非常低第二是缓存Key的规范化管理排查问题时省了大量时间第三是状态管理坚持拉模型配合心跳的折中方案虽然状态有秒级延迟但换来了系统整体的稳定性。踩过的最大坑是在设计接口时没有充分考虑到下游服务的异常场景。比如消息服务在用户服务不可用时应该返回降级数据而不是报错当时没有在客户端做这层容错导致用户服务一次短暂抖动引发了消息模块的连环超时。后来专门在所有下游调用方加了一组降级策略配置用户服务不可用时直接返回缓存里的旧资料而不是等超时再去查数据库。基础服务的稳定性不是靠自身不犯错保证的而是靠上下游一起兜底保证的。如果让我重新做一遍我会把用户数据模型设计的重心再往前移一点在动手写代码之前做一轮更彻底的数据对账和容量规划比如几个关键表的预期数据量、单Key缓存的大小上限、在线状态Redis的持久化策略。这些在项目初期没有被足够重视中期都回来找补了代价。做底层服务设计上的预见性永远比写代码的速度重要。