理发店的故事:彻底搞懂用户状态管理(Cookie/Session/Token/JWT)


为什么需要状态管理?

HTTP 协议本身是无状态的。

也就是说,服务器每次收到请求时,默认都不知道这个请求是谁发来的。就像一家理发店,如果客人每次来都不报身份,店员根本分不清你是新客还是老客。

为了解决这个问题,就出现了Cookie。

Cookie 可以简单理解成:店家发给你的一张小纸条。

  • 服务器通过 Set-Cookie 把这张纸条发给浏览器。
  • 浏览器会把纸条存起来。
  • 以后你每次来店里,浏览器都会自动把这张纸条带上。

Cookie 本身不是状态管理方案,它只是一个载体(用来传递信息的工具)。真正的状态管理方案(Session、Token、JWT)都会用到它,或者用类似的方式传递信息。


方案一:Session —— 把会员卡放在店里

你去理发店办了张会员卡,但你怕卡丢了,于是跟店家说:

“卡你帮我保管吧,给我一个卡号就行。”

店家同意了:

  1. 把你的会员信息(姓名、余额、消费记录等)锁进店里的保险柜。
  2. 只给你一张写着「卡号」的小纸条(这个卡号就是 SessionId)。
  3. 这张小纸条通常放在 Cookie 里,浏览器会自动带着。

以后你每次来,只要报出卡号,店员就去保险柜里查出你的完整信息。

特点

  • 真正的用户信息存在服务器(保险柜 = 内存 / Redis / 数据库)。
  • 客户端只拿着一个随机字符串(卡号)。
  • 服务器对信息有完全的控制权(想过期就过期,想改就改)。

这就是经典的 Session 方案。


方案二:不透明 Token —— 换了一种传卡号的方式

随着业务发展,有人发现 Session 有些不方便:

  • 它主要依赖 Cookie,浏览器会自动带,但手机 App、跨域请求、前后端分离项目用起来没那么灵活。
  • 前端几乎感知不到自己有“登录凭证”。

于是出现了 不透明 Token(Opaque Token)。

它的核心机制其实和 Session 几乎一样:

  1. 服务器生成一个随机字符串(Token)。
  2. 把这个 Token 和用户信息的对应关系存在 Redis 里。
  3. 把 Token 发给客户端。
  4. 客户端以后每次请求都主动带上这个 Token(通常放在请求头 Authorization: Bearer xxxxx)。
  5. 服务器拿到 Token 后,去 Redis 查询对应的用户信息。

和 Session 的主要区别

对比点 Session 不透明 Token
传递方式 放在 Cookie,浏览器自动携带 放在 Header,客户端主动携带
适用场景 传统网站(同域名) 前后端分离、手机 App、跨域 API
客户端感知 几乎无感知 明确知道自己有一个 Token,可控性强
本质 服务器存数据 + 客户端拿钥匙 服务器存数据 + 客户端拿钥匙

所以可以简单记:不透明 Token ≈ 现代化的 Session,只是传递方式从 Cookie 换成了 Header。


方案三:JWT —— 把信息直接写在卡上

当分店越开越多(分布式系统)时,Session 和不透明 Token 都遇到了同一个问题:

所有分店都必须能访问同一个保险柜(Redis),否则就查不到会员信息。

虽然用 Redis 可以解决,但总归还是依赖共享存储。

有没有办法让每一家分店自己就能认出你,而不用每次都问总店?

于是 JWT(JSON Web Token) 出现了。

JWT 的做法

店家直接把会员信息写在卡上,并盖上一个防伪章:

  1. Header:写明“这张卡用什么算法做防伪”。
  2. Payload:真正的会员信息(userId、角色、过期时间等)。注意:这里不能放隐私信息,因为内容是可见的(只是 Base64 编码,不是加密)。
  3. Signature(签名):店家用只有自己知道的密钥,对前面两部分做一个防伪印章。

最终这张卡长这样:

xxxxx.yyyyy.zzzzz
(Header.Payload.Signature)

分店怎么验证?

