第03章 · JWT 认证与 Kubernetes 部署(正式版)

2022-12-16T14:11:02+08:00 | 63分钟阅读 | 更新于 2022-12-16T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 03 章。上一章(第 02 章)你已经做出了一个能在本地跑起来的单机注册登录服务——它用 cookie 把登录态存在内存里。

但这一章是全书第一个真正的断崖:原文直接上 Kubernetes,而你可能从没接触过 Redis 和 K8s。为了避免你在这里"必崩",这一版做了三件额外的事:

  1. 就地补齐六颗补课胶囊:Redis 是什么、Docker 三件套、为什么需要 K8s、K8s 四大核心概念、kubectl 常用命令、什么是"无状态"。每一个都当场讲,不让你去搜。
  2. 把"为什么需要 JWT"讲成一条完整的问题演进线:单机 Session → 多实例被踢下线 → 用 Redis 共享 → 再引出无状态方案 JWT,让你理解每一步的来由,而不是硬塞结论。
  3. 每一步都给出可验证的预期输出。涉及 K8s / docker 的命令,我标了"示例输出"和你能确认什么;不确定的数值我不编造。

如果你想要"高密度速查版",可以看原版第 03 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

上一章你做了一个单机的注册登录服务:用户登录后,我们把"你是谁"记在服务进程自己的内存里(这就是 Session),下次请求带着 cookie 来,我们就从内存里认出他。这在只有一台服务器时工作得很好。

但真实项目不会只跑一台机器。当流量变大、或者你想让一台挂了服务也不中断,你得启动多个实例,再用一个"负载均衡"把用户请求分给它们。问题就在这里爆发了:用户在 A 机器登录,登录态记在 A 的内存里;可下一次请求被分到 B 机器,B 的内存里没有他的登录态——他莫名其妙被踢回登录页。

这一章要解决的就是这个"多实例登录态"的难题,并顺手把服务连同数据库一起搬进 Kubernetes(一个自动管理容器集群的平台),让它具备扩缩容、故障自愈、滚动发布的能力。我们会走两条技术路线:

  1. Redis 共享 Session:把登录态从"每家自己记"改成"所有人查同一个中央账本"——先解决多实例的问题。
  2. JWT 无状态认证:让凭证自己携带身份信息,服务端不存任何状态也能认出用户——这是更彻底的分布式解法。

最后我们再用限流(基于 Redis + IP)给系统上一层保护,并把 webook + MySQL + Redis 一起部署到 K8s。读完后你能亲手把第 02 章的项目真正"上线"到一个容器集群里。

开读前先看一眼全局:本章的三件事在图里一目了然——认证从"服务端存 Session"换成"凭证自带身份",防护靠 Redis 限流,部署交给 K8s。正文所有内容都在打通这三段。

