写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 03 章。上一章(第 02 章)你已经做出了一个能在本地跑起来的单机注册登录服务——它用 cookie 把登录态存在内存里。
但这一章是全书第一个真正的断崖:原文直接上 Kubernetes,而你可能从没接触过 Redis 和 K8s。为了避免你在这里"必崩",这一版做了三件额外的事:
- 就地补齐六颗补课胶囊:Redis 是什么、Docker 三件套、为什么需要 K8s、K8s 四大核心概念、kubectl 常用命令、什么是"无状态"。每一个都当场讲,不让你去搜。
- 把"为什么需要 JWT"讲成一条完整的问题演进线:单机 Session → 多实例被踢下线 → 用 Redis 共享 → 再引出无状态方案 JWT,让你理解每一步的来由,而不是硬塞结论。
- 每一步都给出可验证的预期输出。涉及 K8s / docker 的命令,我标了"示例输出"和你能确认什么;不确定的数值我不编造。
如果你想要"高密度速查版",可以看原版第 03 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
上一章你做了一个单机的注册登录服务:用户登录后,我们把"你是谁"记在服务进程自己的内存里(这就是 Session),下次请求带着 cookie 来,我们就从内存里认出他。这在只有一台服务器时工作得很好。
但真实项目不会只跑一台机器。当流量变大、或者你想让一台挂了服务也不中断,你得启动多个实例,再用一个"负载均衡"把用户请求分给它们。问题就在这里爆发了:用户在 A 机器登录,登录态记在 A 的内存里;可下一次请求被分到 B 机器,B 的内存里没有他的登录态——他莫名其妙被踢回登录页。
这一章要解决的就是这个"多实例登录态"的难题,并顺手把服务连同数据库一起搬进 Kubernetes(一个自动管理容器集群的平台),让它具备扩缩容、故障自愈、滚动发布的能力。我们会走两条技术路线:
- Redis 共享 Session:把登录态从"每家自己记"改成"所有人查同一个中央账本"——先解决多实例的问题。
- 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"的推进顺序。
二、本章知识点大纲
先扫一眼地图,知道这一章往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 用 docker-compose 起一个 Redis,并用
redis-cli验证连通(PONG)。 - 把第 02 章的 cookie Session 换成 Redis Session,理解
sess_id在 cookie、数据在 Redis 的分工。 - 在 middleware 里实现 Session 过期固定间隔刷新(如 1 分钟内只刷一次)。
- 用
github.com/golang-jwt/jwt/v5签发一个 JWT,并在 middleware 里验签 + 解析 + 校验 User-Agent。 - 写一个 基于 Redis 的 IP 限流 middleware,并在 Redis 故障时降级放行。
- 写一份
k8s-webook-deployment.yaml+Service+Ingress,用kubectl apply把 webook 跑进 K8s。
理解(能讲清原理和取舍,不要求背代码)
- Session 多实例困境的演进逻辑:单机内存 → 多实例记忆分家 → 共享存储。
- JWT 三段结构(Header / Payload / Signature)各自是什么、为什么签名能防篡改、为什么 Payload “只编码不加密”。
- JWT vs Session 的优缺点对比(存储、分布式、撤销、安全、续期)。
- 限流的思路和对象:为什么用 IP、为什么用 Redis 集中计数、为什么 Redis 故障要降级。
- K8s 四件套(Pod / Deployment / Service / Ingress)各自解决什么问题。
- “无状态” 的含义——这是理解 JWT 为什么能适应分布式的根。
了解(知道有这回事,面试能说出名字和一句话定位)
- Redis 的数据结构(string / hash / list / set / zset)以及它为什么比 MySQL 快。
- Docker 三件套:镜像 / 容器 / 仓库的区别。
- K8s 的 PV / PVC / StorageClass 持久化抽象;Service 的四种 type(ClusterIP / NodePort / LoadBalancer / ExternalName)。
- 四种限流算法:固定窗口 / 滑动窗口 / 漏桶 / 令牌桶。
- 编译标签
//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 建议精读并跟着敲。
三、正文
本章正文很长,但逻辑是一条线:先补课(把没见过的工具讲清楚)→ 再用这些工具解决多实例登录态问题 → 最后顺手把系统保护做掉、把服务部署上 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(命令略)
为什么差这么多? 关键在"数据住哪":
| 维度 | MySQL | Redis |
|---|---|---|
| 数据主要存放 | 硬盘(即使 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 一条命令到底在干什么? 拆开看就四步:
- 找镜像:本地有没有
redis:7?没有就先去仓库拉。 - 建容器:按镜像的模板,在宿主机上创建一个隔离的运行环境(有自己的文件系统、网络、进程空间)。
- 执行命令:运行镜像里
ENTRYPOINT/CMD指定的程序(Redis 就是启动redis-server)。 - 连网络:
-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 盏灯亮,坏一盏立刻补一盏。
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 起来了、网络通了。
3.5.5 用 Redis Store 替换 cookie store
接下来把 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 字节 |
| 15 | redis.NewStore(16, ...) | 16 是 Redis 连接池大小;"tcp" 是网络协议 |
| 16 | "localhost:6379" | 本地 Redis 地址;生产换成 Redis Service 名 |
| 17 | 空串密码 | 测试环境;生产必须填真实密码 |
| 19-21 | err 处理 | Redis 连不上直接 panic,开发阶段早发现早修 |
为什么"面向接口"在这里值钱:Gin Session 的 store 是接口类型 sessions.Store。我们只要把原来返回 cookie store 的函数,改成返回 redis store,业务里的 sessions.Default(c).Get("user_id") 完全不用动。设计自己的系统时也要常问:将来会不会换实现?会,就抽象成接口。
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 --> N3.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 对不上、端口配错 |
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 --> T3.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。
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-19 | UserClaims 嵌入 RegisteredClaims | 标准声明(exp/iat/nbf)由库管理,我们只加业务字段 |
| 27-33 | 绑定 + 校验 | 和 Session 版登录完全一致,区别在"成功后做什么" |
| 36-42 | 填 Claims | user_id 是身份核心;User-Agent 是后面防冒用用的 |
| 44-45 | NewWithClaims + SignedString | 库帮我们算签名、拼成三段字符串 |
| 49 | c.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 | 路由注册处一目了然 |
| 适用场景 | 已有全局中间件、不便重整 | 新项目/愿整理路由(推荐) |
⚠️ 两个新手必踩的坑:
- 做法二同一路径不能注册两次——
pub.POST("/login", ...)之后再auth.POST("/login", ...)会 panic(gin 报 handlers are already registered)。一个路径只进一个组。 - 做法一配前缀匹配别过宽——
strings.HasPrefix(path, "/users")会把/users/profile一起放行,等于登录校验全失效。前缀白名单必须精确到"仅公开的那段"。
实践中两种可结合:主体用路由分组,个别历史遗留路径用白名单兜底。
3.7.10 加餐:JWT 常见坑合集
把新手最常在 JWT 上栽的跟头集中列一遍,免得你上线后踩雷。
my-jwt-secret 这种短密钥分分钟被暴力破解;写死在代码里还可能被提交到 git。正确做法:用足够长的随机串 + 从环境变量 / Secret 读取。exp 设成"7 天"甚至"不过期"。一旦 token 泄露,攻击者有整整一周的窗口冒充你。正确做法:access token 短(15 分钟 ~ 24 小时),靠 refresh token 续期。localStorage 不防 XSS:前端把 token 存 localStorage,任何被注入的脚本都能 localStorage.getItem 偷走。高安全场景建议存 HttpOnly Cookie(JS 读不到),或至少对前端做严格的 XSS 防护。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/秒足够。
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 容器起来了、端口通了。
步骤 2 · 把 cookie Session 换成 Redis Session
目标: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 安全(六条红线)
- 不放敏感信息:Payload 默认只编码不加密,密码 / 手机号 / 身份证绝对不能进 Payload。
- secret 保密:泄露后所有 token 可被伪造。用足够长的随机串,从配置 / Secret 读取,别硬编码。
- 设短过期时间:泄露风险窗口小(如 15 分钟 ~ 24 小时,按业务定)。
- User-Agent 校验:增强安全性,token 被盗换设备也拦得住。
- HTTPS 必备:防止 token 被中间人截获。
- 撤销难是 JWT 硬伤:需要黑名单机制(用 Redis 记录被撤销的
jti)。
5.3 限流实践(五条)
- 集群限流用 Redis:单机限流在多实例下各计各的,不准。
- 限流阈值靠压测:整体阈值靠压测得到,单 IP 阈值靠经验(如 100/秒)。
- 限流后返回标准错误:
429 Too Many Requests。 - Redis 故障要降级:限流组件故障不能影响主流程,可临时放行(
c.Next())。 - 关键接口都要限流:尤其是免登录接口(注册、登录、短信)。
5.4 K8s 实践(六条)
- 生产禁止用 AutoMigrate:DDL 走 SQL review 流程,别让程序自己改表结构。
- 配置走 ConfigMap / Secret:不要硬编码在镜像里(密钥进 Secret,不进 git)。
- 资源限制:每个容器设
resources.limits,防止单个 Pod 吃光节点资源。 - 健康检查:配
livenessProbe和readinessProbe,K8s 自动重启异常 Pod、发布时只把流量给就绪的。 - 多副本:生产至少 2 个副本,避免单点。
- 镜像版本:用具体 tag(如
v0.0.1),不要用latest,避免不可控变化。
dev.go / k8s.go)用编译标签切换配置是最轻量的做法;但真实项目配置会更复杂(多环境、热更新),第 04 章讲接口抽象与测试时,我们会把"配置从哪来"也抽象成可替换的部分。六、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 第 02 章的 cookie Session 在单机上工作正常,为什么上了多实例用户会被踢回登录页?Redis Session 又是怎么解决"记忆分家"的?
- JWT 的三段(Header / Payload / Signature)各自是什么、
xxxxx.yyyyy.zzzzz是怎么拼出来的?为什么说 Payload “只编码不加密”? - JWT 的 Payload 里能放密码吗?为什么?如果业务真要在登录态里携带昵称、角色,该怎么设计?
- 基于 Redis 的 IP 限流,为什么不能在 Redis 出错时一律拒绝?正确的降级策略是什么?
- K8s 的
port/targetPort/nodePort三者分别作用于哪一段?Deployment 的template.labels和selector.matchLabels不一致会发生什么? - 登录校验中间件挂到全局后,注册、登录这些公开接口也被拦截了。放行公开接口有哪两种做法?各有什么优缺点?
- Session 过期刷新有哪几种策略?为什么本书推荐"固定间隔刷新"?
- JWT 为什么"撤销难"?如果要让一个已签发的 JWT 立刻失效,工程上一般怎么做?
动手练习(建议真做一遍):
- 拆 JWT:用 jwt.io 粘贴你签发的 token,确认能直接看到 Payload 里的
user_id;再故意改 Signature 一段里一个字符,看验签为何失败,体会"签名防篡改"。 - 切到 Redis Session:把本地项目 cookie store 换成 redis store,起两个不同端口的实例 + 一个简单轮流访问两个端口的脚本,验证登录态不再丢失(不再被踢下线)。
- 本地起一套 K8s:在 Docker Desktop 开启 Kubernetes,依次
applyMySQL / Redis / webook 的 YAML,用kubectl get pods确认全 Running,并通过 Ingress 域名(如webook.local)访问接口;进阶:手动删一个 webook Pod,观察 Deployment 是否自动补回。
七、模拟面试问答
这一节模拟真实面试。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。共 12 题,分基础 4 / 进阶 4 / 追问 4。
一句话结论:单机时 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)。
一句话结论: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)。
一句话结论:不能。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 计数出错就统统拒绝,Redis 一抖会直接打挂全站登录。正确降级是 Redis 出错时 c.Next() 放行。
展开:限流是"保护系统"的辅助手段,不是主业务。它的故障不应比"被攻击"更严重。代码在 Incr 返回 err 时直接 c.Next(),让请求正常通过,把限流暂时交给"下次 Redis 恢复"。这是典型的"fail open(故障时开放)“策略——和"fail closed(故障时拒绝)“相反,限流场景选 open 更稳。
面试官想听的关键词:限流组件不阻塞主流程、fail open 降级、Redis 抖动不影响登录、429 与放行权衡。
常见失分点:反着答"Redis 挂了就该拒绝所有请求保护系统”(这是把限流组件变成单点故障);或说不清"为什么降级要放行而不是排队”。
一句话结论:Session 胜在安全(数据在后端)和易撤销(直接删);JWT 胜在无存储、分布式天然友好、解析快。选型看三问:要不要主动注销、要不要放敏感数据、是不是微服务。
展开:Session 需要服务端存(Redis),每次请求多一次存储访问,但数据不在客户端、要踢人直接删 key;JWT 服务端不存,任意实例拿到 token 都能独立验,但 Payload 明文易泄露、且无法直接撤销(除非维护黑名单)。实际选型:单体且要主动注销→Session;微服务且性能敏感→JWT;要放敏感数据→Session+Redis;既要分布式又要敏感数据→JWT(user_id)+Redis(user.info)。
面试官想听的关键词:Session 易撤销、JWT 无状态、分布式友好、撤销难、按场景混用、黑名单。
常见失分点:说"JWT 比 Session 好所以都用 JWT"(忽略撤销难和 Payload 泄露);或只背表格不给出"怎么选型"的判断框架。
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"。
一句话结论:四种——每次访问刷新、快过期才刷新、固定间隔刷新、长短 token。推荐固定间隔(如 1 分钟内首次访问刷一次),因为它折中了体验与性能。
展开:每次刷新体验最好但 Redis 压力大;快过期刷新性能好但用户可能刷新后再也不来、白刷且仍有极小窗口被踢;固定间隔用"距上次刷新超过 N 秒才刷"把写 Redis 频率压到可接受范围,又保证活跃用户不被踢。长短 token(access token 短、refresh token 长)体验最佳但实现最复杂,留给后续课程。
面试官想听的关键词:四种策略、体验 vs 性能权衡、固定间隔、长 token 短 token、MaxAge 双层含义。
常见失分点:只答"每次刷新"当标准答案(没讲权衡);或不知道 MaxAge 同时控制 Cookie 和 Redis 的 TTL。
一句话结论:因为 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(其实有成熟方案)。
一句话结论:签名是用算法 + secret 对 header.payload 算出的校验值,服务端验签时重算比对,不一致即说明被改过。HS256 对称(签发验签同 secret,适合自签自验);RS256 非对称(私钥签、公钥验,适合微服务)。
展开:防篡改原理像"骑缝章"——谁都能抄 Payload 明文,但改任何一个字符,重算的签名就和原签名对不上,验签失败。HS256 一把密钥通吃,简单但密钥到处放;RS256 用私钥签名、公钥验证,公钥泄露也伪造不出 token,所以认证中心持私钥、其它服务只拿公钥验,是微服务首选。
面试官想听的关键词:HMAC 重算比对、骑缝章类比、对称 vs 非对称、私钥签名公钥验、微服务用 RS256。
常见失分点:把"防篡改"说成"防偷看"(防偷看靠加密,JWT 默认不加密);或不知道 RS256 比 HS256 更适合多服务场景。
一句话结论: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。
一句话结论:做法一在中间件里维护 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,这是"配置从哪来"最轻量的解法。但真实的配置往往还要支持多环境、热更新、密钥管理。下一章讲接口抽象时,我们会顺手把"配置来源"也收敛成一个可替换的依赖——这正是"面向接口编程"在更大尺度上的样子。