GO学习笔记 | 第八章节 JWT、Redis 入门与 K8s 部署实战| JWT 深化

代码仓库地址:Darling-123456/go_learning: go学习过程记录

核心内容:系统保护(限流 + 登录安全增强)
前置知识:JWT 登录校验、Gin 中间件、Redis 基础


一、保护登录系统概述

image-20260627183821858

功能做完之后,只要跟浏览器/终端用户打交道,就必须考虑一个问题:怎么保护好你的系统? 分两个方面:

  1. 正常用户会不会搞崩你的系统?——按产品设计的正常用法,系统能不能撑住?
  2. 如果有人不按套路出牌(攻击者),你的系统能撑住吗?

对中小公司来说,第一条基本不是问题;大公司两条都要考虑。

最明显的漏洞:注册和登录。任何人都可以注册/登录,攻击者用脚本构造高并发请求,就能把系统和数据库一起打崩。注册影响新用户接入,登录影响已有用户——这是非常关键的路径,必须保护好。

image-20260627183857226

保护手段:限流 + 登录辅助信息校验


二、限流:限制请求数量

2.1 什么是限流?

核心思路:限制每个人发的请求数量,或者限制系统处理的请求速率。你用脚本刷几千几万个请求?没关系,我根据处理能力来处理,超出的直接拒绝。

常见限流算法:滑动窗口、固定窗口、令牌桶、漏桶。

image-20260627183946217

2.2 限流的基本问题

image-20260627184014415

image-20260627184032727

image-20260627184131197

2.3 为什么用 Redis 做限流?

1
2
3
4
5
6
7
用户请求 → 负载均衡 → 实例A  ← 本地计数,只有实例A知道
→ 实例B ← 不知道实例A已经计了多少

↓ 解决

用户请求 → 负载均衡 → 实例A ─┐
→ 实例B ─┤→ Redis(统一计数)

单体应用多实例部署时,用户请求不一定落到同一台机器上,必须用 Redis 做集中式计数。

2.4 滑动窗口的并发陷阱

1
2
3
4
5
6
7
8
9
10
11
12
假设一分钟限 3 个请求,当前时间 01:02,窗口 = [00:02, 01:02]

时间轴上 4 个请求:00:00, 00:01, 00:02, 00:03
↑ ↑
这两个不在窗口内,移除
窗口内剩:00:02, 00:03(2个请求)

同一时刻 2 个请求同时到达:
请求A:窗口有 2 个 → 还能处理 → 通过 → 记入窗口(3个)
请求B:窗口有 2 个 → 还能处理 → 通过 → 记入窗口(4个!)

你实际处理了 4 个请求,限流失效!

这是典型的**「检查再做某事」并发场景**——必须做成原子操作

为什么本地锁没用?

1
2
实例A ──加锁──→ 锁住了A的进程
实例B ────────→ 完全不受影响,照样处理

多实例部署下,只能用分布式锁跨进程竞争同一把锁。

业务开发里本地锁基本用不上,要么无锁实现最终一致性,要么分布式锁。

2.5 Redis 限流实现(了解即可)

1
2
3
4
5
6
7
8
// 核心思路(伪代码)
func (l *RedisSlidingWindowLimiter) Allow(key string) bool {
now := time.Now()
windowStart := now.Add(-l.window) // 窗口起始时间
// 1. 删除窗口外的记录
// 2. 统计窗口内的请求数
// 3. 如果 < 阈值,记入新请求,返回 true;否则 false
}

手写滑动窗口限流是高级工程师面试题,初级了解概念即可。

2.6 Gin 中接入限流

1
2
3
4
5
// 全局限流中间件
server.Use(ratelimit.NewBuilder().
SetRedis(redisClient).
SetWindow(time.Minute, 100). // 每分钟 100 次
Build())

2.7 项目中使用的限流

路径:pkg/ginx/middlewares/ratelimit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
//builder.go
package ratelimit

import (
_ "embed"
"fmt"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"log"
"net/http"
"time"
)

type Builder struct {
prefix string
cmd redis.Cmdable
interval time.Duration
// 阈值
rate int
}

//go:embed slide_window.lua
var luaScript string

func NewBuilder(cmd redis.Cmdable, interval time.Duration, rate int) *Builder {
return &Builder{
cmd: cmd,
prefix: "ip-limiter",
interval: interval,
rate: rate,
}
}

func (b *Builder) Prefix(prefix string) *Builder {
b.prefix = prefix
return b
}