flowchart LR
    subgraph AUTH["认证:无状态 JWT"]
        A["登录成功"] --> B["签发 JWT"] --> C["请求携带 token"] --> D{"middleware 验签"}
        D -->|"通过"| E["从 claims 取 uid
处理业务"] D -->|"无效 / 过期"| F["401"] end subgraph GUARD["防护"] G["IP 限流
Redis 计数"] --> D end subgraph DEPLOY["部署:K8s"] H["webook 镜像"] --> I["Deployment Pod"] --> J["Service / Ingress 暴露"] K[("MySQL")] --> I L[("Redis")] --> I end

这张图怎么读:三个子图各管一件事,在 middleware 验签处汇合。认证链路:登录成功后签发 JWT,之后每个请求携带 token 到 middleware 验签(D),通过就从 claims 取出 uid 处理业务,无效或过期则走失败分支直接 401。防护链路:IP 限流靠 Redis 计数(G),它排在验签之前——请求先过限流这一关,才轮到 D 判身份。部署链路:webook 镜像被拉起为 Deployment 的 Pod(I),由 Service/Ingress 对外暴露流量,MySQL(K)与 Redis(L)作为 Pod 的下游依赖被连接。这三段从左到右的排布,也正是本章"先换认证、再上防护、最后进 K8s"的推进顺序。


二、本章知识点大纲

先扫一眼地图,知道这一章往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 用 docker-compose 起一个 Redis,并用 redis-cli 验证连通(PONG)。
  2. 把第 02 章的 cookie Session 换成 Redis Session,理解 sess_id 在 cookie、数据在 Redis 的分工。
  3. 在 middleware 里实现 Session 过期固定间隔刷新(如 1 分钟内只刷一次)。
  4. 用 github.com/golang-jwt/jwt/v5 签发一个 JWT,并在 middleware 里验签 + 解析 + 校验 User-Agent。
  5. 写一个 基于 Redis 的 IP 限流 middleware,并在 Redis 故障时降级放行。
  6. 写一份 k8s-webook-deployment.yaml + Service + Ingress,用 kubectl apply 把 webook 跑进 K8s。

理解(能讲清原理和取舍,不要求背代码)

  1. Session 多实例困境的演进逻辑:单机内存 → 多实例记忆分家 → 共享存储。
  2. JWT 三段结构(Header / Payload / Signature)各自是什么、为什么签名能防篡改、为什么 Payload “只编码不加密”。
  3. JWT vs Session 的优缺点对比(存储、分布式、撤销、安全、续期)。
  4. 限流的思路和对象:为什么用 IP、为什么用 Redis 集中计数、为什么 Redis 故障要降级。
  5. K8s 四件套(Pod / Deployment / Service / Ingress)各自解决什么问题。
  6. “无状态” 的含义——这是理解 JWT 为什么能适应分布式的根。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. Redis 的数据结构(string / hash / list / set / zset)以及它为什么比 MySQL 快。
  2. Docker 三件套:镜像 / 容器 / 仓库的区别。
  3. K8s 的 PV / PVC / StorageClass 持久化抽象;Service 的四种 type(ClusterIP / NodePort / LoadBalancer / ExternalName)。
  4. 四种限流算法:固定窗口 / 滑动窗口 / 漏桶 / 令牌桶。
  5. 编译标签 //go:build k8s 管理多环境配置。

前置依赖与补课清单

本章会用到一些你大概率第一次听到的技术。下面是补课地图——正文里遇到时我会就地展开「补课胶囊」,你不必提前去查。

需要你已掌握是否在前面章节讲过本章补齐位置
第 02 章的 cookie Session、sess.Get/Set/Save✅ 第 02 章3.5 节接上
Gin middleware 的写法(c.Next() / c.Abort...)✅ 第 02 章3.5.7 / 3.7 / 4 节复用
GORM + MySQL 建表分层✅ 第 02 章部署时复用
Redis 是什么❌ 全新3.1 补课胶囊
Docker 镜像/容器/仓库❌ 全新3.2 补课胶囊
为什么需要 K8s❌ 全新3.3 补课胶囊
什么是"无状态"❌ 全新3.4 补课胶囊
K8s 核心概念 Pod/Deployment/Service/Ingress❌ 全新3.6 补课胶囊
kubectl 常用命令❌ 全新3.6 / 4 节

不需要你提前懂:具体的 K8s YAML 字段、JWT 的加密数学、Redis 集群——这些都会边用边讲。

预计时长与学习成果

  • 预计时长:4 小时(精读 + 跟着敲全部命令与代码);只求主干约 2 小时。

  • 学完你能跑出来的东西(这是本章验收标准,做到才算过关):

    一个起在本地 Docker 里的 Redis;把第 02 章项目切到 Redis Session,起两个实例验证登录态不再丢失;亲手签发一个 JWT 并到 jwt.io 拆开看三段;加一个 IP 限流中间件;最后写齐 webook / MySQL / Redis 的 K8s YAML,用 kubectl get pods 看到它们全 Running。

flowchart LR
    A["3.1 Redis 补课"] --> B["3.2 Docker 补课"]
    B --> C["3.3 为什么需要 K8s"]
    C --> D["3.4 无状态补课"]
    D --> E["3.5 Session 困境
与 Redis 共享"] E --> F["3.6 K8s 四件套补课"] F --> G["3.7 JWT 三段与实现"] G --> H["3.9 限流 Redis 实现"] H --> I["第四节 跟着写
部署到 K8s"] I --> J["第七节 面试问答"]

这张图怎么读:路线为单向串行——3.1 到 3.4 先用四颗补课胶囊补齐 Redis、Docker、K8s、“无状态"等前置概念,随后进入两条主线:3.5 用 Redis 共享存储解决 Session 的多实例困境,3.7 换 JWT 做无状态认证,中间的 3.6 K8s 四件套是通往部署的桥。3.9 限流在认证定型后给系统加防护,最后第四节"跟着写部署到 K8s"把认证、防护、编排三者一起落地,第七节面试问答收口。补课胶囊节可快读,3.5 与 3.7 建议精读并跟着敲。

JWT 与 K8s 部署登录态演进补课与概念系统保护部署单机→多实例被踢下线Redis 共享 Session + 刷新JWT 三段 + 验签防篡改Redis/Docker/K8s 补课无状态 概念Pod/Deployment/Service/IngressIP 限流 + User-Agent 校验四种限流算法YAML 编排 + kubectl
复习提示:怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。本章前半段会先补六颗「补课胶囊」(Redis / Docker / K8s / 无状态 / K8s 四件套 / kubectl),这是为了让你在正式进入 Session 演进和 JWT 之前,先把"工具箱"备齐。如果你只想快速过一遍,可以只读每节的"抛问题"和"小收口";要真学会,就把每个命令都敲一遍。

三、正文

本章正文很长,但逻辑是一条线:先补课(把没见过的工具讲清楚)→ 再用这些工具解决多实例登录态问题 → 最后顺手把系统保护做掉、把服务部署上 K8s。


3.1 补课胶囊 · Redis 是什么

进入正题前,先补一个你大概率没接触过的东西——Redis。因为本章解决"多实例登录态"的第一步,就是把数据搬进 Redis。

补课胶囊 · Redis 一句话:Redis 是一个把数据放在内存里的数据库,读写极快,常用来存"临时、要快取"的数据(如登录态、缓存)。 类比:MySQL 像"文件柜”——数据放硬盘,取之前要先翻抽屉,慢但能存很多、很稳;Redis 像"你手边的便签贴"——贴桌上,一眼就看到,快但空间有限、断电可能丢。 最小示例:

# 连上本机 Redis(默认 6379 端口)
redis-cli
# 设置一个键值,10 秒后自动消失
SET login:user:123 "ok" EX 10
# 取值
GET login:user:123

回到主线:多实例下,每个实例自己的内存无法共享,所以我们要把"登录态"这种需要所有实例都能读到的数据,挪到一个大家都能访问的 Redis 里。

为什么 Redis 比 MySQL 快? 一句话:MySQL 主要把数据放硬盘,Redis 主要把数据放内存。内存的随机访问速度是硬盘(即使是 SSD)的几十到上百倍。当然代价是:内存贵、容量小,而且 Redis 默认把数据放内存,重启可能丢——所以它适合存"丢了能恢复、或者本来就是临时"的数据,不适合当唯一真相源(真账本还是 MySQL)。

Redis 能存哪些数据结构? 这也是它比"单纯的键值库"好用的地方。MySQL 是一张张表,而 Redis 的 value 可以是多种类型:

类型一句话本章用到
string(字符串)最常见,一个 key 对应一段文本/数字限流计数 INCR、存 Session 序列化结果
hash(哈希)一个 key 对应一组"字段-值",像一张小表Session 内部常以 hash 存多个字段
list(列表)有序可重复的队列本教程未直接用
set(集合)无序不重复本教程未直接用
zset(有序集合)带分数的有序集合本教程未直接用

动手小实验:直观感受 Redis 和 MySQL 的快慢差异

为了让你不只是"听说 Redis 快",我们做一组对比。假设我们要存 / 取 1 万次"用户 123 是否登录":

# 1) Redis:纯内存,毫秒级
# 先写 1 万次(每次起一个 redis-cli 执行 SET,输出丢弃)
for i in $(seq 1 10000); do redis-cli SET "login:user:$i" "ok" EX 60 >/dev/null; done
# 再读 1 万次
for i in $(seq 1 10000); do redis-cli GET "login:user:$i" >/dev/null; done

# 2) MySQL:数据在硬盘,需要建表、走 SQL 引擎
# 建表
mysql -e "CREATE TABLE t_login(id INT PRIMARY KEY, v VARCHAR(10));"
# 写 1 万次 INSERT、读 1 万次 SELECT(命令略)

为什么差这么多? 关键在"数据住哪":

维度MySQLRedis
数据主要存放硬盘(即使 SSD 也慢于内存)内存
访问路径SQL 解析 → 查询优化 → 磁盘 IO → 返回直接内存寻址 → 返回
单次访问量级毫秒级(~1-10ms)亚毫秒级(~0.1ms)
适合存什么业务真相数据(用户、帖子)临时 / 要快取的数据(登录态、缓存)

一句话:MySQL 是"保险柜",稳但慢;Redis 是"手边抽屉",快但空间有限、断电可能丢。登录态这种"丢了能重登、且要快取"的数据,天然归 Redis。

再看一眼 Redis 里的数据结构长什么样(用 hash 存一个 Session 内部的多个字段):

# 用 hash 存一个登录态的内部字段
HSET session:abc user_id 123 user_agent "curl/8.0" role "normal"
# 取单个字段
HGET session:abc user_id
# 取全部
HGETALL session:abc

示例输出:

123
user_id
123
user_agent
curl/8.0
role
normal

这正是 Gin 的 Redis Session 在背后做的事——它把 user_id、user_agent 等字段序列化后存进 Redis 的一个 key 里。你不必手写 HSET,库替你做了,但知道"底层就是这么存的"能帮你在排查问题时更有底气。

小收口:记住三件事就够了——(1)Redis 是内存数据库、快;(2)它支持多种结构,本章主要用 string(限流计数)和库内部的 hash(Session);(3)它天然支持 EXPIRE 过期,特别适合登录态这种"过一阵就失效"的数据。带着这三点,下面看 Docker。


3.2 补课胶囊 · Docker 三件套

本章要把服务"装进容器"再交给 K8s。容器是 Docker 提出的概念。先分清三个最容易被混为一谈的词:镜像、容器、仓库。

补课胶囊 · Docker 三件套 一句话:镜像是"打包好的应用模板",容器是"镜像跑起来的一个实例",仓库是"存镜像的地方"。 类比:镜像像"月饼模具",容器像"用模具压出来、真正能吃的月饼",仓库像"放模具的货架(如 Docker Hub)"。一个模具可以压出很多块月饼,一个镜像可以启动很多个容器。 最小示例:

# 从仓库拉取一个 Redis 镜像(模板)
docker pull redis:7
# 用镜像启动一个容器(真正跑起来的进程),-d 后台运行
docker run -d --name my-redis -p 6379:6379 redis:7
# 看看正在跑的容器
docker ps

回到主线:后面我们把 webook 编译成二进制、写进 Dockerfile 做成镜像,K8s 再去调度这些镜像启动容器。

docker run 一条命令到底在干什么? 拆开看就四步:

  1. 找镜像:本地有没有 redis:7?没有就先去仓库拉。
  2. 建容器:按镜像的模板,在宿主机上创建一个隔离的运行环境(有自己的文件系统、网络、进程空间)。
  3. 执行命令:运行镜像里 ENTRYPOINT / CMD 指定的程序(Redis 就是启动 redis-server)。
  4. 连网络:-p 6379:6379 把宿主机的 6379 端口映射到容器内的 6379 端口,这样你访问本机 6379 就等于访问容器里的 Redis。

额外补一颗小糖:镜像为什么启动这么快(分层)

Docker 镜像不是一整块,而是一层层叠加的。你的 Dockerfile 每写一行(FROM / COPY / RUN),就生成一层。相同的基础层(比如 ubuntu:22.04)在所有镜像间共享,不用重复下载。这就是为什么 docker build 第二次往往飞快——没变的基础层直接复用。

flowchart TD
    L0["层0: ubuntu:22.04 基础系统"] --> L1["层1: COPY webook"]
    L1 --> L2["层2: ENTRYPOINT 启动命令"]
    L2 --> IMG[("最终镜像 = 三层叠加")]
    CACHE[("本地/仓库缓存: 已下载的层直接复用")] -. 共享 .-> L0

类比:镜像像"千层蛋糕",最底下是通用的蛋糕胚(ubuntu),上面叠你自己的奶油(你的程序)。别人做蛋糕也能直接拿同一个胚,省得每次从头烤。

复习提示:⚠️ 新手必踩的坑:docker run 不加 --name 也行,但容器会得到一个随机名字,之后 docker stop / docker logs 都得更麻烦。建议每次都带 --name。另外容器停止不等于删除——docker ps -a 能看到已退出的容器,docker rm <名> 才真正删掉,避免名字冲突。

3.3 补课胶囊 · 为什么需要 Kubernetes

前面我们用 docker run 手动起了一个 Redis 容器。那如果我们要管理一整套系统呢——比如 3 个 webook 实例 + MySQL + Redis,一共 5 个以上的容器?手动管理会出现三个头疼问题:

  • 挂了要人盯着重启:某个 webook 容器崩了,得有人手动 docker run 拉起来。
  • 流量大了要手动扩容:双十一来了,得手动多起几个实例,再手动改负载均衡配置。
  • 发布要手动滚动:发新版本时不能一刀切停服,得一个个换,手动做极易出错。

Kubernetes(常被缩写成 K8s,“K” 和 “s” 之间有 8 个字母,所以叫 K8s) 就是来解决这些的——它是一个容器编排平台:你告诉它"我要 3 个 webook",它就负责把 3 个容器调度到集群里的机器上;哪个挂了它自动补;流量大了你改个数字它就扩;发布新版本它自动一个一个换(滚动发布)。

类比:没有 K8s 时,你是"自己挨个去给每盏灯开关"的物业;有了 K8s,你只要在一块总控板上写"客厅要 3 盏灯亮着",总控板(K8s)自己保证客厅永远有 3 盏灯亮,坏一盏立刻补一盏。

复习提示:一句话:K8s 不 Production 化你的代码,它 Production 化你的"运维动作"——把重启、扩容、发布这些重复劳动交给平台自动完成。本章你只要会用 kubectl apply 把写好的"愿望清单"(YAML)交给它就行。

小收口:Docker 负责"把应用打包成可移植的容器",K8s 负责"在集群里把这些容器管好"。下一节要讲一个概念,它是理解 JWT 为什么适合 K8s 的关键。


3.4 补课胶囊 · 什么是"无状态"

“无状态(stateless)“这个词本章会反复出现,尤其在讲 JWT 时。先把它的意思钉死。

补课胶囊 · 无状态(stateless) 一句话:无状态指"服务端不保存任何关于客户端的会话记忆”——每个请求自带全部所需信息,服务端处理完就完了,不依赖"上次这个用户来过没”。 类比:有状态像"熟客餐厅"——服务员记得你上次坐哪、爱喝什么(记忆在服务端);无状态像"快餐店"——每次点单你都要把"要什么、几份、打包还是堂食"一次性说清,店员不记你(记忆在请求本身)。 最小示例:

// 有状态:依赖服务端内存里存的 userID(Session 的做法)
func handler(c *gin.Context) {
    userID := session.Get("userID") // 服务端内存里翻"账本"
}

// 无状态:每次请求自己带着身份证明(JWT 的做法)
func handler(c *gin.Context) {
    token := c.GetHeader("Authorization") // 凭证在请求里,服务端不存
}

回到主线:JWT 之所以适合多实例和 K8s,正是因为它"无状态"——不管请求落到哪个 webook 实例,只要拿着同一个 token,每个实例都能独立验出身份,不需要去共享存储(Redis)查。

为什么"无状态"对分布式这么重要? 回想 3.5 节要讲的困境:有状态(Session 存内存)在多实例下会"记忆分家"。而无状态把"记忆"交给了请求自己携带,服务端之间就不需要同步任何状态,任意一台机器都能处理任意请求——这正是 K8s 这种"随时可能把你的容器调度到不同机器"的环境最想要的特质。

小收口:记住"无状态 = 服务端不存会话记忆,凭证随请求走"。它是后面理解 JWT 的总钥匙。


3.5 主线一:Session 的困境(单机 → 多实例 → Redis 共享)

这一节是本章第一条主线,也是理解"为什么需要 JWT"的铺垫。我们按问题演进来讲,不要跳过任何一步。

3.5.1 先抛问题:单机 Session 是怎么工作的

第 02 章你写的登录是这样:用户登录成功,我们在服务端内存里记一笔 user_id=123,同时发给他一个 cookie,里面只放一个 session_id(注意:不是 user_id,user_id 在服务端内存里)。之后每次请求,浏览器自动带上 cookie 里的 session_id,我们就用这个 id 去内存里查到 user_id=123,从而知道"这是用户 123"。

sequenceDiagram
    participant U as 用户浏览器
    participant S as 单台服务器(内存)
    U->>S: 登录(账号密码)
    S->>S: 内存记 user_id=123, 生成 sess_id=abc
    S-->>U: Set-Cookie: sess_id=abc
    U->>S: 后续请求 带 Cookie: sess_id=abc
    S->>S: 用 abc 查内存 → user_id=123
    S-->>U: 返回 用户123 的数据

这在只有一台服务器时完美工作。一句话:登录态 = 服务端内存里的一张"账本",cookie 是取号的纸条。

3.5.2 多实例的灾难:用户被踢下线

现在假设流量涨了,你启动 3 个 webook 实例(Pod1 / Pod2 / Pod3),前面放一个负载均衡(Load Balancer,一个把请求分给不同实例的组件)。问题来了:

用户在 Pod1 登录,账本记在 Pod1 的内存。可下一次请求,负载均衡可能把它转到 Pod2。Pod2 的内存里根本没有他的 sess_id,于是我们认为"你没登录",把他踢回登录页。

flowchart LR
    U[用户] --> LB[负载均衡]
    LB --> P1["Pod1
账本: user_id=123"] LB --> P2["Pod2
账本: 空"] LB --> P3["Pod3
账本: 空"]

类比:你只在 A 银行网点开了户,却拿着卡去 B 网点取钱,B 不认你——除非所有网点背后是同一个中央账本。Redis 就是这个"中央账本"。

复习提示:一句话结论:登录态本质是"服务端记住你是谁"。单机时它记在自己内存里;一旦有多个实例,记忆就分了家,用户会在实例间"被踢下线"。

3.5.2.x 常见坑:有人会想用"会话粘滞"凑合,为什么不行

新手很容易想到一个"偷懒"解法:让负载均衡记住"这个用户上次去了 Pod1,以后都发到 Pod1"。这叫会话粘滞(Session Sticky / 会话保持)。

它确实能暂时止血——只要用户一直被分到同一台,登录态就在。但它是个脆弱的权宜之计,有三个硬伤:

问题说明
实例挂了照样丢Pod1 崩了,粘在它身上的用户还是被踢,粘滞保不了故障场景
扩容/缩容会乱你新增 Pod4、下线 Pod2,粘滞表立刻失效,部分用户被踢
负载可能不均某些"重用户"总黏在同一台,造成热点,违背多实例初衷

一句话:粘滞是"把问题藏起来",Redis 共享是"把问题根治"。生产上即使用了粘滞(作为性能优化),也必须配共享存储兜底,否则一次重启就露馅。

3.5.3 解决方案:把所有实例的账本合并到 Redis

看清问题后,解法很自然:别把账本记在每家自己家里,挪到一个所有实例都能访问的"中央账本"去。这个中央账本就是 Redis。

Gin 的 Session 机制早就想到了这一点,它把"账本存在哪"抽象成了一个 store 接口,你可以换不同的实现:

Store场景
cookie单机开发、纯演示
memstore单实例部署
redis多实例部署,推荐
gorm/mysql团队已有 MySQL,想复用
memcached团队已有 Memcached
mongo团队已有 MongoDB

选 store 的思路是"按你已有的基础设施选":团队已经有 MySQL,直接复用可以少维护一个组件;如果是新起的多实例项目,Redis 最常见——它读写快、自带 TTL 过期,天然适合存"有时候效"的登录态。

关键区分:切换 store 后,sess_id 仍然放在用户的 Cookie 里(这是浏览器的规矩),真正存 user_id 等数据的地方从"服务内存"变成了"Redis"。业务代码(取 user_id 的地方)一行都不用改——这正是"面向接口编程"的好处。

3.5.4 启动 Redis(docker-compose)

本地先起一个 Redis 供开发调试。下面用 docker-compose 声明一个 Redis 7,把 6379 端口映射到宿主机:

# docker-compose.yaml(放在项目根目录)
services:
  redis:
    image: redis:7
    ports:
      - "6379:6379"
    # 官方 redis:7 镜像默认无密码(空密码可直接连);生产环境需通过 requirepass 或 secrets 设置密码

启动并验证:

docker compose up -d
# 验证连通:应返回 PONG
redis-cli ping

示例输出:

PONG

如果你没装 redis-cli,也可以 docker exec -it <容器名> redis-cli ping。返回 PONG 就说明 Redis 起来了、网络通了。

接下来把 store 从 cookie 切换成 redis。这段代码只做一件事——创建并返回一个连接本地 Redis 的 store:

// internal/web/session.go
package web

import (
	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/redis"
)

// InitRedisSessionStore 创建连接本地 Redis 的 Session Store
func InitRedisSessionStore() sessions.Store {
	// 步骤 1:两个 key
	//   Authentication:身份认证(防篡改,保证数据没被改)
	//   Encryption:数据加密(防泄露,别人拿到 Redis 数据也读不出)
	//   长度必须恰好 32 字节
	// 步骤 2:连接本地 Redis(测试环境无密码,传空串)
	store, err := redis.NewStore(16, "tcp",
		"localhost:6379", "",
		[]byte("a-key-for-auth-must-be-32-bytes!"),
		[]byte("a-key-for-encryption-32-bytes-ok"),
	)
	if err != nil {
		panic(err)
	}
	return store
}

逐行讲解:

行代码为什么这么写
13-14两个 []byte key一个管"防篡改"、一个管"防泄露",都是 32 字节
15redis.NewStore(16, ...)16 是 Redis 连接池大小;"tcp" 是网络协议
16"localhost:6379"本地 Redis 地址;生产换成 Redis Service 名
17空串密码测试环境;生产必须填真实密码
19-21err 处理Redis 连不上直接 panic,开发阶段早发现早修

为什么"面向接口"在这里值钱:Gin Session 的 store 是接口类型 sessions.Store。我们只要把原来返回 cookie store 的函数,改成返回 redis store,业务里的 sessions.Default(c).Get("user_id") 完全不用动。设计自己的系统时也要常问:将来会不会换实现?会,就抽象成接口。

复习提示:⚠️ 新手必踩的坑:key 长度必须是 32 字节。redis.NewStore 的两个 key 都要求恰好 32 字节,短了/长了会报错(构造时或首次 Save 时)。而生产环境的 key 必须保密,不能像示例写死在代码里——应当从配置 / Secret 读取。把密钥提交进 git 是严重安全事故。

3.5.6 Session 过期刷新策略

解决了"Session 存哪里",紧接着是"存多久"。登录态不能永久有效(否则别人拿到 cookie 能一直用),但也不能让活跃用户在操作中途被突然踢下线。

如果 Cookie 过期时间是 10 分钟,用户在 9:59 还能访问,10:00 突然被踢——体验很差。需要在用户活跃时刷新过期时间。刷新方式不止一种,核心矛盾是"体验 vs 性能":

策略优点缺点
每次访问都刷新体验最好性能最差,Redis 压力大
快过期才刷新(如剩 1 分钟)性能好万一刷新后用户不再访问,白刷
固定间隔刷新(如 1 分钟内首次访问刷新)折中,推荐实现稍复杂
长短 token体验最佳实现最复杂,后续课程讲

本书采用固定间隔刷新——1 分钟内至多刷一次,既保住体验,又把对 Redis 的压力限制在可接受范围:

flowchart TD
    R[用户请求到达] --> C{"距上次刷新
超过 60 秒?"} C -- 否 --> N["放行, 不碰 Redis"] C -- 是 --> S["刷新过期时间
并 Save"] S --> N

3.5.7 在 Middleware 里刷新

把"固定间隔刷新"落到代码。这个 middleware 在原有登录校验基础上,加一段"距上次刷新超过 60 秒就续期"的逻辑:

// internal/web/middleware.go
package web

import (
	"net/http"
	"time"

	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
)

// LoginRequired 登录校验 + 过期刷新
func (h *UserHandler) LoginRequired(c *gin.Context) {
	// 步骤 1:取出 user_id,拿不到说明没登录
	sess := sessions.Default(c)
	userID, ok := sess.Get("user_id").(int64)
	if !ok || userID == 0 {
		c.AbortWithStatus(http.StatusUnauthorized)
		return
	}

	// 步骤 2:固定间隔刷新——1 分钟内只刷一次
	// 用 sess 里记录的 last_refresh 判断
	now := time.Now().UnixMilli()
	lastRefresh, _ := sess.Get("last_refresh").(int64)
	if now-lastRefresh > 60*time.Second.Milliseconds() {
		// 步骤 3:更新刷新时间 + 重设过期时间 + 落盘
		sess.Set("last_refresh", now)
		sess.Options(sessions.Options{MaxAge: 60 * 60 * 24})
		_ = sess.Save() // 把新状态写回 Redis
	}

	// 步骤 4:把 user_id 注入 context,供后续 handler 使用
	c.Set("user_id", userID)
	c.Next()
}

为什么放在 middleware:刷新过期时间是所有业务接口都要做的事,不该散落到每个 handler 里。Middleware 是天然的位置——一次写好,全局生效。

MaxAge 的双层含义(最容易踩坑):sess.Options 里的 MaxAge 同时控制两件事——一方面初始化 Cookie 的过期时间,另一方面(对 Redis store)控制 Redis 里那条数据的 TTL。所以你改 MaxAge,Cookie 和 Redis 的"寿命"会一起变。

// MaxAge 同时作用于 Cookie 和 Redis 里的 key TTL
sess.Options(sessions.Options{
    MaxAge:   60 * 60 * 24, // 秒。Cookie 24h 过期;Redis 也用它设 key TTL
    Path:     "/",          // Cookie 生效路径
    Domain:   "",            // 留空默认当前域
    Secure:   true,          // 只允许 HTTPS 传输
    HttpOnly: true,          // 禁止 JS 读 Cookie
})
复习提示:⚠️ 新手必踩的坑:Secure 和 HttpOnly 必须开。这是面试常问的"怎么保护 Session id"标准答案之一:Secure=true 保证 Cookie 只走 HTTPS,中间人抓不到明文;HttpOnly=true 让浏览器里的 JavaScript 读不到 Cookie,即使页面被注入恶意脚本(XSS 攻击)也偷不走你的登录态。两个都设 true。

小收口(主线一·上半):到这儿,我们解决了多实例下的 Session 问题——用 Redis 当中央账本,并把过期刷新放进 middleware。但每个请求仍然要去 Redis 查一次登录态,存储与网络开销始终存在。有没有可能让服务端不查任何存储就认出用户?这就引出本章第二条主线:JWT。不过在进入 JWT 之前,还有一组"容器编排"的补课要先做,因为 JWT + K8s 才是这套架构的最终形态。


3.6 补课胶囊 · K8s 四件套与 kubectl

在讲 JWT 和部署之前,把 K8s 的四块核心积木一次讲清。它们是本章"部署"部分的骨架。

补课胶囊 · K8s 核心概念(Pod / Deployment / Service / Ingress) 一句话:Pod 是"跑应用的集装箱",Deployment 是"保证集装箱数量够的船长",Service 是"固定的电话号码",Ingress 是"前台总机按名字转接"。 类比:

  • Pod = 一个集装箱,里面装着你真正要跑的程序(容器)。K8s 调度的最小单位就是 Pod。
  • Deployment = 船长,你告诉他"我要 3 个集装箱",少了他立刻造一个、多了他拆掉多余的,永远让实际数量等于你想要的。
  • Service = 固定电话号码,号码不变,但背后接的是哪几个集装箱(Pod)由 K8s 动态决定;你打这个号,K8s 自动把电话转给某个活着的 Pod(负载均衡)。
  • Ingress = 前台总机,按"域名 / 路径"把外部请求转给对应的 Service(例如 webook.local 转给 webook 的电话,mysql.local 转给 mysql 的电话)。 最小示例:
# 看集群里现在有哪些"集装箱"
kubectl get pods
# 看"船长"们(Deployment)
kubectl get deployments
# 看"电话号码"(Service)
kubectl get services

回到主线:部署 webook 时,我们写一份 Deployment(要 3 个 Pod)+ 一份 Service(对外暴露 webook)+ 一份 Ingress(按域名转发),K8s 自己把剩下的一切管好。

四者的关系图:

flowchart TD
    subgraph D["Deployment: replicas=3"]
        P1[Pod webook-1]
        P2[Pod webook-2]
        P3[Pod webook-3]
    end
    S["Service: webook
负载均衡"] --> P1 S --> P2 S --> P3 I[Ingress
按域名转发] --> S U[用户] --> I

一个具体例子:部署 3 个 webook 实例 = 1 个 Service + 1 个 Deployment(replicas=3)+ 3 个 Pod。Deployment 是"船长":你跟它说要 3 个,少了它重启一个、多了它删一个。Service 是"门面":用户访问 Service,Service 把流量负载均衡到 3 个 Pod。

kubectl 最常用的几个命令(kubectl 是和 K8s 对话的命令行工具,像 redis-cli 之于 Redis):

命令干什么你何时用
kubectl apply -f xxx.yaml把 YAML"愿望清单"交给 K8s,它调到符合预期部署 / 更新任何资源
kubectl get pods看所有 Pod 状态(Running? CrashLoopBackOff?)每次部署后确认
kubectl get deployments看 Deployment 副本数是否达标确认扩缩容
kubectl get services看 Service 及分配到的集群 IP排查访问不到
kubectl logs <pod>看某个 Pod 的日志应用报错时
kubectl describe pod <pod>看 Pod 详情,底部 Events 告诉你为何起不来Pod 不是 Running 时
kubectl delete -f xxx.yaml删除 YAML 描述的资源本地改乱了重来

Service 的四种类型(面试高频):前面我们用了 LoadBalancer 和 NodePort,这里一次说清:

类型特点典型场景
ClusterIP(默认)分配集群内部虚拟 IP,集群外访问不到集群内部互访,如 webook 连 mysql:3306
NodePort每个节点开一个端口(默认 30000-32767),集群外可 节点IP:nodePort 访问本地调试、无负载均衡器的环境
LoadBalancer在 NodePort 之上再接云厂商负载均衡器云上生产对外暴露
ExternalName把 Service 映射到一个外部 DNS 名(返回 CNAME),不代理 Pod集群内用统一名字访问外部服务

一句话:四种类型层层包含——LoadBalancer 包含 NodePort 能力,NodePort 又包含 ClusterIP 能力。MySQL / Redis 这类中间件生产上一般只给 ClusterIP,不对外暴露,能被集群外摸到的入口越少越安全。

selector 的另一种写法:matchExpressions:除了 matchLabels(按标签精确匹配),还支持按表达式筛选,适合更复杂条件:

selector:
  matchExpressions:
    - key: app
      operator: In        # 可选 In / NotIn / Exists / DoesNotExist
      values: [webook, api]

selector 在 K8s 里会反复出现(Deployment 选 Pod、Service 选 Pod 都靠它),你后面会经常和它打交道。

常用排错命令(Pod 不是 Running 时按此顺序):

# 步骤 1:看 Pod 详情——底部 Events 区告诉你为什么起不来
kubectl describe pod <pod-name>

# 步骤 2:看容器日志——应用自身报错(连不上库、端口占用)
kubectl logs <pod-name>
kubectl logs -f <pod-name> --previous   # 上一次崩溃前的日志(CrashLoopBackOff 时很有用)

# 步骤 3:看集群事件——调度失败、镜像拉取失败都在这
kubectl get events --sort-by='.lastTimestamp'

# 步骤 4:进容器里看看(网络通不通、配置文件在不在)
kubectl exec -it <pod-name> -- sh

# 步骤 5:把 Pod 端口转发到本机,像调试本地服务一样调试它
kubectl port-forward <pod-name> 8080:8080

常见的 Pod 异常状态:

状态含义常见原因
Pending一直没被调度集群资源不足、PVC 没绑定成功
ImagePullBackOff拉镜像失败镜像名 / tag 写错、私有仓库需认证
CrashLoopBackOff起来就崩、反复重启应用启动报错,用 logs --previous 看崩溃前日志
Running 但访问不通进程活着Service 的 selector 和 Pod labels 对不上、端口配错
复习提示:⚠️ 新手必踩的坑:改了 YAML 不生效。kubectl apply 只对声明得合法的资源生效;本地学习时 Deployment 改乱了,最粗暴有效的办法是 kubectl delete -f xxx.yaml 再重新 apply。线上环境则用 kubectl rollout undo deployment/webook 回滚到上一个版本。

小收口:记住"Pod 是集装箱、Deployment 是船长、Service 是电话号、Ingress 是总机"。kubectl 的 get / describe / logs / exec 四件套是排查一切的钥匙。后面写 YAML 时,每个文件对应其中一块积木。


3.7 主线二:JWT 原理与实现

回到登录态问题。Redis Session 解决了多实例,但每个请求仍要去 Redis 查一次——存储和网络开销始终在。有没有可能让服务端不查任何存储就认出用户?JWT 就是答案。

3.7.1 先抛问题:JWT 是什么

JWT(JSON Web Token,JSON 网络令牌) 是一种常用于身份认证的令牌机制。和 Session 不同,JWT 把登录态直接编码成一个 token 字符串,客户端每次请求带上它,服务端解析即知身份,不需要查询任何存储。

这种"凭证自带全部信息、服务端不保存状态"的风格,就是 3.4 节讲的无状态(stateless)认证——它正是 JWT 天然适配分布式环境的根源。

类比:Session 像"门禁卡 + 前台登记簿"(每次进门前台翻簿子认人);JWT 像"带照片和公章的工作证"——门卫看一眼证上的章和照片就知道是你,不用翻登记簿。证本身带了身份信息,但证丢了别人也能冒用(除非加防伪 / 绑定)。

3.7.2 JWT 的三段结构

一个 JWT 长这样:xxxxx.yyyyy.zzzzz,用 . 分隔成三段。我们一段段拆。

第一段 · Header(头部):描述 token 元信息的 JSON,经 Base64URL 编码。

{
  "alg": "HS256",
  "typ": "JWT"
}

第二段 · Payload(负载):存放实际业务数据的 JSON,经 Base64URL 编码。

{
  "user_id": 123,
  "user_agent": "Mozilla/...",
  "exp": 1699999999,
  "iat": 1690000000,
  "nbf": 1690000000
}

⚠️ 致命提醒:Payload 默认只编码不加密!任何人拿到 token 都能解出明文。所以 user_id、user_agent 这类非敏感信息可以放;密码、手机号、身份证绝对不能放。

第三段 · Signature(签名):用 Header 指定的算法(如 HS256)+ 一个只有服务端知道的密钥(secret),对前两段计算出来的签名。

HMACSHA256(
    base64UrlEncode(header) + "." + base64UrlEncode(payload),
    secret
)

三段是怎么拼成最终字符串的:

flowchart LR
    H[Header JSON] --> HB[Base64URL 编码]
    P[Payload JSON] --> PB[Base64URL 编码]
    S[前两段 + secret] --> SB[HMAC-SHA256 签名]
    HB --> T[xxxxx.yyyyy.zzzzz]
    PB --> T
    SB --> T

3.7.3 为什么签名能防篡改

签名的核心作用是保证前两段没被人改过。服务端验签时,会用同样的算法 + 同样的 secret,对收到的 header.payload 重新算一遍签名,再和你带来的 signature 比对:

  • 一致 → 说明 header 和 payload 从签发到现在没被改过,可信。
  • 不一致 → 说明有人动了手脚(比如把 user_id 从 123 改成 1),直接拒绝。

一句话:签名像"骑缝章"。证上的信息(Payload)谁都能看见、谁都能抄,但只要你改了任何一个字,章就对不上了——门卫一眼识破。

签名算法不止一种,Header 里的 alg 常见两类:

  • HS256(HMAC + SHA-256,对称):签发和验签用同一个 secret。简单高效,本章示例用它,适合"一个系统自己签自己验"。缺点是持有 secret 的任一方都能签发 token,服务一多密钥就得到处放。
  • RS256 / ES256(RSA / 椭圆曲线,非对称):私钥签发、公钥验签。适合微服务:只有认证中心持私钥能签发,其它业务服务只拿公钥就能验证——公钥泄露也伪造不出 token。
复习提示:⚠️ 新手必踩的坑:Payload 不是密文。Base64URL 只是编码,随便找个在线工具就能解出明文。另外 secret 一旦泄露,攻击者可以伪造任意用户的 token——务必保密、用足够长的随机串、且不要硬编码进代码。

3.7.3.x 动手:不用库也能看懂签名(用 Python 算一遍)

光说"签名防篡改"有点虚。我们用 20 行 Python 把签发和验签都做一遍,你会彻底明白它在防什么。

# 手动签发 + 验签一个 HS256 JWT(仅用于理解,生产请用库)
import hmac, hashlib, base64, json, time

secret = b"my-jwt-secret-key-keep-it-safe"

def b64url(b: bytes) -> str:
    # Base64URL:把 +/ 换成 -_,去掉末尾 =
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

# 步骤 1:构造 Header 和 Payload,分别编码
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64url(json.dumps({
    "user_id": 123,
    "exp": int(time.time()) + 86400,
}).encode())

# 步骤 2:对 "header.payload" 用 secret 算 HMAC-SHA256,再编码成第三段
signing_input = f"{header}.{payload}".encode()
sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
signature = b64url(sig)

token = f"{header}.{payload}.{signature}"
print("签发的 token:", token)

# 步骤 3:验签——重新算一遍签名,比对是否一致
expected = b64url(hmac.new(secret, signing_input, hashlib.sha256).digest())
print("验签通过?" , expected == signature)   # True

# 步骤 4:如果有人篡改 Payload(比如把 user_id 改成 1)
forged_payload = b64url(json.dumps({"user_id": 1, "exp": int(time.time())+86400}).encode())
forged_input = f"{header}.{forged_payload}".encode()
forged_sig = b64url(hmac.new(secret, forged_input, hashlib.sha256).digest())
print("篡改后验签通过?", forged_sig == signature)  # False → 被识破

示例输出:

签发的 token: eyJhbGciOiJIUzI1Ni...eyJ1c2VyX2lkIjoxMjN9...xxx
验签通过? True
篡改后验签通过? False

你从这段代码学到了什么:

  • token 的第三段就是 HMACSHA256(header.payload, secret) 的结果。
  • 验签 = 用同样的 secret 对收到的 header.payload 重算一遍,和带来的 signature 比对。
  • 攻击者改了 user_id 却算不出正确的 signature(因为他没有 secret),于是验签失败——这就是防篡改的真相。

小收口:别被"加密数学"吓到,JWT 的签名就是"用密钥对内容算个校验和"。密钥不泄露,校验和就无法伪造。

3.7.4 JWT 的工作流程

sequenceDiagram
    participant U as 用户/前端
    participant S as 服务端
    U->>S: 1. 登录(账号密码)
    S->>S: 2. 校验通过, 用 secret 签发 JWT
    S-->>U: 3. 返回 token(响应头携带)
    U->>S: 4. 后续请求 Authorization: Bearer xxx
    S->>S: 5. 用 secret 验签 + 解析 Payload 拿 user_id
    S-->>U: 6. 返回业务数据

这张图怎么读:时序自上而下六步——用户先发账号密码登录(1),服务端校验通过后用 secret 签发 JWT(2)并经响应头把 token 返还给用户(3);此后用户每次请求都在 Authorization 头里带上 Bearer token(4),服务端用同一把 secret 验签并解析 Payload 取出 user_id(5),验签通过才返回业务数据(6)。关键在第 2、5 步用的是同一个 secret:签发一次、处处验签,服务端全程不查任何存储;一旦 token 被篡改或 secret 不一致,第 5 步验签失败,请求在 middleware 处被 401 拒绝。

3.7.5 JWT vs Session 优缺点对比

维度Session(Redis)JWT
服务端存储需要(Redis)不需要
分布式需要共享存储天然支持(无状态)
性能多一次 Redis 访问解析即可,更快
撤销直接删 Session难(需要黑名单)
安全性较高(数据在后端)较低(Payload 易泄露)
续期直接刷新难(需重新签发或 refresh token)

怎么取舍:Session 胜在"安全、易撤销";JWT 胜在"无存储、分布式友好、快"。没有银弹——所以工程上常常混用(见 3.8 节)。

3.7.6 JWT 登录实现(Go)

先装库:

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

登录接口改造:校验账号密码通过后,用 secret 签发一个带 user_id 和 User-Agent 的 JWT,通过响应头 x-jwt-token 返回。

// internal/web/user_handler.go
package web

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"

	"webook/internal/service"
)

// UserClaims JWT 中携带的数据(Claims = 声明)
type UserClaims struct {
	UserID    int64  `json:"user_id"`
	UserAgent string `json:"user_agent"` // 用于增强安全:比对登录设备
	jwt.RegisteredClaims // 嵌入标准声明(exp/iat/nbf...)
}

type UserHandler struct {
	svc       *service.UserService
	jwtSecret []byte // JWT 签名密钥,必须保密
}

// Login 用 JWT 改造的登录接口
func (h *UserHandler) Login(c *gin.Context) {
	// 步骤 1:绑定请求体
	var req struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := c.Bind(&req); err != nil {
		return
	}
	// 步骤 2:校验账号密码
	user, err := h.svc.Login(req.Email, req.Password)
	if err != nil {
		c.JSON(http.StatusOK, gin.H{"msg": "用户名或密码不对"})
		return
	}

	// 步骤 3:填充 Claims(user_id + User-Agent + 过期/签发时间)
	claims := UserClaims{
		UserID:    user.ID,
		UserAgent: c.GetHeader("User-Agent"),
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	// 步骤 4:用 HS256 + secret 签发 token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenStr, err := token.SignedString(h.jwtSecret)
	if err != nil {
		c.JSON(http.StatusOK, gin.H{"msg": "系统错误"})
		return
	}

	// 步骤 5:通过响应头返回,前端存好后续带上
	c.Header("x-jwt-token", tokenStr)
	c.JSON(http.StatusOK, gin.H{"msg": "登录成功"})
}

逐行讲解:

行代码为什么这么写
16-19UserClaims 嵌入 RegisteredClaims标准声明(exp/iat/nbf)由库管理,我们只加业务字段
27-33绑定 + 校验和 Session 版登录完全一致,区别在"成功后做什么"
36-42填 Claimsuser_id 是身份核心;User-Agent 是后面防冒用用的
44-45NewWithClaims + SignedString库帮我们算签名、拼成三段字符串
49c.Header("x-jwt-token", ...)token 放响应头,前端之后每次请求带在 Authorization 头

验证签发是否成功(动手):登录后拿到 token,去 jwt.io 粘贴,中间 PAYLOAD 区会直接显示 {"user_id":123,...}——这能让你直观确认"Payload 只编码不加密"。

3.7.7 JWT 登录校验 Middleware

每个受保护接口都要先验 token。这个 middleware 从 Authorization: Bearer xxx 头取 token、验签、解析、再比对 User-Agent:

// internal/web/jwt_middleware.go
package web

import (
	"net/http"
	"strings"
	"time"

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

func (h *UserHandler) LoginRequiredJWT(c *gin.Context) {
	// 步骤 1:从 Authorization 头读取
	authHeader := c.GetHeader("Authorization")
	if authHeader == "" {
		c.AbortWithStatus(http.StatusUnauthorized)
		return
	}
	// 步骤 2:按 "Bearer xxxxxx" 格式拆分
	parts := strings.SplitN(authHeader, " ", 2)
	if len(parts) != 2 || parts[0] != "Bearer" {
		c.AbortWithStatus(http.StatusUnauthorized)
		return
	}
	tokenStr := parts[1]

	// 步骤 3:解析 + 验证签名
	claims := &UserClaims{}
	token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
		return h.jwtSecret, nil // 返回验签用的密钥
	})
	if err != nil || !token.Valid {
		c.AbortWithStatus(http.StatusUnauthorized)
		return
	}

	// 步骤 4:增强安全——校验 User-Agent 是否和登录时一致
	if claims.UserAgent != c.GetHeader("User-Agent") {
		c.AbortWithStatus(http.StatusUnauthorized)
		return
	}

	// 步骤 5:快过期时重新签发,把新 token 放响应头
	if claims.ExpiresAt != nil &&
		claims.ExpiresAt.Sub(time.Now()) < time.Hour {
		newClaims := UserClaims{
			UserID:    claims.UserID,
			UserAgent: claims.UserAgent,
			RegisteredClaims: jwt.RegisteredClaims{
				ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
				IssuedAt:  jwt.NewNumericDate(time.Now()),
			},
		}
		newToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
		if newTokenStr, err := newToken.SignedString(h.jwtSecret); err == nil {
			c.Header("x-jwt-token", newTokenStr)
		}
	}

	// 步骤 6:注入 context,放行
	c.Set("user_id", claims.UserID)
	c.Next()
}

注意:JWT 不能像 Session 那样"在服务端悄悄续期"——它无状态,服务端不记事。所以续期只能重新签发一个新 token 返回给前端,前端下次用新的。这就是表格里"续期难"的来源。

3.7.8 跨域改造(CORS)

JWT 模式下前端把 token 放在 Authorization 头,需要改 CORS 配置放行这个头:

// internal/web/server.go
package web

import (
	"time"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
)

func NewEngine() *gin.Engine {
	engine := gin.Default()
	engine.Use(cors.New(cors.Config{
		AllowOriginFunc: func(origin string) bool {
			return origin == "http://localhost:3000" // 只允许前端域名
		},
		AllowCredentials: true,
		// 业务请求允许带的头:加上 Authorization
		AllowHeaders: []string{"Content-Type", "Authorization"},
		// 允许前端读到的响应头:加上 x-jwt-token
		ExposeHeaders: []string{"x-jwt-token"},
		AllowMethods:  []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		MaxAge:        12 * time.Hour,
	}))
	return engine
}

3.7.9 加餐:公开接口怎么放行(path 白名单的两种做法)

把 LoginRequiredJWT 挂到全局 engine.Use(...) 后,注册 /users/signup、登录 /users/login 这些本就不需要登录的接口也会被拦成 401。得开"白名单"。两种做法:

做法一:中间件里维护 path 白名单(精确匹配)

// internal/web/whitelist.go
package web

import "github.com/gin-gonic/gin"

// skipPaths 无需登录即可访问的路径白名单
// 用 map 而不是 slice:查找 O(1),且天然去重
var skipPaths = map[string]struct{}{
	"/users/signup": {},
	"/users/login":  {},
}

func (h *UserHandler) LoginRequiredJWT(c *gin.Context) {
	// 步骤 1:白名单内的路径直接放行
	if _, ok := skipPaths[c.Request.URL.Path]; ok {
		c.Next()
		return
	}
	// 步骤 2:以下与 3.7.7 完全一致——解析、验签、校验 User-Agent……
	_ = c
}
  • 优点:实现简单、查找快、不依赖路由怎么注册。
  • 缺点:白名单和路由注册分离在两处,新增公开接口(如 /health)必须记得回来改;只支持精确匹配,前缀批量放行要自己补 strings.HasPrefix。

做法二:路由分组——让"要不要登录"体现在路由注册处

// internal/web/routes.go
package web

import "github.com/gin-gonic/gin"

func (h *UserHandler) RegisterRoutes(server *gin.Engine) {
	// 步骤 1:公开分组——不挂登录中间件
	pub := server.Group("/users")
	pub.POST("/signup", h.Signup)
	pub.POST("/login", h.LoginJWT)

	// 步骤 2:受保护分组——挂上登录校验
	auth := server.Group("/users")
	auth.Use(h.LoginRequiredJWT)
	auth.GET("/profile", h.Profile)
	auth.POST("/edit", h.Edit)
}

这样"哪些接口需要登录"一眼可见:新增公开接口直接进 pub 组,不用动中间件一行代码——本质是把白名单信息从"代码里的 if"变成"路由的组织结构"。

类比:做法一像小区门禁给访客发"免登记名单",物业得维护一张表,漏登记就被拦门外;做法二像直接把小区分成"开放区"和"业主区",业主区门口站保安,开放区随便走。结构本身就是规则,不存在"忘登记"。

维度做法一:白名单匹配做法二:路由分组
实现难度简单,几行代码简单,只改路由注册
新增公开接口要记得改白名单(易漏)注册到公开组即可(不易漏)
前缀/批量放行需自己写 HasPrefix分组天然支持
可读性白名单藏在 middleware路由注册处一目了然
适用场景已有全局中间件、不便重整新项目/愿整理路由(推荐)
复习提示:

⚠️ 两个新手必踩的坑:

  1. 做法二同一路径不能注册两次——pub.POST("/login", ...) 之后再 auth.POST("/login", ...) 会 panic(gin 报 handlers are already registered)。一个路径只进一个组。
  2. 做法一配前缀匹配别过宽——strings.HasPrefix(path, "/users") 会把 /users/profile 一起放行,等于登录校验全失效。前缀白名单必须精确到"仅公开的那段"。

实践中两种可结合:主体用路由分组,个别历史遗留路径用白名单兜底。

3.7.10 加餐:JWT 常见坑合集

把新手最常在 JWT 上栽的跟头集中列一遍,免得你上线后踩雷。

复习提示:⚠️ 坑 1 · secret 用弱密码或硬编码:my-jwt-secret 这种短密钥分分钟被暴力破解;写死在代码里还可能被提交到 git。正确做法:用足够长的随机串 + 从环境变量 / Secret 读取。
复习提示:⚠️ 坑 2 · 过期时间设太长:有人图省事把 exp 设成"7 天"甚至"不过期"。一旦 token 泄露,攻击者有整整一周的窗口冒充你。正确做法:access token 短(15 分钟 ~ 24 小时),靠 refresh token 续期。
复习提示:⚠️ 坑 3 · 把 token 存 localStorage 不防 XSS:前端把 token 存 localStorage,任何被注入的脚本都能 localStorage.getItem 偷走。高安全场景建议存 HttpOnly Cookie(JS 读不到),或至少对前端做严格的 XSS 防护。
复习提示:⚠️ 坑 4 · 没校验 alg:早期 JWT 库有个经典漏洞——攻击者把 Header 的 alg 改成 none,某些库会跳过验签直接放行。务必用固定算法(如 jwt.SigningMethodHS256)校验,并用库的 Claims 机制,不要自己手写解析。

小收口(主线二):JWT 用"凭证自带信息 + 签名防篡改"做到无状态认证,天然适配多实例和 K8s。但它也有硬伤——Payload 易泄露、撤销难、这些坑(弱密钥 / 长过期 / XSS / alg 伪造)都得在设计和代码里提前堵上。所以下一节讲怎么和 Session/Redis 取长补短。


3.8 JWT 与 Session 混用

JWT 不能放敏感数据,但业务有时确实要在登录态里携带昵称、角色等资料。解决方案:JWT 里只放 user_id,再用 user_id 作 key 去 Redis 取详细数据。

JWT:  { user_id: 123 }
Redis: user.info:123 → { nickname, role, ... }

这样既能享受 JWT 的分布式优势,又能放敏感数据(数据在 Redis,前端解码 JWT 看不到):

flowchart TD
    Req[请求带 JWT] --> V[验签拿 user_id]
    V --> C{"需要敏感资料?"}
    C -- 否 --> Ok[直接返回]
    C -- 是 --> R["查 Redis: user.info:user_id"]
    R --> Ok

一句话结论:JWT 管"你是谁",Redis 管"你的详细资料"。两者分工,既无状态又保安全。


3.9 主线三:保护系统——限流

一个能注册登录的服务上线后,马上会暴露两个漏洞:任何人都能无限次调 /users/signup 和 /users/login。攻击者写个脚本狂发请求,数据库直接被打挂。

3.9.1 先抛问题:为什么要限流

限流的目的有两个层面:

  • 保护系统:限制每秒处理的总请求数,避免被打爆。
  • 保护公平:限制每个用户 / IP 的请求频率,防止少数人占满资源。

3.9.2 限流对象:为什么用 IP

登录注册接口用户还没登录,拿不到 user_id,所以没法按用户限流。用 IP 是 Web 端较优选择(虽然共享 IP(如公司 NAT)可能误伤,但阈值设合理就行)。App 端可以用设备序列号。

3.9.3 基于 Redis 的 IP 限流

为什么用 Redis:单机限流在多实例下不准(每个实例各计各的)。用 Redis 集中计数,所有实例共享同一个计数器,才能准确限流。

// internal/middleware/ratelimit.go
package middleware

import (
	"fmt"
	"net/http"
	"time"

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

// IPMaxReqPerSec 每秒每 IP 最大请求数
const IPMaxReqPerSec = 100

func IPRateLimit(client *redis.Client) gin.HandlerFunc {
	return func(c *gin.Context) {
		// 步骤 1:拼 key,精确到 "秒 + IP"
		ip := c.ClientIP()
		key := fmt.Sprintf("ip:rate:%s:%d", ip, time.Now().Unix())

		// 步骤 2:INCR 原子递增;key 不存在时会创建并设为 1
		cnt, err := client.Incr(c, key).Result()
		if err != nil {
			// 步骤 3:Redis 出错时降级——放行,避免 Redis 故障拖垮全站
			c.Next()
			return
		}
		// 步骤 4:第一次访问时设过期时间,让 key 在下一秒自动清理
		if cnt == 1 {
			client.Expire(c, key, time.Second)
		}
		// 步骤 5:超阈值则拦截
		if cnt > IPMaxReqPerSec {
			c.AbortWithStatusJSON(http.StatusTooManyRequests,
				gin.H{"msg": "请求过于频繁"})
			return
		}
		c.Next()
	}
}

流程:

flowchart TD
    Req[请求] --> K["INCR ip:rate:ip:秒"]
    K --> F{"计数 > 100?"}
    F -- 是 --> B[429 拦截]
    F -- 否 --> P[放行]

阈值怎么定:整体阈值靠压测得到(面试答这个);单 IP 阈值靠经验,正常人手速 1 秒 1 个,考虑共享 IP 给 100/秒足够。

复习提示:⚠️ 新手必踩的坑:Redis 故障要降级放行。上面在 Incr 出错时直接 c.Next()。如果你反过来——Redis 一挂就统统拒绝,那 Redis 抖动会直接打挂全站登录。限流组件本身必须是"高可用且不阻塞主流程"的。

3.9.4 增强登录安全:User-Agent 校验

光靠 JWT/Session 不够,token 泄露后攻击者就能冒充。增强手段:登录时记录 User-Agent,校验时比对,设备变了就认为有风险。

// 登录时把 User-Agent 写进 Claims(见 3.7.6)
// 校验时比对(见 3.7.7)
if claims.UserAgent != c.GetHeader("User-Agent") {
    c.AbortWithStatus(http.StatusUnauthorized)
    return
}

更高级的做法是二次验证(短信 / 邮件),后面课程讲。

3.9.5 常见限流算法速览

3.9.3 用的是最朴素的"固定窗口计数"(每秒一个窗口、INCR 计数)。业界四种常见算法,面试聊限流最好都能说几句:

算法思路优点缺点
固定窗口每个时间窗口(如 1 秒)内计数,超阈值拒绝实现最简单窗口边界突刺:0.9s 和 1.1s 各来 100,中间 1 秒实际承受 200
滑动窗口把窗口切成小格子,滑动统计最近一段时间计数平滑边界问题实现稍复杂,格子越多越准也越耗内存
漏桶请求进桶,以固定速率流出处理;桶满拒绝流量绝对平整无法应对合理突发
令牌桶以固定速率往桶里放令牌,请求拿到令牌才放行允许一定突发参数需调优

类比:漏桶像景区闸机——门口排队再猛,进门速度恒定;令牌桶像游乐园快速通行票——平时慢慢攒票,高峰期一口气用掉几张,允许"憋大招"。3.9.3 的 Redis 计数就是最简单的固定窗口;生产级网关(如 Sentinel)大多用令牌桶或滑动窗口。

小收口(主线三 + 正文收尾):限流 + User-Agent 校验是系统的"门卫"。本章三条主线(Session 困境 → JWT → 限流)到此讲完。下一步,把它们落到可以跑的项目里,并真正部署上 K8s。


四、跟着写:分步实现

这一节把前面所有知识点串成一条可验证的动手主线。每一步结束时项目都能编译、能跑、能验证。环境假设:你已经完成第 02 章的单机 webook(Gin + GORM + cookie Session)。

步骤 1 · 起一个 Redis(docker-compose)

目标:本地有一个可达的 Redis,地址 localhost:6379。

# docker-compose.yaml(项目根目录)
services:
  redis:
    image: redis:7
    ports:
      - "6379:6379"
    # 官方 redis:7 镜像默认无密码,可直接连接

验证命令:

docker compose up -d
redis-cli ping

预期输出:

PONG

看到 PONG 说明 Redis 容器起来了、端口通了。

目标:go run 启动后,登录态落在 Redis,而不是进程内存。

// internal/web/session.go
package web

import (
	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/redis"
)

func InitRedisSessionStore() sessions.Store {
	store, err := redis.NewStore(16, "tcp",
		"localhost:6379", "",
		[]byte("a-key-for-auth-must-be-32-bytes!"),
		[]byte("a-key-for-encryption-32-bytes-ok"),
	)
	if err != nil {
		panic(err)
	}
	return store
}

在你的 main / 启动处,把原来 sessions.NewCookieStore(...) 换成 InitRedisSessionStore()。业务里 sess.Get("user_id") 一行都不用改。

验证命令:登录一次,然后看 Redis 里有没有 Session 数据。

redis-cli keys '*'

预期输出(示例,键名由库生成,前缀不同属正常):

sess_xxx...

能看到以 sess 开头的键,说明登录态已经写进 Redis 而非内存。

步骤 3 · 加 Session 过期刷新 Middleware

目标:活跃用户不会被突然踢下线,且 1 分钟内至多刷一次 Redis。

// internal/web/middleware.go
package web

import (
	"net/http"
	"time"

	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
)

func (h *UserHandler) LoginRequired(c *gin.Context) {
	sess := sessions.Default(c)
	userID, ok := sess.Get("user_id").(int64)
	if !ok || userID == 0 {
		c.AbortWithStatus(http.StatusUnauthorized)
		return
	}
	now := time.Now().UnixMilli()
	lastRefresh, _ := sess.Get("last_refresh").(int64)
	if now-lastRefresh > 60*time.Second.Milliseconds() {
		sess.Set("last_refresh", now)
		sess.Options(sessions.Options{MaxAge: 60 * 60 * 24, Secure: true, HttpOnly: true})
		_ = sess.Save()
	}
	c.Set("user_id", userID)
	c.Next()
}

验证:连续访问受保护接口,用 redis-cli TTL sess_xxx 看过期时间是否稳定在 1 天附近、且活跃时会被刷新(TTL 回到接近 86400)。

步骤 4 · 签发你的第一个 JWT

目标:登录成功返回 x-jwt-token,并能在 jwt.io 拆开看到 user_id。

// internal/web/user_handler.go(节选,完整见 3.7.6)
func (h *UserHandler) Login(c *gin.Context) {
	var req struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := c.Bind(&req); err != nil {
		return
	}
	user, err := h.svc.Login(req.Email, req.Password)
	if err != nil {
		c.JSON(http.StatusOK, gin.H{"msg": "用户名或密码不对"})
		return
	}
	claims := UserClaims{
		UserID:    user.ID,
		UserAgent: c.GetHeader("User-Agent"),
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenStr, _ := token.SignedString(h.jwtSecret)
	c.Header("x-jwt-token", tokenStr)
	c.JSON(http.StatusOK, gin.H{"msg": "登录成功"})
}

验证:用 curl 登录,看响应头有没有 token。

curl -i -X POST localhost:8080/users/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"a@b.com","password":"123456"}'

预期输出(节选):响应头里应出现 x-jwt-token: eyJ... 一行,形如:

HTTP/1.1 200 OK
Content-Type: application/json
x-jwt-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx.yyy

把 eyJ... 整串贴到 jwt.io,中间 PAYLOAD 区直接显示 {"user_id":...,"user_agent":"curl/...",...}——这步能让你"亲眼看到" Payload 不加密。

步骤 5 · JWT 登录校验 Middleware

目标:受保护接口能从 Authorization: Bearer xxx 验出 user_id。完整代码见 3.7.7,注册到受保护路由分组(做法二):

// internal/web/routes.go
func (h *UserHandler) RegisterRoutes(server *gin.Engine) {
	pub := server.Group("/users")
	pub.POST("/signup", h.Signup)
	pub.POST("/login", h.Login)

	auth := server.Group("/users")
	auth.Use(h.LoginRequiredJWT)
	auth.GET("/profile", h.Profile)
}

验证:不带 token 访问 /users/profile 应得 401;带正确 token 应得 200。

curl -i localhost:8080/users/profile
# 不带 token → HTTP/1.1 401 Unauthorized

curl -i localhost:8080/users/profile \
  -H "Authorization: Bearer <上一步拿到的token>"
# 带 token → HTTP/1.1 200 OK

步骤 6 · IP 限流 Middleware

目标:单 IP 每秒超 100 请求被拦(429)。完整代码见 3.9.3,挂到全局 engine.Use(IPRateLimit(redisClient))。

验证:快速连发请求,观察第 101 个起返回 429。

# 一秒内连发 105 次(示例)
for i in $(seq 1 105); do curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/users/login; done | sort | uniq -c

预期输出(示例):大致前 100 个 200,后 5 个 429。注意:本机 localhost 可能被识别为同一 IP,阈值 100 较宽松,多发几次才触发,属正常。

步骤 7 · 打包镜像 + 写 K8s YAML

目标:webook 能被 K8s 调度。

第一步:编译 Linux 二进制

# Mac Apple Silicon 用 arm64;云服务器多为 amd64
GOOS=linux GOARCH=amd64 go build -o webook .

第二步:Dockerfile

# Dockerfile(项目根目录)
FROM ubuntu:22.04
COPY webook /app/webook
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["/app/webook"]

第三步:构建镜像

docker build -t flycash/webook:v0.0.1 .

第四步:Deployment

# k8s-webook-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webook
  labels:
    app: webook
spec:
  replicas: 3                 # 要 3 个 Pod
  selector:
    matchLabels:
      app: webook             # 按标签找自己的 Pod
  template:
    metadata:
      labels:
        app: webook           # 必须和 selector 对得上
    spec:
      containers:
        - name: webook
          image: flycash/webook:v0.0.1
          ports:
            - containerPort: 8080
复习提示:⚠️ 新手必踩的坑:template.labels 必须和 selector.matchLabels 完全一致。不一致时 Deployment 会创建出 Pod 却"认不出"是自己管的,反复创建新 Pod,典型"Pod 无限漂移"。

第五步:Service + Ingress

# k8s-webook-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: webook
spec:
  type: LoadBalancer
  selector:
    app: webook
  ports:
    - name: http
      port: 8080
      targetPort: 8080
---
# k8s-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: webook-ingress
spec:
  ingressClassName: nginx
  rules:
    - host: webook.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: webook
                port:
                  number: 8080

附 A · MySQL 部署(含 PVC 持久化):MySQL 数据不能随容器删除而丢,用 PVC(PersistentVolumeClaim)挂载持久盘。

# k8s-mysql.yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql-pvc
spec:
  accessModes:
    - ReadWriteOnce          # 一个 Pod 挂载、可读写(MySQL 用这个)
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysql
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
        - name: mysql
          image: mysql:8.0.29
          env:
            - name: MYSQL_ROOT_PASSWORD
              value: root
          ports:
            - containerPort: 3306
          volumeMounts:
            - name: mysql-storage
              mountPath: /var/lib/mysql   # MySQL 数据目录挂到 PVC
      volumes:
        - name: mysql-storage
          persistentVolumeClaim:
            claimName: mysql-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: mysql
spec:
  selector:
    app: mysql
  ports:
    - port: 3306
      targetPort: 3306

PVC 一句话:PVC 是"我需要一块这样的盘",PV 是"这有一块盘",StorageClass 是"按需求自动造盘"。MySQL 用 ReadWriteOnce(RWO,一个实例一个卷);还有 ReadOnlyMany(多 Pod 只读)和 ReadWriteMany(多 Pod 都写)。

附 B · Redis 部署(NodePort 便于本地调试):

# k8s-redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7
          ports:
            - containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
  name: redis
spec:
  type: NodePort             # 暴露到集群外,本机可直连调试
  selector:
    app: redis
  ports:
    - port: 6379
      targetPort: 6379
      nodePort: 31379        # 集群外访问:localhost:31379

验证:

kubectl apply -f k8s-redis.yaml
redis-cli -p 31379 ping   # 应返回 PONG

附 C · 安装 ingress-nginx(让 Ingress 真正生效):Ingress 只是"规则",得有 Ingress Controller 去执行它。本地用 helm 装 nginx 版:

# 安装 helm(一个 K8s 包管理器)
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod 700 get_helm.sh
./get_helm.sh

# 用 helm 安装 ingress-nginx
helm upgrade --install ingress-nginx ingress-nginx \
  --repo https://kubernetes.github.io/ingress-nginx \
  --namespace ingress-nginx --create-namespace

附 D · 多环境配置用编译标签切换:本地连 localhost,K8s 内连 Service 名(mysql:3306 / redis:6379)。用 //go:build 编译标签管理:

// internal/config/dev.go
//go:build !k8s

package config

func init() {
    Cfg = Config{
        DBDSN:     "root:root@tcp(localhost:3306)/webook",
        RedisAddr: "localhost:6379",
    }
}
// internal/config/k8s.go
//go:build k8s

package config

func init() {
    Cfg = Config{
        DBDSN:     "root:root@tcp(mysql:3306)/webook",  // K8s 内用 Service 名
        RedisAddr: "redis:6379",
    }
}

编译:

go build -o webook .              # 本地开发
go build -tags=k8s -o webook .    # 打 K8s 镜像用这个

一句话:K8s 内访问别的服务用 Service名:端口(Service 名即 metadata.name)。编译标签让你同一份代码跑在两种环境,不写死地址。

步骤 8 · 用 kubectl 部署并验证

目标:MySQL / Redis / webook 全 Running。

kubectl apply -f k8s-mysql.yaml
kubectl apply -f k8s-redis.yaml
kubectl apply -f k8s-webook-deployment.yaml
kubectl apply -f k8s-webook-service.yaml
kubectl apply -f k8s-ingress.yaml

验证:看 Pod 状态

kubectl get pods

示例输出(Pod 名带随机后缀,属正常):

NAME                       READY   STATUS    RESTARTS   AGE
webook-7d9f8c6b4-abcde     1/1     Running   0          30s
webook-7d9f8c6b4-fghij     1/1     Running   0          30s
webook-7d9f8c6b4-klmno     1/1     Running   0          30s
mysql-6c8b9d-x1y2z         1/1     Running   0          45s
redis-5d7e8f-a9b8c         1/1     Running   0          40s

看到 5 个 Pod 都是 Running,说明整套系统已在 K8s 里跑起来。如果某个不是 Running,用 kubectl describe pod <名> 看底部 Events 找原因。

本章完成后的项目结构(节选)

webook/
├── docker-compose.yaml        # 本地 Redis
├── Dockerfile                 # 构建镜像
├── k8s-mysql.yaml             # MySQL 部署 + PVC
├── k8s-redis.yaml             # Redis 部署 + Service(NodePort)
├── k8s-webook-deployment.yaml # webook Deployment(replicas=3)
├── k8s-webook-service.yaml    # webook Service
├── k8s-ingress.yaml           # 按域名转发
├── internal/
│   ├── web/
│   │   ├── session.go         # Redis Session Store
│   │   ├── user_handler.go     # 登录(支持 Session/JWT)
│   │   ├── jwt_middleware.go   # JWT 校验中间件
│   │   ├── middleware.go        # Session 刷新中间件
│   │   └── routes.go           # 路由分组(公开/受保护)
│   ├── middleware/
│   │   └── ratelimit.go        # IP 限流
│   └── config/
│       ├── dev.go              # //go:build !k8s
│       └── k8s.go              # //go:build k8s
└── main.go

验证清单(可勾选)

  • redis-cli ping 返回 PONG
  • 登录后 redis-cli keys '*' 能看到 Session 键
  • 活跃访问时 Session TTL 被刷新(不中途踢下线)
  • 登录响应头带 x-jwt-token,jwt.io 能拆出 user_id
  • 不带 token 访问受保护接口得 401,带 token 得 200
  • 单 IP 超阈值返回 429
  • kubectl get pods 中 webook/mysql/redis 全 Running

五、工程实践要点

把前面知识点落成生产系统时,下面这些"老手才知道的坑"值得记牢。它们来自原文章节的工程经验,我按四条主线重新归类。

5.1 Session vs JWT 选型

没有银弹,按场景选:

场景建议
单体应用、需要主动注销Session(可直接删)
微服务、对性能敏感JWT
需要在登录态放敏感数据Session + Redis
既想要 JWT 分布式、又想放敏感数据JWT(user_id)+ Redis(user.info:user_id)

一句话:先用"要不要主动注销 / 要不要放敏感数据 / 是不是微服务"三个问题反问自己,再决定。

5.2 JWT 安全(六条红线)

  1. 不放敏感信息:Payload 默认只编码不加密,密码 / 手机号 / 身份证绝对不能进 Payload。
  2. secret 保密:泄露后所有 token 可被伪造。用足够长的随机串,从配置 / Secret 读取,别硬编码。
  3. 设短过期时间:泄露风险窗口小(如 15 分钟 ~ 24 小时,按业务定)。
  4. User-Agent 校验:增强安全性,token 被盗换设备也拦得住。
  5. HTTPS 必备:防止 token 被中间人截获。
  6. 撤销难是 JWT 硬伤:需要黑名单机制(用 Redis 记录被撤销的 jti)。

5.3 限流实践(五条)

  1. 集群限流用 Redis:单机限流在多实例下各计各的,不准。
  2. 限流阈值靠压测:整体阈值靠压测得到,单 IP 阈值靠经验(如 100/秒)。
  3. 限流后返回标准错误:429 Too Many Requests。
  4. Redis 故障要降级:限流组件故障不能影响主流程,可临时放行(c.Next())。
  5. 关键接口都要限流:尤其是免登录接口(注册、登录、短信)。

5.4 K8s 实践(六条)

  1. 生产禁止用 AutoMigrate:DDL 走 SQL review 流程,别让程序自己改表结构。
  2. 配置走 ConfigMap / Secret:不要硬编码在镜像里(密钥进 Secret,不进 git)。
  3. 资源限制:每个容器设 resources.limits,防止单个 Pod 吃光节点资源。
  4. 健康检查:配 livenessProbe 和 readinessProbe,K8s 自动重启异常 Pod、发布时只把流量给就绪的。
  5. 多副本:生产至少 2 个副本,避免单点。
  6. 镜像版本:用具体 tag(如 v0.0.1),不要用 latest,避免不可控变化。
复习提示:给下一章埋的雷:本期(dev.go / k8s.go)用编译标签切换配置是最轻量的做法;但真实项目配置会更复杂(多环境、热更新),第 04 章讲接口抽象与测试时,我们会把"配置从哪来"也抽象成可替换的部分。

六、自测题与动手练习

自测题(合上书能答出来,才算懂):

  1. 第 02 章的 cookie Session 在单机上工作正常,为什么上了多实例用户会被踢回登录页?Redis Session 又是怎么解决"记忆分家"的?
  2. JWT 的三段(Header / Payload / Signature)各自是什么、xxxxx.yyyyy.zzzzz 是怎么拼出来的?为什么说 Payload “只编码不加密”?
  3. JWT 的 Payload 里能放密码吗?为什么?如果业务真要在登录态里携带昵称、角色,该怎么设计?
  4. 基于 Redis 的 IP 限流,为什么不能在 Redis 出错时一律拒绝?正确的降级策略是什么?
  5. K8s 的 port / targetPort / nodePort 三者分别作用于哪一段?Deployment 的 template.labels 和 selector.matchLabels 不一致会发生什么?
  6. 登录校验中间件挂到全局后,注册、登录这些公开接口也被拦截了。放行公开接口有哪两种做法?各有什么优缺点?
  7. Session 过期刷新有哪几种策略?为什么本书推荐"固定间隔刷新"?
  8. JWT 为什么"撤销难"?如果要让一个已签发的 JWT 立刻失效,工程上一般怎么做?

动手练习(建议真做一遍):

  1. 拆 JWT:用 jwt.io 粘贴你签发的 token,确认能直接看到 Payload 里的 user_id;再故意改 Signature 一段里一个字符,看验签为何失败,体会"签名防篡改"。
  2. 切到 Redis Session:把本地项目 cookie store 换成 redis store,起两个不同端口的实例 + 一个简单轮流访问两个端口的脚本,验证登录态不再丢失(不再被踢下线)。
  3. 本地起一套 K8s:在 Docker Desktop 开启 Kubernetes,依次 apply MySQL / Redis / webook 的 YAML,用 kubectl get pods 确认全 Running,并通过 Ingress 域名(如 webook.local)访问接口;进阶:手动删一个 webook Pod,观察 Deployment 是否自动补回。

七、模拟面试问答

这一节模拟真实面试。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。共 12 题,分基础 4 / 进阶 4 / 追问 4。

面试官
【基础】 第 02 章的 cookie Session 在单机上正常,为什么上了多实例用户会被踢回登录页?Redis Session 是怎么解决这个问题的?
候选人

一句话结论:单机时 Session 存在单个实例内存里;多实例下请求被负载均衡分到不同 Pod,目标 Pod 找不到 Session,用户就被踢回登录页。解决思路是用 Redis 作为共享 Session 存储(“中央账本”),所有实例都从同一个 Redis 读写,登录态不再丢失。

展开:Session 的本质是"服务端记住你是谁",第 02 章把它记在进程内存。多实例下每个实例各有各的内存账本,用户在 A 登录、请求被分到 B,B 内存里没有他的 sess_id 自然认为没登录。Redis Session 把账本从"各家自己记"改成"所有人查同一份中央账本"——sess_id 仍在 Cookie,真正的 user_id 等数据挪到 Redis。因为 Gin 的 store 是接口,业务代码 sess.Get("user_id") 一行都不用改。

面试官想听的关键词:内存账本分家、负载均衡、中央账本、store 接口、sess_id 在 Cookie 数据在 Redis。

常见失分点:说不清"为什么多实例会丢"(漏掉"各实例内存独立"这一句);或者误以为换 Redis 后 Cookie 里要放 user_id(错,Cookie 只放 sess_id,敏感数据在 Redis)。

面试官
【基础】 JWT 的三段(Header / Payload / Signature)各自是什么?为什么说 Payload “只编码不加密”?
候选人

一句话结论:Header 是描述算法的 JSON 经 Base64URL 编码;Payload 是存放 user_id 等数据的 JSON 经 Base64URL 编码;Signature 是用指定算法 + 服务端 secret 对前两段算出的签名。Base64URL 只是编码不是加密,任何人拿到 token 都能解出 Payload 明文。

展开:三段用 . 连接成一个字符串。Header 和 Payload 都是 JSON 先 Base64URL 编码(注意是编码 encoding,不是加密 encryption);Signature 是拿 base64(header).base64(payload) 加上 secret 算 HMAC。所以 Payload 谁都能解码——你把它贴到 jwt.io 立刻看到明文。这也是为什么密码、手机号这类敏感信息绝不能进 Payload。

面试官想听的关键词:Base64URL 编码非加密、三段用点连接、Signature 防篡改、secret 不公开、Payload 明文可见。

常见失分点:把"编码"说成"加密"(这是最高频错误);或者以为 Signature 里也藏了 user_id(错,Signature 只是校验值,身份数据在 Payload)。

面试官
【基础】 JWT 的 Payload 里能放密码吗?为什么?如果业务真的要在登录态里携带昵称、角色等资料,该怎么设计?
候选人

一句话结论:不能。Payload 默认只编码不加密,密码、手机号、身份证放进去等于明文泄露。正确做法是 JWT 里只放 user_id,再用 user_id 作 key 去 Redis 查 user.info:123 拿到昵称、角色——敏感数据留在后端。

展开:因为 Base64URL 可逆,任何人拿到 token 都能解出 Payload。但业务常需要在登录态带资料:解决方案是"分工"——JWT 只装最小身份 user_id(无敏感),需要资料时用 user_id 去 Redis 取 user.info:user_id。这样既享受 JWT 的无状态 / 分布式优势,又把敏感数据挡在前端解码视线之外。

面试官想听的关键词:Payload 明文、JWT 只放 user_id、Redis 存 user.info:user_id、敏感数据留后端、JWT+Session 混用。

常见失分点:回答"用 RS256 加密就行"(错,RS256 只管签名不管 Payload 加密,Payload 仍可被任何人解码);或知道不能放却给不出"JWT+Redis 混用"的具体方案。

面试官
【基础】 基于 Redis 的 IP 限流,为什么不能在 Redis 出错时一律拒绝?正确的降级策略是什么?
候选人

一句话结论:限流组件本身必须高可用且不阻塞主流程。若 Redis 计数出错就统统拒绝,Redis 一抖会直接打挂全站登录。正确降级是 Redis 出错时 c.Next() 放行。

展开:限流是"保护系统"的辅助手段,不是主业务。它的故障不应比"被攻击"更严重。代码在 Incr 返回 err 时直接 c.Next(),让请求正常通过,把限流暂时交给"下次 Redis 恢复"。这是典型的"fail open(故障时开放)“策略——和"fail closed(故障时拒绝)“相反,限流场景选 open 更稳。

面试官想听的关键词:限流组件不阻塞主流程、fail open 降级、Redis 抖动不影响登录、429 与放行权衡。

常见失分点:反着答"Redis 挂了就该拒绝所有请求保护系统”(这是把限流组件变成单点故障);或说不清"为什么降级要放行而不是排队”。

面试官
【进阶】 请对比 JWT 和 Session 的优缺点,并说明实际项目怎么选型。
候选人

一句话结论:Session 胜在安全(数据在后端)和易撤销(直接删);JWT 胜在无存储、分布式天然友好、解析快。选型看三问:要不要主动注销、要不要放敏感数据、是不是微服务。

展开:Session 需要服务端存(Redis),每次请求多一次存储访问,但数据不在客户端、要踢人直接删 key;JWT 服务端不存,任意实例拿到 token 都能独立验,但 Payload 明文易泄露、且无法直接撤销(除非维护黑名单)。实际选型:单体且要主动注销→Session;微服务且性能敏感→JWT;要放敏感数据→Session+Redis;既要分布式又要敏感数据→JWT(user_id)+Redis(user.info)。

面试官想听的关键词:Session 易撤销、JWT 无状态、分布式友好、撤销难、按场景混用、黑名单。

常见失分点:说"JWT 比 Session 好所以都用 JWT"(忽略撤销难和 Payload 泄露);或只背表格不给出"怎么选型"的判断框架。

面试官
【进阶】 K8s 的 port / targetPort / nodePort 分别作用于哪一段?Deployment 的 template.labels 和 selector.matchLabels 不一致会发生什么?
候选人

一句话结论:port 是 Service 自己监听的端口(集群内访问 webook:8080);targetPort 是转发到 Pod 的容器内端口;nodePort 是集群外访问端口(type=NodePort 时才有)。template.labels 与 selector.matchLabels 不一致,Deployment 会创建出 Pod 却"认不出"是自己管的,反复创建新 Pod,造成无限漂移。

展开:三者构成"外部→Service→Pod"的端口链:外部用户打 nodePort(节点 IP:3xxxx),到 Service 的 port,再转 targetPort 进容器。selector 是 K8s 的"筛子"——Deployment 靠 matchLabels 找自己该管的 Pod,Pod 模板的 labels 必须被这个筛子筛中。不一致时筛子筛不到自己造的 Pod,于是认为"还缺 Pod",不断新建,经典雪崩之一。

面试官想听的关键词:端口三兄弟、selector 筛子、labels 必须匹配、Pod 无限漂移、ClusterIP。

常见失分点:把 targetPort 说成"对外端口"(错,对外是 nodePort);或只说"会出错"说不清"为什么会反复创建 Pod"。

面试官
【进阶】 Session 过期刷新有哪几种策略?为什么本书推荐"固定间隔刷新"?
候选人

一句话结论:四种——每次访问刷新、快过期才刷新、固定间隔刷新、长短 token。推荐固定间隔(如 1 分钟内首次访问刷一次),因为它折中了体验与性能。

展开:每次刷新体验最好但 Redis 压力大;快过期刷新性能好但用户可能刷新后再也不来、白刷且仍有极小窗口被踢;固定间隔用"距上次刷新超过 N 秒才刷"把写 Redis 频率压到可接受范围,又保证活跃用户不被踢。长短 token(access token 短、refresh token 长)体验最佳但实现最复杂,留给后续课程。

面试官想听的关键词:四种策略、体验 vs 性能权衡、固定间隔、长 token 短 token、MaxAge 双层含义。

常见失分点:只答"每次刷新"当标准答案(没讲权衡);或不知道 MaxAge 同时控制 Cookie 和 Redis 的 TTL。

面试官
【进阶】 JWT 为什么"续期难"?无状态带来这个限制后,工程上一般怎么续期?
候选人

一句话结论:因为 JWT 无状态,服务端不存会话,没法像 Session 那样"悄悄把过期时间往后推",只能重新签发一个新 token。常见做法是快过期时服务端用原 Claims 重签一个返回,或引入 refresh token 机制。

展开:Session 续期是改 Redis 里一条 key 的 TTL,客户端无感;JWT 的过期时间写在 Payload 里、签名已固化,服务端改不了旧 token,只能签新的。折中:middleware 发现 exp 快到(如剩 <1 小时)就用同样 user_id 重签一个塞回 x-jwt-token 响应头,前端下次用新的。更规范的方案是长短 token——access token 短(常被验),refresh token 长(只在 access 过期时去换)。

面试官想听的关键词:无状态不可改旧 token、重新签发、响应头返回新 token、长短 token、refresh 端点。

常见失分点:以为"JWT 也能像 Session 一样服务端改过期时间"(错,签名固化);或说续期难就放弃 JWT(其实有成熟方案)。

面试官
【追问】 JWT 的 Signature 为什么能防篡改?HS256 和 RS256 有什么区别,分别适合什么场景?
候选人

一句话结论:签名是用算法 + secret 对 header.payload 算出的校验值,服务端验签时重算比对,不一致即说明被改过。HS256 对称(签发验签同 secret,适合自签自验);RS256 非对称(私钥签、公钥验,适合微服务)。

展开:防篡改原理像"骑缝章"——谁都能抄 Payload 明文,但改任何一个字符,重算的签名就和原签名对不上,验签失败。HS256 一把密钥通吃,简单但密钥到处放;RS256 用私钥签名、公钥验证,公钥泄露也伪造不出 token,所以认证中心持私钥、其它服务只拿公钥验,是微服务首选。

面试官想听的关键词:HMAC 重算比对、骑缝章类比、对称 vs 非对称、私钥签名公钥验、微服务用 RS256。

常见失分点:把"防篡改"说成"防偷看"(防偷看靠加密,JWT 默认不加密);或不知道 RS256 比 HS256 更适合多服务场景。

面试官
【追问】 K8s 的 Pod / Deployment / Service / Ingress 各自解决什么问题?Ingress 和 Service 的区别是什么?
候选人

一句话结论:Pod 是跑应用的最小单位(集装箱);Deployment 管 Pod 副本数(船长);Service 给一组 Pod 一个稳定访问入口并负载均衡(电话号);Ingress 按域名/路径把外部流量转发到不同 Service(总机)。Service 解决"Pod 变来变去怎么稳定访问",Ingress 解决"多个 Service 怎么按规则对外暴露"。

展开:Pod 可能因调度/重启换 IP,直接访问 Pod IP 不稳,所以抽象出 Service(ClusterIP 不变,背后动态挂活 Pod)。但当有多个 Service(webook、mysql…)要对外时,需要按域名/路径分流,这就是 Ingress——它更像"七层路由",而 Service 更像"四层负载均衡"。一句话:Service 转发到 Pod,Ingress 转发到 Service。

面试官想听的关键词:Pod 最小单位、Deployment 副本数、Service 稳定入口、Ingress 七层路由、转发层次不同。

常见失分点:把 Ingress 和 Service 混为一谈(说不清"Ingress 转发到 Service 而 Service 转发到 Pod"的层次);或不知道 Pod IP 会变才需要 Service。

面试官
【追问】 登录校验中间件挂全局后,公开接口(注册/登录)也被拦。放行公开接口有哪两种做法?各有什么优缺点?什么时候会 panic?
候选人

一句话结论:做法一在中间件里维护 path 白名单(map 精确匹配)命中即放行,简单但易漏且只支持精确路径;做法二用路由分组,公开/受保护分开注册,结构即规则、天然不易漏,但同一路径不能重复注册(gin 会 panic)。推荐做法二为主体。

展开:白名单法查找 O(1)、不依赖路由结构,但白名单和路由分离两处,新增公开接口容易忘记加,且前缀放行要自己写 HasPrefix。路由分组法把"要不要登录"直接体现在注册处,新增公开接口进 pub 组即可。panic 发生在做法二里同一路径在两个分组都注册(如 pub.POST("/login") 又 auth.POST("/login"),gin 报 handlers are already registered)。

面试官想听的关键词:map 白名单 O(1)、路由分组、结构即规则、同一路径重复注册 panic、两种结合。

常见失分点:只答一种做法;或不知道做法二重复注册会 panic;或把做法一的前缀匹配过宽(如 HasPrefix("/users") 误伤 /users/profile)。

面试官
【追问】 常见的限流算法有哪四种?固定窗口有什么缺陷,生产级组件一般用什么?
候选人

一句话结论:四种——固定窗口、滑动窗口、漏桶、令牌桶。固定窗口实现最简单但有"窗口边界突刺"(0.9s 和 1.1s 各来 100,中间 1 秒实际承受 200)。生产级网关(如 Sentinel)多用令牌桶或滑动窗口。

展开:固定窗口每秒一个计数器,实现最简但边界处可被"两波各打满"击穿;滑动窗口把窗口切片平滑边界;漏桶以固定速率流出、流量绝对平整但无法应对合理突发;令牌桶以固定速率放令牌、允许攒令牌应对突发。我们的 Redis INCR 限流就是最简固定窗口——够用,但理解它的边界突刺很重要。

面试官想听的关键词:固定窗口突刺、滑动窗口、漏桶平整、令牌桶突发、Sentinel 用令牌桶。

常见失分点:只背名字说不清各自优缺点;或不知道"我们的 Redis INCR 属于固定窗口"这一对应。


八、本章小结

学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回看对应小节。

复习提示:

可勾选清单(面试前 5 分钟扫一遍)

  • Redis 是什么:内存数据库、比 MySQL 快(数据在内存)、支持 string/hash/list/set/zset、天然 TTL 适合登录态。
  • Docker 三件套:镜像(模板)/ 容器(跑起来的实例)/ 仓库(存镜像处);docker run = 找镜像→建容器→跑命令→连网络。
  • 为什么需要 K8s:把重启/扩容/发布这些运维动作自动化(容器编排)。
  • 无状态:服务端不存会话记忆、凭证随请求走;是 JWT 适配分布式的根。
  • Session 多实例困境:单机内存→多实例记忆分家→用 Redis 当中央账本;store 接口让切换不动业务代码。
  • Session 刷新:四种策略,推荐固定间隔(1 分钟刷一次);MaxAge 同时控制 Cookie 和 Redis TTL;Secure+HttpOnly 必须开。
  • K8s 四件套:Pod(集装箱)/ Deployment(船长管副本)/ Service(电话号负载均衡)/ Ingress(总机按域名转发);template.labels 必须 = selector.matchLabels。
  • 端口三兄弟:port=Service 端口、targetPort=Pod 端口、nodePort=集群外端口。
  • JWT 三段:Header(算法)+ Payload(数据,Base64 不加密)+ Signature(防篡改);不放敏感数据;secret 必须保密。
  • JWT vs Session:JWT 无存储、分布式友好、快;Session 安全、易撤销。可混用:JWT 放 user_id,Redis 放 user.info:user_id。
  • JWT 续期难:无状态不能改旧 token,只能重签新 token 返回 / 用 refresh token。
  • 公开接口放行:白名单匹配(map 精确,易漏)vs 路由分组(推荐);同一路径重复注册会 panic。
  • 限流:基于 IP + Redis 集群计数;阈值靠压测;Redis 故障降级放行;四种算法(固定窗口/滑动窗口/漏桶/令牌桶)。
  • kubectl:apply 声明式、get pods 看状态、describe pod 查 Events、logs 看日志、delete -f 重来。
  • 部署流程:编译 Linux 二进制 → 构建镜像 → Deployment+Service+Ingress YAML → kubectl apply;多环境用编译标签 -tags=k8s 切换。

下一章我们将进入接口抽象、短信服务与测试(第 04 章)。你会看到本章写的 UserHandler、各种 middleware 如何被抽象成可替换的接口,以及"怎么给登录注册这类逻辑写单元测试"——毕竟上了 K8s 之后,靠手动 curl 验证就太低效了。

复习提示:给下一章留个伏笔:本章我们用 //go:build !k8s / //go:build k8s 两个编译标签切换 DBDSN 和 RedisAddr,这是"配置从哪来"最轻量的解法。但真实的配置往往还要支持多环境、热更新、密钥管理。下一章讲接口抽象时,我们会顺手把"配置来源"也收敛成一个可替换的依赖——这正是"面向接口编程"在更大尺度上的样子。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!