任意一家分店拿到这张卡后:

  1. 用同样的密钥重新计算一遍签名。
  2. 和卡上的签名对比。
  3. 一致 → 说明没被篡改,直接信任卡上写的信息。
  4. 完全不需要再打电话问总店。

这就是 JWT 能解决分布式问题的核心原因:信息自包含 + 可本地验证。

重要提醒

JWT 解决的是「认证」(你是谁?这个请求合法吗?),而不是把所有用户数据都塞进去。

实际开发中:

  • 认证阶段:验证签名即可,不需要查库。
  • 需要完整用户信息时(比如余额、详细资料、最新状态):还是会用 Payload 里的 userId 再去查一次数据库或缓存。

三种方案如何应对「开分店」(分布式)

方案 分布式下的做法 是否需要共享存储 是否真正无状态
Session 所有服务器共享同一个 Redis 需要 否
不透明 Token 所有服务器共享同一个 Redis 需要 否
JWT 所有服务器只需共享同一个密钥 不需要 是(认证层面)

方案对比总结

方式 数据主要存在哪里 传递方式 分布式友好度 能否立即作废 典型使用场景
Session 服务器 Cookie(自动) 中 容易 传统后台管理系统
不透明 Token 服务器 Header(主动) 中 容易 前后端分离、移动端 API
JWT 客户端(Token 自身) Header(主动) 高 较难 微服务、无状态 API、跨系统

写在最后

用一句话总结三者的本质:

  • Session / 不透明 Token:服务器保管会员档案,你只拿钥匙。
  • JWT:把会员信息直接写在卡上,并盖上防伪章,任何分店都能自己验真。

没有绝对的好坏,只有适不适合当前场景。

实际项目中最常见的组合是:

  • 简单后台 → Session
  • 前后端分离 / 移动端 → 不透明 Token + Redis,或 JWT + 必要的数据库查询
  • 高并发、微服务 → JWT(认证) + Redis(缓存用户详细信息)

Go简单实现JWT

本文使用Go + gin + github.com/golang-jwt/jwt/v5 来简单实现登陆鉴权。
下面给你一套清晰、可直接学习的 Go JWT 实现模板(使用当前最主流的 github.com/golang-jwt/jwt/v5)。

我会分成两部分:

  1. 最基础核心实现(自己手写,方便理解原理)
  2. 配合 Gin 的完整可运行示例(含中间件)

1. 安装依赖

go get github.com/golang-jwt/jwt/v5

2. 核心 JWT 工具(推荐单独放一个文件)

// pkg/jwt/jwt.go
package jwt

import (
	"errors"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// 密钥(生产环境一定要从环境变量读取,不要写死)
var jwtSecret = []byte("your-256-bit-secret-change-me-please")

// 自定义 Claims(可以把需要的用户信息放进去)
type Claims struct {
	UserID   int64  `json:"user_id"`
	Username string `json:"username"`
	Role     string `json:"role"`
	jwt.RegisteredClaims
}

// GenerateToken 生成 JWT
func GenerateToken(userID int64, username, role string) (string, error) {
	// 过期时间(建议 access token 15分钟 ~ 2小时)
	expirationTime := time.Now().Add(2 * time.Hour)

	claims := Claims{
		UserID:   userID,
		Username: username,
		Role:     role,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(expirationTime),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			NotBefore: jwt.NewNumericDate(time.Now()),
			Issuer:    "dance-elite", // 你的项目名
		},
	}

	// 库会自动生成 Header 基于使用的算法 {"alg": "HS256","typ": "JWT"} 进行Base64URL 编码 变成header
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	// 整个Claims就会变成payload字段内容,也要进行base64编码,变成 JWT 的第二部分。
	// SignedString 会完成「Base64URL 编码 header/payload + HMAC-SHA256 签名」三步,
	// 返回 header.payload.signature 三段拼接的最终字符串。
	return token.SignedString(jwtSecret) // 具体进行签名的部分
}

// ParseToken 解析并验证 Token
func ParseToken(tokenString string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		// 强制校验签名算法,防止算法混淆攻击
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, errors.New("unexpected signing method")
		}
		return jwtSecret, nil
	})

	if err != nil {
		return nil, err
	}

	// 校验是否有效
	if claims, ok := token.Claims.(*Claims); ok && token.Valid {
		return claims, nil
	}

	return nil, errors.New("invalid token")
}

