我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [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 卡住:
- "后台发现这个账号在洗钱,立刻踢下线"------JWT 签出去就管不了了,只能等过期;
- "同一个账号最多登录 3 台设备"------JWT 无状态,服务端不知道签了几张票;
- "用户改了密码,旧登录态全部失效"------同上,做不到。
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> |
当前在线设备数 |
五、排查步骤:用户"被登出"或"登不掉"
- 被登出:查 token 的 TTL 是不是只剩很短(idleTTL 太短)或 key 不存在(被踢/被运维清库);
- 登不掉(踢了还在线):Kick 只删了索引没删实体,或用户重新登录又生成新 token(检查风控逻辑是不是漏了 ZREM 之后的重登拦截);
- Redis 内存涨:session key 没带 TTL------
SCAN MATCH session:*抽查 +OBJECT IDLETIME找幽灵会话。
六、总结
- JWT 换 Session 的理由从来不是性能,是可控性:踢人、限设备、强制失效;
- Session 双 TTL:maxTTL 绝对过期 + idleTTL 滑动续期,缺一不可;
- 多端控制用 ZSet 索引(score=登录时间,排序踢老留新);
- C 端 Session、服务间 JWT,不要二选一,是分工。
下一篇预告:登录态往往伴随"签到打卡"功能------下一篇《1 亿用户签到一年只占 44MB------Bitmap 的 Go 实现》,我们看看位运算能省出多少内存,以及"连续签到 30 天"怎么算。