Go IM 源码实战(11):token 只是个 HASH,多端互踢的顺序错了就踢错人

你好,我是利威尔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 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。

文章目录

一、先分清两个词:认证和授权

认证(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,跨服务传下去。

所以:

  1. 只在同一个 RPC 调用链内有效;
  2. HTTP 请求头不会 自动变成这个,必须代码显式调 WithTempAdmin;
  3. 它本质是"内部服务自称系统身份",安全性完全依赖"内网可信"(第 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])里都存在。

实际影响有多大

诚实地说:不大。 因为

  1. 同端多 token 超限本来就少见(MaxNumOneEnd 一般 ≥ 3);
  2. 被误踢的 token 用户重新登录一次就好;
  3. 被踢的只是"同一端"的另一个会话,不影响别的端。

但它是一个真实的正确性缺陷 ,而且极难在测试里发现------因为 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 源码实战》

相关推荐
microrain1 小时前
SagooIoT 把协议解析和消息通知都放进了独立进程
物联网·golang·插件·sagooiot
充值内卷1 小时前
把 Visio 和 Axure 塞进一个 1.5 MB 的 exe:Visioxure 的架构与取舍
架构·axure
小李科技1 小时前
【性能优化】事件委托:目标定位与 React/Vue 实战
前端·vue.js·react.js·面试·性能优化·前端框架
QianhangQianping1 小时前
一块大屏的数据在浏览器里怎么变成画面:背景、子屏、前景三层结构
前端·低代码·架构·编辑器·数据可视化·大屏可视化
猿究院--Cu-Sn合金1 小时前
Spring Boot + DeepSeek 求职 Agent 实践:工具注册、多轮调用与简历 JD 对比
数据库·人工智能·spring boot·后端·deepseek
程序员cxuan1 小时前
别学 subagent 了,很可能要凉了。
后端·agent·ai编程
小咕聊编程1 小时前
基于Django的个人摄影博客网站设计与开发
后端·python·django
北冥you鱼1 小时前
Go 语言 GORM 使用 Preload:从关联查询到嵌套预加载的完整实践
开发语言·jvm·golang
microrain2 小时前
把规则链存成文件之后,上线一条规则只需改一个字段
物联网·golang·规则引擎·sagooiot