你好,我是利威尔xu。
这是**「Go IM 源码实战」系列第 11 篇**。前面 10 篇讲的是"消息怎么跑",这一篇讲**"凭什么让它跑"**------鉴权。
先说一个我们自测时遇到的诡异现象:A 手机点了"退出登录",B 手机上同一个账号还是能收发消息;过了大概十几秒,B 突然被踢下线。
这不是玄学。原因是 token 的状态被缓存在每个服务进程的内存里 ,而"踢人"的广播在 Redis Pub/Sub 上------广播到之前,那台机器认为你还是合法的。
这篇讲四件事:整个项目最"小而重"的 106 行(authverify)、token 在 Redis 里到底长什么样、CreateToken 为什么"先踢人再发牌"、以及本地缓存一致性的三种解法各自的天花板。
源码位置(全系列同一份):
- GitHub:https://github.com/CYQ111999/IM-Server
- 基线:OpenIM Server v3.8 瘦身版,main @
07f9ff6 - 本篇涉及:
pkg/authverify/token.go(106 行)、pkg/common/storage/controller/auth.go、pkg/common/storage/cache/redis/token.go、pkg/common/storage/cache/cachekey/token.go
前 4 篇:1 全局地图、2 WS 网关、3 HTTP API 层、4 发送链路。第 5 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。
文章目录
-
- 一、先分清两个词:认证和授权
- [二、Token 存哪:两条路和它们的代价](#二、Token 存哪:两条路和它们的代价)
- [三、authverify:106 行,影响面最大](#三、authverify:106 行,影响面最大)
-
- 五个判断函数,语义千万别混
- [最容易踩的坑:`CheckSystemAccount` ≠ `IsSystemAdmin`](#最容易踩的坑:
CheckSystemAccount≠IsSystemAdmin) - [一段死代码:`temp` 创建了就没用过](#一段死代码:
temp创建了就没用过) - [`WithTempAdmin` 到底解决什么问题](#
WithTempAdmin到底解决什么问题)
- [四、Token 在 Redis 里长什么样](#四、Token 在 Redis 里长什么样)
-
- [key:用平台名不用平台 ID](#key:用平台名不用平台 ID)
- [HASH 里存什么](#HASH 里存什么)
- 一个"空间换简单"的取舍
- [读全平台 token 为什么要按 slot 分组](#读全平台 token 为什么要按 slot 分组)
- 五、`CreateToken`:先踢人,再发牌
- 六、五种多端登录策略
- 七、一个真问题:三处代码依赖了不存在的顺序
- [八、管理端那 5 分钟的宽限](#八、管理端那 5 分钟的宽限)
- [九、本地缓存怎么失效:Pub/Sub 和它的天花板](#九、本地缓存怎么失效:Pub/Sub 和它的天花板)
-
- 三种解法,各有天花板
- [为什么不能只靠 TTL](#为什么不能只靠 TTL)
- 十、为什么注册必须是管理员
-
- [一个连带的约束:userID 不能含冒号](#一个连带的约束:userID 不能含冒号)
- [十一、踩坑记录:两个坑,一共 6 小时](#十一、踩坑记录:两个坑,一共 6 小时)
- 十二、面试会怎么问
- 十三、小结:一张表、两阶段、三种一致性
一、先分清两个词:认证和授权
| 认证(Authentication) | 授权(Authorization) | |
|---|---|---|
| 回答的问题 | "你是谁" | "你能干什么" |
| 本项目的实现 | JWT + Redis token 状态 | authverify 的五个判断函数 |
| 失败返回 | 401 / Unauthenticated |
403 / PermissionDenied |
两个混在一起会导致很隐蔽的 bug:一个用户登录成功了(认证通过),但去改别人的昵称(授权失败),如果代码统一返回"登录失效",客户端就会把他踢到登录页------用户一脸问号地重新登录,然后继续失败。
二、Token 存哪:两条路和它们的代价
| Cookie-Session | Token(JWT) | |
|---|---|---|
| 状态在哪 | 服务端(session 存储) | 客户端(token 自带)+ 服务端(状态表) |
| 横向扩展 | 要共享 session 存储 | 天然无状态 |
| 主动失效 | 删 session 即可 | 难点:JWT 签发后无法撤销 |
| 移动端友好 | 差(Cookie 麻烦) | 好 |
IM 这类应用必然选 Token(多端、移动端、跨域)。但 JWT 有个天生的缺陷:签发出去就撤不回来,除非你在服务端维护一张"这个 token 还有效吗"的表。
所以真实系统几乎都是"JWT + 服务端状态表"的组合------JWT 负责"内容不可篡改",状态表负责"能不能撤销"。本项目正是这么做的,代价是每次校验可能要多查一次 Redis。
📌 这一步想清楚很重要:纯 JWT 是无状态的,但"能踢人"就必然有状态。 想要"无状态 + 可撤销",只能靠很短的过期时间 + refresh token 换新,而 IM 的长连接场景不适合频繁换 token。
三、authverify:106 行,影响面最大
整个包两个文件,doc.go 是空注释,逻辑全在 token.go(106 行)。这是全项目代码量最小、影响面最大的文件------所有 RPC 的权限检查都过它。
五个判断函数,语义千万别混
token.go:20 CheckAdmin(ctx) → 不是 admin 就报 ErrNoPermission
token.go:31 CheckUserIsAdmin(ctx, uid) → 这个 uid 在不在管理员列表
token.go:35 CheckSystemAccount(ctx, lvl) → lvl >= constant.AppAdmin
token.go:52 IsAdmin(ctx) → IsTempAdmin || IsSystemAdmin
token.go:56 CheckAccess(ctx, owner) → opUserID == owner || IsAdmin
token.go:66 CheckAccessIn(ctx, owners...) → opUserID 在列表里 || IsAdmin
IsAdmin 是"或":
go
func IsAdmin(ctx context.Context) bool {
return IsTempAdmin(ctx) || IsSystemAdmin(ctx)
}
两个来源完全不同:
IsTempAdmin:上下文里有没有那个"内部调用"标记(WithTempAdmin打的);IsSystemAdmin:opUserID在不在配置的imAdminUser.userIDs列表里。
最容易踩的坑:CheckSystemAccount ≠ IsSystemAdmin
go
// :35 ------ 看用户表里的等级字段(数据驱动)
func CheckSystemAccount(ctx context.Context, level int32) bool {
return level >= constant.AppAdmin
}
// :104 ------ 看配置里的管理员 ID 列表(配置驱动)
func IsSystemAdmin(ctx context.Context) bool {
return datautil.Contain(mcontext.GetOpUserID(ctx), GetIMAdminUserIDs(ctx)...)
}
一个 App 管理员账号(app_manger_level = AppAdmin)去调 /user/user_register,不一定能过 ------如果他的 userID 不在 imAdminUser.userIDs 配置里,IsSystemAdmin 返回 false,请求被拒。
📌 在 IM 里,"App 管理员"和"IM 系统管理员"是两个概念:
- 前者是业务角色(第三方推送的通知账号、系统消息发送者),存在用户表的等级字段里;
- 后者是运维角色(能调管理接口),写在配置文件里。
这两个概念混起来,排查起来非常痛苦:日志里"我明明是管理员",但代码看的是另一个列表。
一段死代码:temp 创建了就没用过
token.go:79-97:
go
var tempAdminValue = []string{"1"}
const ctxTempAdminKey = "ctxImTempAdminKey"
func WithTempAdmin(ctx context.Context) context.Context {
keys, _ := ctx.Value(constant.RpcCustomHeader).([]string)
if datautil.Contain(ctxTempAdminKey, keys...) {
return ctx // 已有标记
}
if len(keys) > 0 {
temp := make([]string, 0, len(keys)+1)
temp = append(temp, keys...)
keys = append(keys, ctxTempAdminKey) // ← 这里改的是 keys,不是 temp
} else {
keys = []string{ctxTempAdminKey}
}
ctx = context.WithValue(ctx, constant.RpcCustomHeader, keys)
return context.WithValue(ctx, ctxTempAdminKey, tempAdminValue)
}
temp 创建、拷贝、然后再也没出现。
这是典型的改代码留下的残骸:原本大概写的是 keys = append(temp, ctxTempAdminKey),后来改成直接 append 到 keys,temp 忘了删。
⚠️ go vet 不会报这个 ------它只报"赋值后从未使用"的局部变量,而 temp 被 append 过,算"使用过"。
📌 这段代码还有个更隐蔽的点:如果 keys 底层数组还有富余容量,append(keys, ...) 会直接改底层数组,而 temp 共享同一块内存的前缀------两个切片互相覆盖。实际不会出问题 (ctx.Value 取出来的切片容量通常等于长度,append 会重新分配),但这是一个教科书级的切片别名陷阱。
判断一段代码该不该删,就看两件事:有没有副作用、有没有被读。 这里两样都没有。
WithTempAdmin 到底解决什么问题
第 8 篇讲过它的用法(worker 里 authverify.WithTempAdmin(context.Background())),这里补上原理:
它往 constant.RpcCustomHeader 这个 key 里塞一个标记字符串。这个 key 是 OpenIM 与 SDK 的约定------放在它下面的字符串切片会被 gRPC 拦截器序列化进 metadata,跨服务传下去。
所以:
- 只在同一个 RPC 调用链内有效;
- HTTP 请求头不会 自动变成这个,必须代码显式调
WithTempAdmin; - 它本质是"内部服务自称系统身份",安全性完全依赖"内网可信"(第 10 篇讲的 metadata 伪造问题在这里同样成立)。
四、Token 在 Redis 里长什么样
key:用平台名不用平台 ID
go
const UidPidToken = "UID_PID_TOKEN_STATUS:"
func GetTokenKey(userID string, platformID int) string {
return UidPidToken + userID + ":" + constant.PlatformIDToName(platformID)
}
拼出来:
UID_PID_TOKEN_STATUS:imAdmin:admin
UID_PID_TOKEN_STATUS:user001:ios
UID_PID_TOKEN_STATUS:user001:android
为什么用 ios 而不是 1? 因为运维用 redis-cli 排查时,看到 :ios 立刻明白,看到 :1 还得查表。代价是拼接多一次映射、反向解析多一次 strings.Split。
⚠️ 这个反向解析函数有个脆弱点:
go
func GetPlatformIDByTokenKey(key string) int {
splitKey := strings.Split(key, ":")
platform := splitKey[len(splitKey)-1] // 假设最后一段是平台名
return constant.PlatformNameToID(platform)
}
而临时 token 的 key 是 UID_PID_TOKEN_STATUS::TEMPORARY:{userID}:{platformName}:{token}------最后一段是 token 本身。如果谁把这个 key 传进去,会解析出错误的平台(查不到就返回 0 或 -1)。
目前没有这种调用路径,属于"设计上埋了个雷,暂时踩不到"。📌 这类"靠字符串位置约定"的解析,一定要配一个 Parse 失败时的显式报错,不能默默返回 0。
HASH 里存什么
go
func (c *tokenCache) SetTokenFlag(ctx context.Context, userID string, platformID int, token string, flag int) error {
key := cachekey.GetTokenKey(userID, platformID)
return c.rdb.HSet(ctx, key, token, flag).Err()
}
key = UID_PID_TOKEN_STATUS:user001:ios
field = eyJhbGciOi...(完整 token,200+ 字节)
value = 1(正常) / 2(被踢)
TTL = tokenPolicy.expire 天
把完整 token 当 field,看起来浪费空间,但这是唯一能精确定位的方式------你没法从 JWT 里提取一个短 ID 当 field,因为签名依赖整个 payload,任何截断都会导致无法验签。
一个"空间换简单"的取舍
SetTokenFlagEx 比 SetTokenFlag 多一句 Expire,而 EXPIRE 是重置整个 HASH 的 TTL,不是单个 field 的。
后果:用户在 iOS 端登录两次,第一个 token 在第 3 天又登录了一次,HASH 的 TTL 被重置为 7 天------第一个 token 在 Redis 里能活到第 10 天,但它的 JWT 在第 7 天就过期了。
Redis 里留了 3 天的无效数据。这是有意的取舍:Redis HASH 不支持 field 级 TTL,要按 field 过期就得把每个 token 拆成独立的 key,那 key 数量会翻好几倍。
读全平台 token 为什么要按 slot 分组
GetAllTokensWithoutError 要读"这个用户所有平台的 token":
go
keys := cachekey.GetAllPlatformTokenKey(userID)
err := ProcessKeysBySlot(ctx, c.rdb, keys, func(ctx context.Context, slot int64, keys []string) error {
pipe := c.rdb.Pipeline()
// 对这批 key 批量 HGETALL
})
为什么要 ProcessKeysBySlot? 因为 Redis Cluster 不允许跨 slot 的 MULTI/EXEC 。如果这些 key 落在不同 slot,用事务会直接报 CROSSSLOT。
ProcessKeysBySlot 把 key 按 slot 分组,组内用 pipeline 批量执行------既避开跨 slot 问题,又保住批量性能。
📌 "Redis Cluster 下怎么做批量操作",标准答案就是"按 slot 分组 + 组内 pipeline"。 这段代码单看"为什么这么麻烦",删了在单机测试完全正常,一上集群就炸------典型的"看不出原因但删不得"。
五、CreateToken:先踢人,再发牌
pkg/common/storage/controller/auth.go:93-139:
go
func (a *authDatabase) CreateToken(ctx context.Context, userID string, platformID int) (string, error) {
tokens, err := a.cache.GetAllTokensWithoutError(ctx, userID) // ① 取该用户所有端 token
deleteTokenKey, kickedTokenKey, adminTokens, err := a.checkToken(ctx, tokens, platformID) // ② 算踢谁
if len(deleteTokenKey) != 0 { a.cache.DeleteTokenByTokenMap(ctx, userID, deleteTokenKey) } // ③ 物理删无效
if len(kickedTokenKey) != 0 { a.cache.SetTokenFlagEx(ctx, userID, plt, k, constant.KickedToken) } // ④ 标记被踢
if len(adminTokens) != 0 { a.cache.DeleteAndSetTemporary(ctx, userID, constant.AdminPlatformID, adminTokens) } // ⑤ 管理端
claims := tokenverify.BuildClaims(userID, platformID, a.accessExpire) // ⑥ 构造 claims
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(a.accessSecret)) // ⑦ 签名
a.cache.SetTokenFlagEx(ctx, userID, platformID, tokenString, constant.NormalToken) // ⑧ 写状态
return tokenString, nil
}
注意顺序:先算踢谁 → 再踢 → 最后才签发。
为什么不"先签发再踢"?因为中间会有几毫秒的窗口"新旧 token 同时有效"。对"同端互踢"这种语义来说,这个窗口不严谨------先踢后发,保证任意时刻同端最多一个有效 token。
📌 这是一条可以迁移的原则:任何"替换型"操作(换 token、换配置、换主节点),都应该"先注销旧的,再启用新的",而不是反过来。 反过来会出现"两个都以为自己是主"的双主窗口。
两阶段清理:先标记,再删除
checkToken 的第一轮分类里有个细节:
go
if err != nil || v != constant.NormalToken {
deleteToken[plfID] = append(deleteToken[plfID], k) // JWT 解析失败 或 状态不是 Normal → 待删
}
"状态不是 Normal"就删------那被踢的 token 岂不是被删掉了?用户下次请求怎么收到"你被踢了"?
不矛盾,是两个时机:
| 时机 | 动作 | 目的 |
|---|---|---|
| 踢的瞬间 | 改状态为 KickedToken(2) |
用户下次请求收到 ErrTokenKicked,能给出准确提示 |
| 下次登录时 | 把这些状态为 2 的 token 物理删除 | 回收空间,不再需要提示 |
这是两阶段清理:先"标记"保证语义正确,再"删除"回收空间。很实用的模式------很多东西都可以这么处理(软删 + 定期物理删、标记位 + 异步清理)。
六、五种多端登录策略
checkToken 的第二轮(auth.go:169-250)按配置走五种策略。这一节是全项目逻辑最绕的 100 行。
| 策略 | 语义 | 典型产品 |
|---|---|---|
DefalutNotKick |
默认不互踢,但同端数量超过 MaxNumOneEnd 就踢最早的 |
宽松型 IM |
AllLoginButSameTermKick |
各端都留着,同端只留最新一个 | 微信(手机+PC+Web 同时在线) |
PCAndOther |
PC 类互不踢,其他端只留一个,优先保 Android | 钉钉/企业 IM |
AllLoginButSameClassKick |
同类(移动/PC/Web)内只留一个 | 折中方案 |
| 管理端 | 新管理端登录 → 旧管理端 token 全部作废 | 后台系统 |
逐条看关键实现
DefalutNotKick (:170-181):
go
for plt, ts := range loginTokenMap {
l := len(ts)
if platformID == plt { l++ } // 加上即将登录的这个(还没进 map)
limit := a.multiLogin.MaxNumOneEnd
if l > limit { kickToken[plt] = ts[:l-limit] } // 踢最早的那批
}
AllLoginButSameTermKick (:182-190):
go
for plt, ts := range loginTokenMap {
kickToken[plt] = ts[:len(ts)-1] // 每端留最后一个
if plt == platformID {
kickToken[plt] = append(kickToken[plt], ts[len(ts)-1]) // 同端连最后一个也踢
}
}
逻辑是"每端留最后一个;如果就是当前登录的端,最后那个也要踢",因为新的马上顶上来。
PCAndOther (:191-228)最绕,用 reserveToken 布尔标记"已经预留了一个非 PC 端",遇到 Android 就把之前预留的踢掉改保 Android。
AllLoginButSameClassKick (:229-247)用 reserved map 记录"这个类别已经留过了",逻辑比策略 2 清晰。
管理端 (:260-265):新管理端登录 → 旧管理端 token 全部作废。
七、一个真问题:三处代码依赖了不存在的顺序
这是我读这段代码最大的收获,也是我认为最值得在面试里讲的一处。
看 DefalutNotKick 的核心一行:
go
kickToken[plt] = ts[:l-limit] // 踢掉"最早"的
注释和语义都写着"踢最早的"。但这个写法隐含要求 ts 是按时间排序的。
而 ts 是怎么来的?
go
tokens, _ := a.cache.GetAllTokensWithoutError(ctx, userID) // map[int]map[string]int
for plfID, tks := range tokens { // ← Go map 遍历,顺序随机
loginTokenMap[plfID] = append(loginTokenMap[plfID], k)
}
GetAllTokensWithoutError 内部用的是 HGetAll,返回 map[string]string------还是 map,还是无序。
所以 ts 里的 token 顺序是不确定的,ts[:l-limit] 踢掉的可能是最新的,而不是最早的。
同样的隐患在 AllLoginButSameTermKick(ts[len(ts)-1])、PCAndOther(ts[len(ts)-1])、AllLoginButSameClassKick(ts[len(ts)-1])里都存在。
实际影响有多大
诚实地说:不大。 因为
- 同端多 token 超限本来就少见(
MaxNumOneEnd一般 ≥ 3); - 被误踢的 token 用户重新登录一次就好;
- 被踢的只是"同一端"的另一个会话,不影响别的端。
但它是一个真实的正确性缺陷 ,而且极难在测试里发现------因为 map 遍历在同一个进程里对同一份数据往往表现稳定(Go 的 map 遍历起点随机,但小 map 容易重复命中同一顺序),测试环境跑一百次可能都对。
📌 正确的做法是显式排序 :JWT 的 claims 里有 Expire(过期时间),按它排序等价于按签发时间排序:
go
sort.Slice(ts, func(i, j int) bool { return claimsOf(ts[i]).Expire < claimsOf(ts[j]).Expire })
代价是每条 token 都要解析一次 JWT(本来就已经解析过了,可以复用)。
这是"依赖 map 遍历顺序"这个坑的标准形态 :代码能跑、测试能过、线上大概率也没事,但它的正确性是碰运气 的。面试时说这一条,比说十句"我读过源码"更有说服力------因为它需要你同时看懂数据结构、语言特性和业务语义。
八、管理端那 5 分钟的宽限
管理端被顶掉时走的是 DeleteAndSetTemporary(redis/token.go:212-227):
go
func (c *tokenCache) DeleteAndSetTemporary(ctx context.Context, userID string, platformID int, fields []string) error {
for _, f := range fields {
k := cachekey.GetTemporaryTokenKey(userID, platformID, f)
c.rdb.Set(ctx, k, "", time.Minute*5) // 5 分钟有效的临时记录
}
key := cachekey.GetTokenKey(userID, platformID)
c.rdb.HDel(ctx, key, fields...) // 从主 HASH 删掉
}
关键:它不是"标记被踢",而是"从主 HASH 删掉 + 建一个 5 分钟的临时 key"。
而 parseToken 里的逻辑是(auth.go:168-176):
go
if len(m) == 0 {
isAdmin := authverify.CheckUserIsAdmin(ctx, claims.UserID)
if isAdmin {
if err = s.authDatabase.GetTemporaryTokensWithoutError(ctx, claims.UserID, claims.PlatformID, tokensString); err == nil {
return claims, nil // ⚠️ 临时 key 存在 → 放行
}
}
return nil, servererrs.ErrTokenNotExist.Wrap()
}
临时 key 存在 = 放行。 也就是说:被顶掉的管理端旧 token,在 5 分钟内仍然有效。
为什么这么设计
我一开始以为是 bug,后来想明白大概是这个原因:管理后台的页面往往同时发好几个请求(拉用户列表、拉群列表、拉配置)。如果用户在新窗口登录,旧窗口正在飞的请求全部 401,页面会满屏报错。
给 5 分钟宽限,旧窗口的请求能完成,用户看到的是"你已在别处登录",而不是"整个页面崩了"。
📌 但它的代价是:这 5 分钟内两个管理端都能操作。 对"高权限操作"(改密码、删用户、改配置)来说,这个窗口是真实的风险。
如果让我改 :宽限只给读接口,写接口不给。或者把临时 key 里存一个 readonly=true,parseToken 之后在写操作上再判一次。
这是一个很典型的"体验 vs 安全"的取舍,而且代码里没有一行注释解释为什么是 5 分钟。这类"魔法数字 + 无注释"的地方,往往是项目里最需要补文档的。
九、本地缓存怎么失效:Pub/Sub 和它的天花板
第 11 篇开头那个"B 手机还能用十几秒"的现象,根因在这里。
redis/token.go:229-247:
go
func (c *tokenCache) removeLocalTokenCache(ctx context.Context, keys ...string) {
topic := c.localCache.Auth.Topic
data, err := json.Marshal(keys)
...
c.rdb.Publish(ctx, topic, string(data)) // 广播:这几个 key 的本地缓存作废
}
每次改 token 状态,除了改 Redis,还要通过 Redis Pub/Sub 广播一条消息,通知所有节点删本地缓存。
因为每个服务进程都在内存里缓存了 token 状态(避免每次校验都查 Redis),一旦某个节点改了状态,其他节点的缓存就脏了。
三种解法,各有天花板
| 方案 | 做法 | 天花板 |
|---|---|---|
| 短 TTL | 本地缓存 1 秒过期 | 最长 1 秒不一致(简单但必然有窗口) |
| Pub/Sub 广播 | 改了就发消息,各节点收到后删本地 | 消息丢了 = 永久不一致(无重试) |
| 版本号 | 每个 key 带版本,读时比对 | 多一次查询,复杂 |
本项目选了 Pub/Sub。它的风险是消息丢失 :某个节点在广播时刚好断线(或 Redis 在那一刻主从切换丢了这条消息),它的本地缓存会一直脏着------直到进程重启。
代码里只 log.ZWarn 了发布失败,没有重试、没有兜底。
📌 这是我给出的改进方案 :Pub/Sub 不要单独用,配一个"兜底 TTL"(比如本地缓存最多活 30 秒)。广播负责"快",TTL 负责"对"。 两个一起用,最坏情况是 30 秒不一致,而不是"永久不一致直到重启"。
这个组合模式极其通用 :CDN 刷新、配置推送、DNS 更新、特征开关------任何"推送式失效"都要配一个"轮询式兜底",因为推送一定会丢。
为什么不能只靠 TTL
有人会问:那为什么不干脆"本地缓存只存 1 秒"?
因为 token 校验是每个请求都要做一次 的。API 层每秒几千请求,本地缓存 1 秒意味着每个节点每秒还要回源几千次 Redis------本来本地缓存就是为了省这个。1 秒 TTL 等于关掉了本地缓存。
十、为什么注册必须是管理员
user_register 这个接口在 IM 里是管理员专用的。这是 IM 和普通 Web 应用最大的产品差异之一:
| 普通 Web | IM | |
|---|---|---|
| 注册入口 | 开放(手机号/邮箱验证) | 不开放,由你的业务后端调 API 创建 |
| 用户体系归属 | 应用自己 | 必须和业务系统的用户 ID 一致 |
原因很直接:IM 的用户 ID 是业务系统的主键。 如果你的 App 有自己的用户表(用户 ID 是 u1001),IM 里的 userID 也必须叫 u1001,否则消息、好友、群全部对不上。
所以正常架构是:
用户在你的 App 注册 → 你的后端生成 u1001 → 后端拿管理员 token 调 IM 的 /user/user_register 创建 u1001
IM 不负责注册,只负责"给你一个 ID 分配聊天能力"。 这也解释了第 1 篇说的"IM 是嵌入式能力,不是独立产品"。
📌 面试里被问"IM 怎么做注册",答"IM 通常不开放注册,由业务后端用管理员身份调 API 创建"------这一句就能区分"做过 IM"和"只做过 Demo"。
一个连带的约束:userID 不能含冒号
因为 token key 是 UID_PID_TOKEN_STATUS:{userID}:{platformName} 拼出来的,而反向解析靠 strings.Split(key, ":") 取最后一段------如果 userID 里带冒号,key 的分段数就变了,解析会错位。
同理,会话 ID 也是拼出来的(si_A_B / sg_G),userID 里的冒号会让单聊会话 ID 在拆分时歧义。
所以这个项目有一条隐式约束:userID 只能是 [a-zA-Z0-9_-] 这类安全字符,不能包含分隔符。
📌 这条约束很值得注意,因为它在代码里没有任何显式校验 ------加一个新人,用手机号当 userID 没事,哪天有人用 user:1001 就会炸,而且炸的地方离原因很远(表现为"登录成功但拿不到 token 状态")。
用自己的项目对照一下 :凡是"多个字段拼成一个字符串当 key"的地方,都要在入口做一次字符校验,别指望用的人记得。
十一、踩坑记录:两个坑,一共 6 小时
坑一:退出登录后另一个端还能用(4 小时)
现象:A 手机退出登录,B 手机同账号还能收发消息;十几秒后 B 被踢。
定位:一开始怀疑"退出登录没实现",查代码发现 Logout 确实调了 SetTokenFlag(kicked)。加日志才看到:B 所在的网关节点,parseToken 走的是本地缓存命中路径,根本没查 Redis。
根因就是第九节:广播还没到(或者丢了)。这不是"退出登录失效",是"最终一致"。
改动:给本地缓存加了 30 秒兜底 TTL。改完最坏情况是 30 秒,可接受。
教训 :任何"立即生效"的承诺,在有多级缓存时都要打个问号。 产品文档上写"退出后立即失效",实现上只能说"最多 30 秒内失效"。
坑二:管理员账号进不了管理后台(2 小时)
现象:给一个账号设了 app_manger_level = AppAdmin,调管理接口还是 403。
定位:就是第三节那个坑------CheckSystemAccount(看用户表等级)和 IsSystemAdmin(看配置里的 ID 列表)是两回事。这个接口用的是后者。
解法:把他的 userID 加进 imAdminUser.userIDs。
教训 :两个名字很像的函数,语义可能完全不相关。 读代码时看到 CheckXxx / IsXxx,一定要点进去看数据来源是"表"还是"配置"。
十二、面试会怎么问
| 追问 | 怎么答(一句话版) |
|---|---|
| JWT 为什么不能撤销? | 签发即生效,要撤销必须在服务端维护状态表,所以真实系统都是"JWT + 状态表" |
| token 存 Redis 用什么结构? | HASH,key 是 user:platform,field 是完整 token,value 是状态(正常/被踢) |
| 为什么 field 存完整 token? | 签名依赖整个 payload,无法截断提取短 ID |
| 踢人为什么要"先踢后发"? | 避免新旧 token 同时有效的双主窗口 |
| 五种多端策略怎么选? | 看产品:宽松不互踢 / 同端互踢(微信)/ PC 不踢(钉钉)/ 同类互踢 |
| 多节点本地缓存怎么保证一致? | Pub/Sub 广播失效 + 兜底 TTL,只靠广播会永久不一致 |
| 这个项目鉴权你看出什么问题? | 互踢策略依赖 map 遍历顺序(应显式按过期时间排序)、temp 死代码、管理端 5 分钟宽限无注释、广播失败无兜底 |
| 为什么 IM 不开放注册? | userID 必须与业务系统主键一致,由业务后端用管理员身份创建 |
十三、小结:一张表、两阶段、三种一致性
- 一张表 :token 状态表。JWT 负责不可篡改,状态表负责可撤销------"能踢人"就一定是有状态的。
- 两阶段清理:先标记被踢(保证语义),下次登录再物理删除(回收空间)。
- 三种一致性 :短 TTL(简单、有窗口)、Pub/Sub(快、会丢)、版本号(准、复杂)。推荐广播 + 兜底 TTL 组合。
这张表建议收藏,做任何"登录态"相关的设计都能照着过:
| 检查项 | 该问什么 | 本项目的答案 |
|---|---|---|
| 状态在哪 | 纯 JWT 还是带状态表? | 状态表(多端 + 可踢) |
| key 设计 | 按什么维度隔离? | user:platform,用平台名不用 ID |
| 撤销 | 怎么让 token 立即失效? | 改状态为 Kicked,下次登录物理删 |
| 互踢 | 五种策略选哪个? | 可配置;实现依赖 map 顺序 ⚠️ |
| 多节点一致 | 本地缓存怎么失效? | Pub/Sub,无兜底 TTL ⚠️ |
| 内部身份 | 后台任务怎么过鉴权? | WithTempAdmin 塞 metadata 标记 |
| 注册 | 开放还是管理员? | 管理员专用,userID 与业务系统一致 |
留个问题,评论区一句话能答: 如果把 token 状态表的 TTL 从 7 天改成 1 小时(比 JWT 的过期时间短),会发生什么?用户会不会被强制下线?
下一篇:《Go IM 源码实战(12):群成员 5000 人,改个群名要写 5000 条记录》------群组、成员、关系链的存储与扇出。
作者:利威尔xu | CSDN 专栏《Go IM 源码实战》