做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设计,短期Token(Access Token)有效期两小时,用于实际接口调用的身份认证;长期Token(Refresh 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存字段ID,score存版本号,这样断线重连后只需要同步缺失的那一段版本,不需要全量拉取,传输数据量小,受网络波动影响也小。在线状态管理的设计经历了两次迭代。最初是用推模式,用户上线上线都通过消息服务实时推给所有好友,这样做的好处是好友用户看到在线状态最及时,但代价是消息量大——一个千万用户级别的系统,如果一个百万粉丝用户频繁上下线,光状态推送就能把消息服务打爆。后来改成拉模式,用户在线状态保存在Redis里,状态变更只写Redis并通知自己的所有在线设备,好友列表页面加载或刷新时才主动拉取好友的在线状态。这样把推送的扇出量降下来了,代价是状态展示有延迟,但对于绝大多数IM使用场景,这个延迟完全能接受。在线状态的数据模型用的是Redis String,Key是用户ID,Value是设备列表加最新心跳时间的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的持久化策略。这些在项目初期没有被足够重视,中期都回来找补了代价。做底层服务,设计上的预见性永远比写代码的速度重要。
做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设计,短期Token(Access Token)有效期两小时,用于实际接口调用的身份认证;长期Token(Refresh 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存字段ID,score存版本号,这样断线重连后只需要同步缺失的那一段版本,不需要全量拉取,传输数据量小,受网络波动影响也小。在线状态管理的设计经历了两次迭代。最初是用推模式,用户上线上线都通过消息服务实时推给所有好友,这样做的好处是好友用户看到在线状态最及时,但代价是消息量大——一个千万用户级别的系统,如果一个百万粉丝用户频繁上下线,光状态推送就能把消息服务打爆。后来改成拉模式,用户在线状态保存在Redis里,状态变更只写Redis并通知自己的所有在线设备,好友列表页面加载或刷新时才主动拉取好友的在线状态。这样把推送的扇出量降下来了,代价是状态展示有延迟,但对于绝大多数IM使用场景,这个延迟完全能接受。在线状态的数据模型用的是Redis String,Key是用户ID,Value是设备列表加最新心跳时间的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的持久化策略。这些在项目初期没有被足够重视,中期都回来找补了代价。做底层服务,设计上的预见性永远比写代码的速度重要。