func (b *Builder) Build() gin.HandlerFunc {
return func(ctx *gin.Context) {
limited, err := b.limit(ctx)
if err != nil {
log.Println(err)
// 这一步很有意思,就是如果这边出错了
// 要怎么办?
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
if limited {
log.Println(err)
ctx.AbortWithStatus(http.StatusTooManyRequests)
return
}
ctx.Next()
}
}

func (b *Builder) limit(ctx *gin.Context) (bool, error) {
key := fmt.Sprintf("%s:%s", b.prefix, ctx.ClientIP())
return b.cmd.Eval(ctx, luaScript, []string{key},
b.interval.Milliseconds(), b.rate, time.Now().UnixMilli()).Bool()
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//slide_window.lua 滑动窗口
-- 1, 2, 3, 4, 5, 6, 7 这是你的元素
-- ZREMRANGEBYSCORE key1 0 6
-- 7 执行完之后

-- 限流对象
local key = KEYS[1]
-- 窗口大小
local window = tonumber(ARGV[1])
-- 阈值
local threshold = tonumber( ARGV[2])
local now = tonumber(ARGV[3])
-- 窗口的起始时间
local min = now - window

redis.call('ZREMRANGEBYSCORE', key, '-inf', min)
local cnt = redis.call('ZCOUNT', key, '-inf', '+inf')
-- local cnt = redis.call('ZCOUNT', key, min, '+inf')
if cnt >= threshold then
-- 执行限流
return "true"
else
-- 把 score 和 member 都设置成 now
redis.call('ZADD', key, now, now)
redis.call('PEXPIRE', key, window)
return "false"
end

面试的时候也可以说自己实现了基于redis的ip限流

1
2
3
4
5
// main.go里面跟着改
import (
"github.com/gin-contrib/sessions/redis"
rateLimitredis "github.com/redis/go-redis/v9" //用于限流的别名,这两个redis重名了
)
  • 第一个 redisgithub.com/gin-contrib/sessions/redis
    是 Gin session 中间件的 Redis 存储后端。当用户登录后,服务器会把 session 数据(比如 session ID、user ID、登录状态、过期时间等)序列化后存到 Redis 里。
    你代码里的 redis.NewStore 就是创建这个存储实例,之后 sessions.Sessions("mysession", store) 会用它在 Redis 中读写 session,实现登录态保持。
  • 第二个 rateLimitredisgithub.com/redis/go-redis/v9
    通用的 Redis 客户端,你拿它来给限流中间件提供连接,用于记录和统计请求次数。

它们两个包名默认都叫 redis,所以在同一个文件里同时直接用会冲突。
通过给通用客户端起别名 rateLimitredis 已经解决了这个问题,很正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// main.go里面跟着改
func initWebServer() *gin.Engine {
server := gin.Default()
//改动就是加了下面这行
redisClient := rateLimitredis.NewClient(&rateLimitredis.Options{
Addr: "localhost:6379",
})
//第二个和第三个参数是在多少时间内允许多少请求
server.Use(ratelimit.NewBuilder(redisClient, time.Second, 100).Build())
// CORS 配置,允许前端跨域请求
server.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
//不加这个前端拿不到x-jwt-token
ExposeHeaders: []string{"x-jwt-token"},
MaxAge: 12 * time.Hour,
}))
//步骤一
//store := cookie.NewStore([]byte("your-secret-key"))
store, err := redis.NewStore(16, "tcp",
"localhost:6379", "root", "",
[]byte("qOYZLAuWmwkxAKG6bijwru9ghNNS9rHc"),
[]byte("8Mv11Olt6x3DX97rUE1exp9XISEMSZJl"))
if err != nil {
panic(err)
}
server.Use(sessions.Sessions("mysession", store))
server.Use(middleware.NewLoginJWTMiddlewareBuilder().
IgnorePaths("/users/signup").
IgnorePaths("/users/login").Build())
/*
//步骤三 链式调用
server.Use(middleware.NewLoginMiddlewareBuilder().
IgnorePaths("/users/signup").
IgnorePaths("/users/login").Build())
*/
return server
}

1. 这个限流限制的是什么?

限制的是用户对网站接口发起的 HTTP 请求,不是限制对 Redis 的访问。

看代码:

  • func (b *Builder) Build() gin.HandlerFunc 返回的是 Gin 的中间件,它会拦截每个进入服务器的 HTTP 请求。
  • Build() 里,先执行 b.limit(ctx),如果返回 limited == true,就直接 ctx.AbortWithStatus(http.StatusTooManyRequests),返回 429 状态码,根本不会让请求到达后面的业务逻辑。
  • 所以限制的对象是你的 Web 接口(比如 /users/login/users/signup 等)。

2. Redis 到底干了个啥?就是计数吗?

对,Redis 在这里就是跨服务器的统一计数器,负责记录每个 IP 的请求时间窗口内的调用次数,并判断是否超限。

具体来看 Lua 脚本做的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 1. 用客户端 IP 拼成 key(如 "ip-limiter:192.168.1.10")
local key = KEYS[1]

-- 2. 清理窗口外的旧记录(删除 < min 的时间戳)
redis.call('ZREMRANGEBYSCORE', key, '-inf', min)

-- 3. 统计窗口内还剩多少个时间戳(即有效请求数)
local cnt = redis.call('ZCOUNT', key, '-inf', '+inf')