3. Gin 中间件(鉴权)

// middleware/auth.go
package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"your_project/pkg/jwt" // 改成你自己的路径
)

// JWTAuth 中间件
func JWTAuth() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 1. 从 Header 获取 Authorization
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少 Authorization 头"})
			c.Abort()
			return
		}

		// 2. 格式必须是 Bearer <token>
		parts := strings.SplitN(authHeader, " ", 2)
		if !(len(parts) == 2 && parts[0] == "Bearer") {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization 格式错误,应为 Bearer <token>"})
			c.Abort()
			return
		}

		// 3. 解析 Token
		claims, err := jwt.ParseToken(parts[1])
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "无效或过期的 Token", "detail": err.Error()})
			c.Abort()
			return
		}

		// 4. 把用户信息存到上下文,后续 handler 可以直接用
        // userID, exists := c.Get("user_id") 
		c.Set("user_id", claims.UserID)
		c.Set("username", claims.Username)
		c.Set("role", claims.Role)

		c.Next()
	}
}

4. 完整使用示例(Login + 受保护接口)

// 示例 main.go 或 router
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"your_project/middleware"
	"your_project/pkg/jwt"
)

func main() {
	r := gin.Default()

	// 登录接口(公开)
	r.POST("/login", LoginHandler)

	// 需要登录才能访问的接口
	auth := r.Group("/api")
	auth.Use(middleware.JWTAuth())
	{
		auth.GET("/profile", ProfileHandler)
	}

	r.Run(":8080")
}

// LoginHandler 登录
func LoginHandler(c *gin.Context) {
	type LoginReq struct {
		Username string `json:"username" binding:"required"`
		Password string `json:"password" binding:"required"`
	}

	var req LoginReq
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// TODO: 这里应该查数据库验证账号密码
	// 这里先写死演示
	if req.Username != "admin" || req.Password != "123456" {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
		return
	}

	// 生成 Token
	token, err := jwt.GenerateToken(1, req.Username, "admin")
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "生成 Token 失败"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"token": token,
		"expire": "2h",
	})
}

// ProfileHandler 受保护接口
func ProfileHandler(c *gin.Context) {
	userID, _ := c.Get("user_id")
	username, _ := c.Get("username")
	role, _ := c.Get("role")

	c.JSON(http.StatusOK, gin.H{
		"user_id":  userID,
		"username": username,
		"role":     role,
		"msg":      "这是受保护的接口,你已通过 JWT 验证",
	})
}

5. 测试方法

  1. 登录获取 Token
curl -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}'
  1. 携带 Token 访问受保护接口
curl http://localhost:8080/api/profile \
  -H "Authorization: Bearer 你刚才拿到的token"

学习重点总结

步骤 关键点
生成 Token jwt.NewWithClaims + SignedString
自定义 Claims 嵌入 jwt.RegisteredClaims
解析验证 ParseWithClaims + 校验签名算法
过期时间 ExpiresAt: jwt.NewNumericDate(...)
安全点 强制检查 SigningMethodHMAC,防止算法攻击
中间件 从 Authorization: Bearer xxx 取 Token

希望这个理发店的故事能帮你真正理解这些概念。
如果还有不清楚的地方,欢迎在评论区继续交流。


文章作者: ZhangZhengYu
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 ZhangZhengYu !
评论
 上一篇
告别 CPU 飙高与内存泄漏:我的 pprof 实战排查与避坑笔记 告别 CPU 飙高与内存泄漏:我的 pprof 实战排查与避坑笔记
记录我学习 pprof 的心路历程,涵盖 CPU 与内存排查的具体套路、实战中踩过的坑,以及那些文档里没写清楚的调优细节。
下一篇 
RAG效果的好坏如何评估? RAG效果的好坏如何评估?
如何评估你的RAG系统效果如何?从那几个方面哪些角度用那些方法来评估呢?
2026-05-07
  目录