Redis 从 0 到 1(十九):JWT 还是 Redis Session?登录态存储的 Go 实现

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [Redis 从 0 到 1(十九):JWT 还是 Redis Session?登录态存储的 Go 实现](#Redis 从 0 到 1(十九):JWT 还是 Redis Session?登录态存储的 Go 实现)
    • [一、现象:JWT 上线三个月,又灰溜溜换回 Session](#一、现象:JWT 上线三个月,又灰溜溜换回 Session)
    • [二、Go 实操:登录态四件套(收藏点 1)](#二、Go 实操:登录态四件套(收藏点 1))
    • [三、四个易错点(收藏点 2)](#三、四个易错点(收藏点 2))
    • 四、命令清单
    • 五、排查步骤:用户"被登出"或"登不掉"
    • 六、总结

Redis 从 0 到 1(十九):JWT 还是 Redis Session?登录态存储的 Go 实现

上一篇《Redis 从 0 到 1(十八):List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操》我们实现了可靠消息投递,链接:https://blog.csdn.net/2501_92769340/article/details/167399809 。

本篇处理所有系统都有的需求:登录态。先回答那个经典争论:JWT 还是 Session?

一、现象:JWT 上线三个月,又灰溜溜换回 Session

团队把登录态从 Redis Session 迁移到 JWT(无状态、少一次 Redis 调用),上线后运营提出三个需求,全部被 JWT 卡住:

  1. "后台发现这个账号在洗钱,立刻踢下线"------JWT 签出去就管不了了,只能等过期;
  2. "同一个账号最多登录 3 台设备"------JWT 无状态,服务端不知道签了几张票;
  3. "用户改了密码,旧登录态全部失效"------同上,做不到。

JWT vs Redis Session 的本质是无状态 vs 可控性的取舍:

维度 JWT Redis Session
服务端开销 验签即可,零存储 每次请求查 Redis
踢人下线 / 强制失效 ❌ ✅ DEL 即踢
多端登录控制 ❌ ✅
过期控制 签发时定死 ✅ 滑动续期、服务端随时改
适合 服务间调用、第三方开放 API C 端用户登录态

工程结论:C 端用户登录态用 Redis Session,服务间鉴权用 JWT,各干各的活。本篇实现完整的 Redis Session 方案。

二、Go 实操:登录态四件套(收藏点 1)

go 复制代码
package session

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

func newToken() string {
	b := make([]byte, 32)
	rand.Read(b)
	return base64.RawURLEncoding.EncodeToString(b)
}

const (
	prefix   = "session:"        // 四段式命名(第 4 篇):session:<token>
	maxTTL   = 7 * 24 * time.Hour // 绝对过期:无论活不活跃最多 7 天
	idleTTL  = 2 * time.Hour      // 空闲过期:滑动续期的基准
	deviceKey = "session:user:"  // 多端控制的用户→token 索引
)

// Login 登录签发:token 存 session,同时登记到用户设备索引(多端控制用)
func Login(ctx context.Context, rdb *redis.Client, userID string, maxDevices int) (string, error) {
	token := newToken()
	key := prefix + token
	pipe := rdb.Pipeline()
	pipe.HSet(ctx, key, "user_id", userID, "created", time.Now().Unix())
	pipe.Expire(ctx, key, maxTTL)
	// 用户设备索引:ZSet,score=登录时间戳,member=token
	pipe.ZAdd(ctx, deviceKey+userID, redis.Z{Score: float64(time.Now().Unix()), Member: token})
	if _, err := pipe.Exec(ctx); err != nil {
		return "", err
	}
	// 踢出最老设备,只保留 maxDevices 个
	if maxDevices > 0 {
		rdb.ZRemRangeByRank(ctx, deviceKey+userID, 0, int64(-maxDevices-1))
	}
	return token, nil
}

// Verify 中间件校验:返回 userID;redis.Nil = 未登录
func Verify(ctx context.Context, rdb *redis.Client, token string) (string, error) {
	key := prefix + token
	userID, err := rdb.HGet(ctx, key, "user_id").Result()
	if errors.Is(err, redis.Nil) {
		return "", err
	}
	if err != nil {
		return "", err
	}
	// 滑动续期:活跃就延长 idleTTL(Expire 只影响空闲期,maxTTL 由 HSET 时的绝对 TTL 兜底)
	rdb.Expire(ctx, key, idleTTL)
	return userID, nil
}

// Kick 踢人下线:后台风控 / 改密码时调用
func Kick(ctx context.Context, rdb *redis.Client, userID string) error {
	// 删掉用户名下所有 token(索引里查出来逐个 DEL)
	tokens, err := rdb.ZRange(ctx, deviceKey+userID, 0, -1).Result()
	if err != nil {
		return err
	}
	if len(tokens) > 0 {
		keys := make([]string, len(tokens))
		for i, t := range tokens {
			keys[i] = prefix + t
		}
		if err := rdb.Del(ctx, keys...).Err(); err != nil {
			return err
		}
	}
	return rdb.Del(ctx, deviceKey+userID).Err()
}

// Logout 主动登出:只删当前 token
func Logout(ctx context.Context, rdb *redis.Client, token string) {
	rdb.Del(ctx, prefix+token)
}

HTTP 中间件接线:

go 复制代码
func AuthMiddleware(rdb *redis.Client) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token := r.Header.Get("Authorization")
			userID, err := session.Verify(r.Context(), rdb, token)
			if errors.Is(err, redis.Nil) {
				http.Error(w, `{"code":401}`, http.StatusUnauthorized)
				return
			}
			if err != nil {
				http.Error(w, `{"code":500}`, http.StatusInternalServerError)
				return
			}
			ctx := context.WithValue(r.Context(), "userID", userID)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

三、四个易错点(收藏点 2)

易错点 正确做法
只设 idleTTL 不设绝对过期 两者都要:HSET 时 EXPIRE maxTTL(绝对),校验时 EXPIRE idleTTL(滑动)------不然"永不掉线"的脏会话会累积
token 直接当 key 明文存 生成随机 token(32 字节熵足够), value 里只放 user_id 等必要字段,别把密码哈希放进去
滑动续期每请求都跑 EXPIRE 高 QPS 下是浪费;可以加"距上次续期超过 5 分钟才续"的节流判断
多端踢出不同步索引 ZRemRangeByRank 踢掉 token 后要同步检查 session key 是否已删,索引和实体要一致(或对账任务兜底)

四、命令清单

命令 作用
HSET session:<token> user_id ... + EXPIRE 签发(Hash 存字段,TTL 双轨)
HGET session:<token> user_id 校验
EXPIRE session:<token> <idleTTL> 滑动续期
DEL session:<token> / ZREM session:user:<uid> <token> 登出 / 设备下线
ZCARD session:user:<uid> 当前在线设备数

五、排查步骤:用户"被登出"或"登不掉"

  1. 被登出:查 token 的 TTL 是不是只剩很短(idleTTL 太短)或 key 不存在(被踢/被运维清库);
  2. 登不掉(踢了还在线):Kick 只删了索引没删实体,或用户重新登录又生成新 token(检查风控逻辑是不是漏了 ZREM 之后的重登拦截);
  3. Redis 内存涨:session key 没带 TTL------SCAN MATCH session:* 抽查 + OBJECT IDLETIME 找幽灵会话。

六、总结

  1. JWT 换 Session 的理由从来不是性能,是可控性:踢人、限设备、强制失效;
  2. Session 双 TTL:maxTTL 绝对过期 + idleTTL 滑动续期,缺一不可;
  3. 多端控制用 ZSet 索引(score=登录时间,排序踢老留新);
  4. C 端 Session、服务间 JWT,不要二选一,是分工。

下一篇预告:登录态往往伴随"签到打卡"功能------下一篇《1 亿用户签到一年只占 44MB------Bitmap 的 Go 实现》,我们看看位运算能省出多少内存,以及"连续签到 30 天"怎么算。

相关推荐
hourinX1 小时前
一张只有两种颜色的头像,为什么还要用 NRGBA?—— `gdenticon` 重构日志(四)
go
我不会起名字3221 小时前
Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测
redis·go·uv·布隆过滤器·hyperloglog
码剑客4 小时前
Gin+GORM+Redis 项目开发常见性能坑汇总(Go后端避坑指南)
redis·后端·golang·gin
FungLeo4 小时前
成为全栈·Go 后端篇·换后端不改前端:四个客户端如何做兼容回归
flutter·golang·go·成为全栈·兼容测试·taro 小程序
snow@li4 小时前
redis:RedisDesktopManager(RDM/RESP.app)Redis 数据可视化全景深度梳理
数据库·redis·缓存
海棠Flower未眠4 小时前
下单请求处理流程:商品维度分布式锁与 Redis 预扣库存
redis·分布式·junit
我不会起名字3224 小时前
Redis 从 0 到 1(二十一):P99 涨到 80ms,慢查询日志 5 步定位法 + Go 采集器
redis·go·慢查询·排查·slowlog
挖掘狂人4 小时前
Redis 为什么快?把 "存内存" 这句话拆开看
redis·后端·性能优化
codigger5 小时前
Redis 为什么快?把 "存内存" 这句话拆开看
redis·后端·性能优化·程序员