-- 4. 比较计数和阈值
if cnt >= threshold then
return "true" -- 超限,拒绝
else
redis.call('ZADD', key, now, now) -- 将当前时间戳写入 ZSET
redis.call('PEXPIRE', key, window) -- 设个过期时间,自动清理不用一直占内存
return "false" -- 通过
end

Redis 里存的是一个有序集合(ZSET),:

  • key:ip-limiter:客户端IP
  • member 和 score 都是请求发生的毫秒时间戳

每次请求来,这个 Lua 脚本会原子性地完成“删旧数据 → 统计 → 判断 → 写入”这一套动作,所以不会出现你前面笔记里提到的“检查完再加导致超限”的并发问题。

3. 为什么要用 Redis,而不是在 Gin 内部用 Go 的变量计数?

你的 limit 方法里用 ctx.ClientIP() 作为限流对象,如果你的服务部署了多个实例,同一个 IP 的请求可能落到不同机器上。用 Redis 做集中存储,才能保证所有实例看到的是同一份计数

1
2
实例A ──┐
实例B ──┤→ Redis(统一计数)

一句话总结:
这段代码用 Redis 的 ZSET + Lua 脚本,实现了基于客户端 IP 的滑动窗口限流,保护你的 Web 接口不被高频请求打垮,而不是限制 Redis 本身。


三、登录安全增强:利用辅助信息

image-20260628140550281

3.1 问题:Token 泄露怎么办?

image-20260628165433523

解决方案:在登录校验时,进一步判断用这个 token 的人是不是原本登录的那个人

  • 高级做法:二次验证(短信、邮件、MFA 验证码)

  • 简单做法:登录辅助信息

    image-20260628165605981

3.2 登录辅助信息

image-20260628165816879

登录时记录额外信息,校验时比较是否匹配。

可用的辅助信息:

  • User-Agent(浏览器类型,对应 HTTP 的 User-Agent 头)
  • 浏览器指纹(时区、语言、屏幕分辨率等,前端采集编码后传给后端)
  • 硬件信息(APP 端常用)
  • IP 归属地(需查询服务商)

为什么不能用 IP?——IP 经常变,尤其是移动网络。一小时前在 A 商场,一小时后在 B 商场,IP 肯定不同。

3.3 实现:登录时记录 User-Agent

User-Agent(浏览器类型,对应 HTTP 的 User-Agent 头)

image-20260628170115158

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (u *UserHandler) LoginJWT(ctx *gin.Context) {
// ... 校验邮箱密码 ...

claims := UserClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
},
Uid: user.Id,
UserAgent: ctx.Request.UserAgent(), // ← 记录登录时的 UA
}

token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims)
tokenStr, _ := token.SignedString([]byte("密钥"))

ctx.Header("x-jwt-token", tokenStr)
ctx.String(http.StatusOK, "登录成功")
}

3.4 中间件中比较 User-Agent

1
2
3
4
5
6
7
8
9
10
11
12
13
func (l *LoginJWTMiddlewareBuilder) Build() gin.HandlerFunc {
return func(ctx *gin.Context) {
// ... 验签,拿到 claims ...

// 比较 User-Agent
if claims.UserAgent != ctx.Request.UserAgent() {
// 严重安全问题!应该加监控告警
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
ctx.Next()
}
}

补充

  • 浏览器升级会导致 UA 变化,可以只匹配 UA 的一部分(不完全匹配)
  • 正常用户换设备会重新登录,不会出现 UA 不一致
  • UA 不匹配 ≠ 一定是攻击,但应该加监控

真正要做得完善,需要前端尽可能多地采集设备/浏览器信息,打包传后端。

image-20260628171709063

image-20260628172226006


四、Builder 模式

Builder 模式用于复杂对象的构建。核心结构:

1
2
3
4
5
type Builder struct { /* 配置字段 */ }

func NewBuilder() *Builder { return &Builder{} } // 构造函数
func (b *Builder) SetXxx(v) *Builder { b.xxx = v; return b } // 中间方法(返回*b,保证链式调用)
func (b *Builder) Build() Target { /* 产出最终对象 */ } // 终结方法
1
2
3
4
5
// 使用示例
b := NewBuilder().
SetKey("xxx").
SetWindow(time.Minute, 100).
Build()

三个角色:

角色 说明
构造函数 创建 Builder 实例
中间方法 return b 保持链式调用,逐步配置
终结方法(Build) 结束链,返回最终产物

五、核心概念速查

概念 一句话
限流 限制请求数量/速率,超出直接拒绝
限流对象 用 IP,APP 端可用设备序列号
限流阈值 压测得出(面试答案),经验值:每秒 100
Redis 限流 多实例部署必须用 Redis 统一计数
滑动窗口并发问题 「检查再做某事」模式,需原子操作 / 分布式锁
登录辅助信息 登录时记录 User-Agent 等,校验时比对,防 token 泄露
User-Agent HTTP 请求头,标识浏览器类型
Builder 模式 链式调用配置复杂对象,Build() 终结