GO学习笔记 | 第八章节 JWT、Redis 入门与 K8s 部署实战| JWT 深化 代码仓库地址:Darling-123456/go_learning: go学习过程记录
核心内容 :系统保护(限流 + 登录安全增强)前置知识 :JWT 登录校验、Gin 中间件、Redis 基础
一、保护登录系统概述
功能做完之后,只要跟浏览器/终端用户打交道,就必须考虑一个问题:怎么保护好你的系统? 分两个方面:
正常用户会不会搞崩你的系统? ——按产品设计的正常用法,系统能不能撑住?
如果有人不按套路出牌(攻击者),你的系统能撑住吗?
对中小公司来说,第一条基本不是问题;大公司两条都要考虑。
最明显的漏洞 :注册和登录。任何人都可以注册/登录,攻击者用脚本构造高并发请求,就能把系统和数据库一起打崩。注册影响新用户接入,登录影响已有用户——这是非常关键的路径 ,必须保护好。
保护手段:限流 + 登录辅助信息校验 。
二、限流:限制请求数量
2.1 什么是限流? 核心思路:限制每个人发的请求数量 ,或者限制系统处理的请求速率 。你用脚本刷几千几万个请求?没关系,我根据处理能力来处理,超出的直接拒绝。
常见限流算法:滑动窗口、固定窗口、令牌桶、漏桶。
2.2 限流的基本问题
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) }
手写滑动窗口限流是高级工程师面试题,初级了解概念即可。
2.6 Gin 中接入限流 1 2 3 4 5 server.Use(ratelimit.NewBuilder(). SetRedis(redisClient). SetWindow(time.Minute, 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 package ratelimitimport ( _ "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 } 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 滑动窗口 local key = KEYS[1 ]local window = tonumber (ARGV[1 ])local threshold = tonumber ( ARGV[2 ])local now = tonumber (ARGV[3 ])local min = now - windowredis.call('ZREMRANGEBYSCORE' , key, '-inf' , min ) local cnt = redis.call('ZCOUNT' , key, '-inf' , '+inf' )if cnt >= threshold then return "true" else redis.call('ZADD' , key, now, now) redis.call('PEXPIRE' , key, window) return "false" end
面试的时候也可以说自己实现了基于redis的ip限流
1 2 3 4 5 import ( "github.com/gin-contrib/sessions/redis" rateLimitredis "github.com/redis/go-redis/v9" )
第一个 redis(github.com/gin-contrib/sessions/redis) 是 Gin session 中间件的 Redis 存储后端 。当用户登录后,服务器会把 session 数据(比如 session ID、user ID、登录状态、过期时间等)序列化后存到 Redis 里。 你代码里的 redis.NewStore 就是创建这个存储实例,之后 sessions.Sessions("mysession", store) 会用它在 Redis 中读写 session,实现登录态保持。
第二个 rateLimitredis(github.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 func initWebServer () *gin.Engine { server := gin.Default() redisClient := rateLimitredis.NewClient(&rateLimitredis.Options{ Addr: "localhost:6379" , }) server.Use(ratelimit.NewBuilder(redisClient, time.Second, 100 ).Build()) 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 , ExposeHeaders: []string {"x-jwt-token" }, MaxAge: 12 * time.Hour, })) 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()) 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 local key = KEYS[1 ]redis.call('ZREMRANGEBYSCORE' , key, '-inf' , min ) local cnt = redis.call('ZCOUNT' , key, '-inf' , '+inf' )if cnt >= threshold then return "true" else redis.call('ZADD' , key, now, now) 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 本身。
三、登录安全增强:利用辅助信息
3.1 问题:Token 泄露怎么办?
解决方案 :在登录校验时,进一步判断用这个 token 的人是不是原本登录的那个人 。
高级做法:二次验证(短信、邮件、MFA 验证码)
简单做法:登录辅助信息
3.2 登录辅助信息
登录时记录额外信息,校验时比较是否匹配。
可用的辅助信息:
User-Agent (浏览器类型,对应 HTTP 的 User-Agent 头)
浏览器指纹(时区、语言、屏幕分辨率等,前端采集编码后传给后端)
硬件信息(APP 端常用)
IP 归属地(需查询服务商)
为什么不能用 IP? ——IP 经常变,尤其是移动网络。一小时前在 A 商场,一小时后在 B 商场,IP 肯定不同。
3.3 实现:登录时记录 User-Agent User-Agent (浏览器类型,对应 HTTP 的 User-Agent 头)
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(), } 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) { if claims.UserAgent != ctx.Request.UserAgent() { ctx.AbortWithStatus(http.StatusUnauthorized) return } ctx.Next() } }
补充 :
浏览器升级会导致 UA 变化,可以只匹配 UA 的一部分(不完全匹配)
正常用户换设备会重新登录,不会出现 UA 不一致
UA 不匹配 ≠ 一定是攻击,但应该加监控
真正要做得完善,需要前端尽可能多地采集设备/浏览器信息,打包传后端。
四、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 } 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() 终结