Go 企业级工程能力实战(8):Redis 不只是缓存——分布式限流、在线心跳与分布式锁从零实现

一、开篇:当 3 个 Pod 同时限流一个人的时候

故事从一次生产事故开始。

我们的 Go 微服务上线了,跑了 3 个 Pod 副本。限流器用的是内存方案------一个 map[string]int 记录每个 IP 的请求次数,每分钟清理一次。QA 测试一切正常:单 IP 超过 10 次/分钟就返回 429。

然而上线第一天,用户投诉:"为什么我被限流了?我这分钟才发了 5 个请求!"

排查后发现:用户的 5 个请求被负载均衡分发到了 3 个不同的 Pod 。Pod-1 收到 2 个,Pod-2 收到 2 个,Pod-3 收到 1 个。每个 Pod 的 map 里这个 IP 的计数都是 2,没有一个超过 10。按理说不该被限流。

那为什么还是被限流了?因为第 6、7、8 个请求也被随机分发,其中一个 Pod 恰好收到了集中的 4 个请求,累计到了 11。它不知道其他 Pod 也"贡献"了一部分。

这就是内存限流在多实例环境下的根本缺陷:状态不共享。

你可能会想:"那把限流阈值调高就行了嘛!" 如果原来阈值是 10,3 个 Pod 就调到 30。但 Pod 可能会被 HPA 扩到 5 个甚至 10 个。阈值怎么定?调到 100?那单个 Pod 被打穿的概率就会急剧升高。

真正的问题不是阈值大小,而是 "分布式系统中的所有全局决策,都需要一个全局视角"

Redis 正好提供了这个全局视角。


二、概念铺垫:Redis 在微服务架构中的两个角色

2.1 Redis 不只是缓存

很多开发者对 Redis 的认知停留在"用 GET/SET 做缓存加速"。但实际上,Redis 在微服务架构中有三个核心身份:

身份 关键能力 示例
缓存 减轻数据库压力 GET user:123 → JSON
计数器 原子递增 + 过期 INCR rate:192.168.1.1 + EXPIRE
状态存储 带 TTL 的键值 SETEX user:online:456 "1" 300

第二个身份------原子计数器 ------是分布式限流的基础。第三个身份------带 TTL 的状态存储------是在线心跳的基础。

2.2 原子性为什么重要?

对于限流来说,每一次请求确认需要一个"检查并递增"的操作。在单机内存里,我们这样写:

go 复制代码
ml.mu.Lock()
v.count++
if v.count > ml.rate { return false }
ml.mu.Unlock()

因为有互斥锁保护,这个操作是原子的。

但在 Redis 里,INCR 命令本身就是原子的------它不需要客户端加锁。为什么?因为 Redis 是单线程事件循环模型,所有命令串行执行。INCR 这条命令从接收到返回结果,中间不会有其他命令插队。

更有趣的是:INCR + EXPIRE 的组合不是原子的。如果 INCR 成功,但 EXPIRE 之前服务器宕机了,这个 key 就永远不会过期,内存泄漏。

所以正确做法是:第一次 INCR 返回 count=1 时立即设置 EXPIRE,后续 INCR(count>1)不再设置(因为 key 已经有过期时间了)。

2.3 在线心跳:为什么需要 Redis?

"用户是否在线"这个信息的特性是:

  • 高时效:5 分钟前的在线状态没有意义
  • 高写入频率:每次请求都要刷新一次心跳
  • 允许有损:偶尔掉一拍在线状态问题不大

这些特性天然匹配 Redis 的 SETEX(SET + EXPIRE 合二为一)。数据库的行锁和磁盘 I/O 对这种高频写入是灾难性的。


三、代码实战:三层抽象的设计

3.1 Limiter 接口:面向接口编程的第一步

pkg/ratelimit/limiter.go:1-5

go 复制代码
package ratelimit

type Limiter interface {
    Allow(key string) bool
}

就 3 行代码。但这是面向接口编程 的典范------不管底层是内存 map 还是 Redis Cluster,对调用方来说,都只有 Allow(key string) bool 这一个方法。

service/middleware.go:131-143 中,限流中间件完全不知道底层实现:

go 复制代码
func RateLimitMiddleware(rl ratelimit.Limiter) gin.HandlerFunc {
    return func(c *gin.Context) {
        ip := c.ClientIP()
        if !rl.Allow(ip) {
            c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
                "code": constant.ERROR_PARAM_ERR,
                "msg":  "too many requests",
            })
            return
        }
        c.Next()
    }
}

3.2 内存限流器:单机时代的遗产

pkg/ratelimit/memory.go:1-61

go 复制代码
type memoryLimiter struct {
    mu       sync.Mutex
    visitors map[string]*visitor
    rate     int
    window   time.Duration
}

type visitor struct {
    count    int
    lastSeen time.Time
}

内存限流器的核心数据结构是一个 map[string]*visitor,用互斥锁保护。关键逻辑:

清理 goroutinememory.go:26-38):

go 复制代码
go func() {
    ticker := time.NewTicker(window)
    defer ticker.Stop()
    for range ticker.C {
        ml.mu.Lock()
        for ip, v := range ml.visitors {
            if time.Since(v.lastSeen) > window {
                delete(ml.visitors, ip)
            }
        }
        ml.mu.Unlock()
    }
}()

这个后台 goroutine 每个窗口周期清理一次过期记录,防止内存无限增长。如果不做清理,100 万个 IP 的 map 占用约 80MB 内存且只增不减。

Allow 方法memory.go:42-61):

go 复制代码
func (ml *memoryLimiter) Allow(key string) bool {
    ml.mu.Lock()
    defer ml.mu.Unlock()
    v, ok := ml.visitors[key]
    if !ok {
        ml.visitors[key] = &visitor{count: 1, lastSeen: time.Now()}
        return true
    }
    if time.Since(v.lastSeen) > ml.window {
        v.count = 1
        v.lastSeen = time.Now()
        return true
    }
    v.lastSeen = time.Now()
    v.count++
    return v.count <= ml.rate
}

滑动窗口的简单实现:如果距离上次访问超过了一个窗口,就重置计数。否则递增并检查阈值。

3.3 Redis 限流器:分布式场景的答案

pkg/ratelimit/redis.go:1-47

go 复制代码
type redisLimiter struct {
    client *redis.Client
    rate   int
    window time.Duration
}

func (rl *redisLimiter) Allow(key string) bool {
    ctx := context.Background()
    fullKey := "rate:" + key
    ttl := int(rl.window.Seconds())

    count, err := rl.client.Incr(ctx, fullKey).Result()
    if err != nil {
        return true // 优雅降级:Redis 故障时放行
    }
    if count == 1 {
        rl.client.Expire(ctx, fullKey, time.Duration(ttl)*time.Second)
    }
    return int(count) <= rl.rate
}

这个实现有三个精妙之处:

(1)INCR + EXPIRE 的模式

INCR 在 key 不存在时返回 1,key 存在时返回递增后的值。我们利用 count == 1 判断"这是一个新窗口的开始",此时设置过期时间。

为什么不直接用 INCR 然后 EXPIRE 无条件设置?因为如果 key 已经存在,重复设置 EXPIRE 会重置过期时间,导致窗口越滚越长,限流失效。

(2)Redis 故障时的优雅降级

go 复制代码
if err != nil {
    return true // 放行
}

如果 Redis 挂了,Allow 返回 true(放行)。这是"可用性优先"的策略------宁可暂时不限流,也不能把合法用户全拦住。配合 memoryLimiter 兜底,Redis 不可用时降级为单机内存限流。

(3)key 的命名空间

go 复制代码
fullKey := "rate:" + key

加了 rate: 前缀,避免和其他用途的 key 冲突。在生产环境,可能还需要加环境前缀:prod:rate:192.168.1.1

3.4 启动时的策略选择

cmd/main.go:105-115

go 复制代码
var rl ratelimit.Limiter
if err := redis.Init(); err != nil {
    zapLog.Warnf("redis init failed, using memory limiter: %v", err)
    rl = ratelimit.NewMemoryLimiter(10, time.Minute)
} else if redis.Enabled() {
    rl = ratelimit.NewRedisLimiterFromClient(redis.Client(), 10, time.Minute)
    zapLog.Infof("rate limiter: redis")
} else {
    rl = ratelimit.NewMemoryLimiter(10, time.Minute)
    zapLog.Infof("rate limiter: memory (not suitable for multi-instance)")
}

策略链:

  1. Redis 初始化失败 → 内存限流 + 警告日志
  2. Redis 可用 → Redis 限流
  3. Redis 没配置(REDIS_ADDR 为空)→ 内存限流 + 提醒日志

无论走哪条路,rl 总是有值的------永远不会 nil。这是一个典型的**"开闭原则"在依赖注入中的体现**:调用方不关心内部实现变更,接口始终稳定。

3.5 Redis 单例客户端

pkg/redis/redis.go:1-45

go 复制代码
var (
    once   sync.Once
    client *goredis.Client
)

func Init() error {
    addr := os.Getenv("REDIS_ADDR")
    if addr == "" {
        return nil
    }
    var initErr error
    once.Do(func() {
        client = goredis.NewClient(&goredis.Options{
            Addr:     addr,
            Password: os.Getenv("REDIS_PASSWORD"),
        })
        ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
        defer cancel()
        if err := client.Ping(ctx).Err(); err != nil {
            initErr = err
            client = nil
        }
    })
    return initErr
}

func Client() *goredis.Client { return client }

func Enabled() bool { return client != nil }

三个亮点:

(1)sync.Once 保证单次初始化 ------整个进程生命周期只创建一次连接。虽然 go-redis 本身就是连接池,但多个 Client 实例会创建多套连接池,浪费资源。

(2)Ping 验证连接------不只是创建对象,而是真正发一个 PING 命令确认 Redis 活着。3 秒超时防止启动卡死。

(3)Enabled() 函数 ------所有使用 Redis 的地方先检查 redis.Enabled(),如果为 false 就走降级逻辑。这是防御性编程的体现------永远假设外部依赖可能不可用。

3.6 在线心跳:SETEX + Pipeline Batch Query

service/OnlineStatusService.go:1-48

go 复制代码
const (
    onlineKeyPrefix = "user:online:"
    onlineTTL       = 5 * time.Minute
)

func heartbeatUser(uid int) {
    if !redis.Enabled() {
        return
    }
    redis.Client().Set(context.Background(), onlineKeyPrefix+strconv.Itoa(uid), "1", onlineTTL)
}

func IsUserOnline(uid int) bool {
    if !redis.Enabled() {
        return false
    }
    n, _ := redis.Client().Exists(context.Background(), onlineKeyPrefix+strconv.Itoa(uid)).Result()
    return n > 0
}

心跳在哪里触发?service/middleware.go:56,认证中间件里:

go 复制代码
heartbeatUser(claims.UserID)
c.Next()

每次 API 请求(经过认证的),都会刷新用户的心跳。5 分钟 TTL 意味着:如果用户 5 分钟没发任何 API 请求,他的在线状态自动消失。不需要离线事件,不需要 WebSocket 断连,纯"心跳超时"机制。

批量查询OnlineStatusService.go:32-47):

go 复制代码
func BatchIsOnline(uids []int) map[int]bool {
    result := make(map[int]bool, len(uids))
    if !redis.Enabled() || len(uids) == 0 {
        return result
    }
    ctx := context.Background()
    pipe := redis.Client().Pipeline()
    cmds := make([]*goredis.IntCmd, len(uids))
    for i, uid := range uids {
        cmds[i] = pipe.Exists(ctx, onlineKeyPrefix+strconv.Itoa(uid))
    }
    pipe.Exec(ctx)
    for i, uid := range uids {
        result[uid] = cmds[i].Val() > 0
    }
    return result
}

为什么要用 Pipeline?

如果不用 Pipeline,查询 100 个用户的在线状态需要 100 次 Redis 网络往返。每次约 0.5ms,总共 50ms。用 Pipeline 打包成一个网络请求,约 1-2ms 搞定。

Pipeline 的核心原理:客户端把所有命令打包发送,Redis 批量执行并返回结果。它不是事务(不保证原子性),但用于批量查询已经足够。

3.7 Pipeline vs Transaction:批量查询的最佳实践

很多人搞混 Redis 的 Pipeline 和 Transaction(MULTI/EXEC)。它们的区别至关重要:

特性 Pipeline Transaction (MULTI/EXEC)
原子性 有(命令要么全执行,要么全不执行)
命令间可见性 后面的命令可以看到前面的结果 看不到,命令排队执行
适用场景 批量查询(读多) 批量写入需要一致性(写多)
性能 极高(减少网络往返) 高(但比 Pipeline 多一次 MULTI/EXEC 往返)

在线状态查询用的是 Pipeline 而非 Transaction,因为:

  1. 查询操作天然幂等,不需要原子性保证
  2. 批量写在线状态时可以容忍"部分成功"(心跳丢一拍问题不大)
  3. Pipeline 比 Transaction 少一次 MULTI/EXEC 的网络往返

但分布式限流的 INCR + EXPIRE 能否用 Pipeline?

答案是不能。因为 INCR 的结果决定了是否需要送 EXPIRE。这不是"独立命令批量发送",而是"前一个命令的结果影响后一个命令是否发送"。这种"读己之写"的场景必须分两步走:

go 复制代码
count, _ := rl.client.Incr(ctx, fullKey).Result()  // 第 1 步:读结果
if count == 1 {
    rl.client.Expire(ctx, fullKey, ...)  // 第 2 步:根据结果决定是否发
}

有人可能会问:"为什么不用 Lua 脚本把 INCR + EXPIRE 打包成一个原子操作?" 这是一个极好的问题。Lua 脚本方案如下:

lua 复制代码
-- 伪代码,Redis 服务端执行
local count = redis.call('INCR', KEYS[1])
if count == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count

Lua 脚本确实是"最优解":原子、单次往返、无竞态。但它的代价是代码可读性下降(Go 代码里有字符串嵌入 Lua),而且对 go-redis 的 Pipeline 和连接池有一定侵入性。user-service 选择了两步法而非 Lua,是在简洁性和最优性之间做了权衡

3.8 内存限流器的适用边界

虽然 memoryLimiter 在多实例环境有"状态不共享"的缺陷,但它并非一无是处。以下场景中内存限流反而是最佳选择:

  1. 单实例部署:没有"多 Pod 状态共享"问题,内存方案比 Redis 快(零网络延迟)且零外部依赖。
  2. 开发环境 :本地开发不需要起 Redis,内存方案让 go run ./cmd/ 直接跑。
  3. 降级兜底:Redis 不可用时,内存方案保证限流功能仍然工作(虽然阈值可能不准确)。

cmd/main.go:105-115 中,无论 Redis 初始化成功与否,rl 变量永远不会为 nil。这就是工程中的"默认安全"原则------宁可功能降级,不让系统崩溃。

3.9 限流 key 的设计艺术

redisLimiter.Allow 使用 c.ClientIP() 作为限流 key。但生产环境需要注意两个问题:

问题 1:反向代理导致 IP 丢失

如果前面有 Nginx/ELB,c.ClientIP() 拿到的可能是反向代理的 IP(如 10.0.0.1),所有用户共享一个 IP,限流完全失效。

解决方案:配置 Gin 的 TrustedPlatform 或手动从 X-Forwarded-For 头提取真实 IP。

问题 2:NAT 网络下的误杀

多个用户在同一个 NAT 网络下(如校园网、公司内网),对外共享一个公网 IP。如果某个人触发了限流,整个公司都被挡住。

解决方案:对于已认证的用户,用 UID 而非 IP 做限流。即在限流 key 中包含 UID:

go 复制代码
func RateLimitMiddleware(rl ratelimit.Limiter) gin.HandlerFunc {
    return func(c *gin.Context) {
        key := c.ClientIP()
        if uid, exists := c.Get("user_id"); exists {
            key = fmt.Sprintf("%s:uid:%d", c.ClientIP(), uid.(int))
        }
        if !rl.Allow(key) { /* ... */ }
    }
}

user-service 目前只对 POST /auth/login(未认证的登录接口)用了 IP 限流,所以 IP 限流问题相对可控。已认证接口靠 JWT 的身份校验防滥用。

3.10 在线心跳:为什么选择 Redis + SETEX 而不是数据库?

在线状态这个功能看起来简单,但设计决策并不简单。我们追问三个问题:

问题 1:为什么不用数据库记录在线状态?

最直观的做法是在 user 表里加一个 last_active_at 字段,每次请求更新这个字段。方案可行,但有三个致命问题:

Redis SETEX 数据库 UPDATE
延迟 ~0.5ms(纯内存) ~2ms(磁盘 IO + 行锁)
并发压力 10 万 QPS 轻松 高频 UPDATE 行锁竞争严重
清理机制 TTL 自动过期,零运维 需要定时任务扫描清理

在线状态是一个"写频繁、读频繁、数据不重要"的场景------丢了就丢了,用户刷新一下又回来了。用数据库承受这种"脏活累活"是浪费。

问题 2:为什么不用 WebSocket 长连接 + 断连事件?

WebSocket 方案的核心思路是"建立连接 = 上线,断开连接 = 下线"。看起来更精确,但实际落地时麻烦不断:

  • 移动网络不稳定,4G/5G/WiFi 切换会导致 WebSocket 频繁重连------用户其实还在,但"下线→上线→下线"反复触发
  • 需要维护连接状态,服务端要处理心跳超时、重连、消息队列
  • WebSocket 是有状态的,多实例部署需要 sticky session 或发布/订阅同步

回到在线状态这个需求的本质:我们只需要知道"用户最近 5 分钟是否活跃",不需要精确到秒的上下线事件。 SETEX + TTL 的"懒过期"模式恰好匹配这个需求------粗粒度、高容错、零运维。

问题 3:为什么心跳放在中间件里而不是独立的心跳端点?

service/middleware.go:56,心跳在认证中间件中触发:

go 复制代码
heartbeatUser(claims.UserID)
c.Next()

这意味着不需要客户端额外发一个 POST /heartbeat 请求。客户端每次正常的 API 调用(查资料、看好友列表、发请求)都自动刷心跳。零侵入、零额外网络开销。

如果你的 App 是纯浏览(用户长时间只看不动),可以在前端加一个定时器,每 3 分钟调一次 /v1/user/:uid 查自己的资料,既刷新了心跳,又保持了 token 活跃。

3.11 Redis 分布式锁:解决多实例下的资源竞争

说明:本项目代码中暂未涉及分布式锁,但作为 Redis 在微服务中的核心能力之一,理解它的使用场景和实现方式非常重要。以下为实战级的方案讲解。

3.11.1 什么场景需要分布式锁?

分布式锁解决的是多实例环境下对共享资源的互斥访问问题。几个典型的现实场景:

场景 A:优惠券库存扣减

复制代码
用户 A 看到"还剩 1 张优惠券",点击领取
→ 实例 1 查 Redis:库存 = 1
→ 实例 2 也查 Redis:库存 = 1(同时请求)
→ 两个实例都判定"库存 > 0,可以发"
→ 库存被扣了两次 → 超发

问题本质:"查库存"和"减库存"是两个操作,中间有时间窗口。分布式锁把这个非原子的两步变成一个互斥区。

场景 B:定时任务去重

复制代码
K8s 集群部署了 3 个 Pod,每个 Pod 都有 CronJob 定时发统计报告
→ Pod-1:10:00 触发 → 发了一份报告
→ Pod-2:10:00 触发 → 又发了一份报告
→ Pod-3:10:00 触发 → 又又发了一份报告
→ 用户收到 3 份一模一样的邮件

解决方案:每个 Pod 在执行任务前尝试获取分布式锁。谁抢到锁谁执行,其他 Pod 检测到锁已存在就跳过。

场景 C:防止重复提交

复制代码
用户网络不好,点了两次"支付"按钮
→ 第一次请求打到实例 1
→ 第二次请求打到实例 2
→ 两个实例都不知道对方在处理同一个订单
→ 扣了两次款

订单号 作为锁 key,第一个请求加锁,第二个请求发现锁存在直接拒绝。

3.11.2 用 Redis 实现分布式锁:从错误到正确

第一版(错误):只用 SETNX

go 复制代码
// ❌ 有严重 bug
func (m *Mutex) Lock(key string) bool {
    ok, _ := redis.Client().SetNX(ctx, key, "1", 0).Result()
    return ok
}
func (m *Mutex) Unlock(key string) {
    redis.Client().Del(ctx, key)
}

问题在哪?如果获取锁的进程在释放锁之前崩溃了,这个 key 永远不会被删除。其他所有进程永远无法获取锁------死锁。

第二版(改进但不够):SETNX + EXPIRE

go 复制代码
// ⚠️ 有竞态条件
ok, _ := redis.Client().SetNX(ctx, key, "1", 30*time.Second).Result()

把锁加上过期时间(TTL),即使进程崩溃,30 秒后锁自动释放。但这引入了新问题:

问题:锁过期比任务执行快

复制代码
进程 A 获取锁,TTL=10秒
进程 A 开始处理任务(可能需要15秒)
10秒后 → 锁自动过期被释放
进程 B 获取了锁,开始处理
进程 A 还在处理,以为锁还是自己的
→ A 和 B 同时处理,分布式锁失效

第三版(正确):SETNX + EXPIRE + 唯一标识 + Lua 释放

核心改进:

  1. 锁的 value 用一个唯一 ID(UUID),释放时验证身份------只释放自己的锁
  2. 释放操作用 Lua 脚本保证原子性(读取+判断+删除在一个原子操作里)

完整实现:

go 复制代码
// 获取锁:SET key value NX EX ttl
func AcquireLock(key string, ttl time.Duration) (string, bool) {
    token := uuid.New().String()
    ok, err := redis.Client().SetNX(ctx, "lock:"+key, token, ttl).Result()
    if err != nil || !ok {
        return "", false
    }
    return token, true
}

// 释放锁:用 Lua 脚本原子化"检查 + 删除"
var unlockScript = redis.NewScript(`
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
`)

func ReleaseLock(key string, token string) bool {
    result, err := unlockScript.Run(ctx, redis.Client(), []string{"lock:" + key}, token).Int()
    return err == nil && result == 1
}

Lua 脚本为什么必要? 如果不原子化,释放锁的流程是:

go 复制代码
// ❌ 竞态条件
val, _ := redis.Client().Get(ctx, key).Result()
if val == token {              // ① 比较通过
    redis.Client().Del(ctx, key)  // ② TTL 在 ① 和 ② 之间过期,另一个进程已经拿到了新锁
}

① 和 ② 之间有网络往返的时间间隙。如果在这期间锁过期,被进程 B 重新获取,那 ② 删除的就是 B 的锁,而不是 A 的锁。Lua 脚本在 Redis 服务端原子执行,消除了这个间隙。

3.11.3 分布式锁的使用示例

完整的使用流程:

go 复制代码
func HandleCouponGrab(userID int, couponID string) error {
    lockKey := fmt.Sprintf("coupon:%s", couponID)
  
    // 1. 获取锁,30 秒超时
    token, ok := AcquireLock(lockKey, 30*time.Second)
    if !ok {
        return errors.New("系统繁忙,请稍后重试")
    }
    defer ReleaseLock(lockKey, token)  // 2. 无论如何都要释放锁
  
    // 3. 在锁保护下做库存扣减
    stock := getCouponStock(couponID)
    if stock <= 0 {
        return errors.New("优惠券已抢完")
    }
    return deductCoupon(couponID, userID)
}

生产环境提醒 :自实现的 Redis 锁可以满足大多数场景,但如果你的业务要求极高的可靠性(比如金融场景),建议使用成熟的实现如 redsync(基于 Redlock 算法),它通过多个独立的 Redis 实例来实现更高的容错性。


四、总结

Redis 在 user-service 中扮演的角色远超"缓存",覆盖了微服务架构的三个核心场景:

场景 Redis 能力 关键命令 解决什么问题
分布式限流 原子计数器 INCR + EXPIRE 多实例共享限流计数
在线心跳 带 TTL 的状态存储 SETEX + Pipeline EXISTS 高频写入 + 自动过期 + 批量查询
分布式锁(扩展) 互斥锁 SETNX + EXPIRE + Lua 脚本 多实例下资源互斥(库存扣减/定时任务去重)

三条设计原则贯穿始终:

  1. 接口抽象Limiter 接口让内存和 Redis 两种实现无缝切换,调用方完全不感知
  2. 优雅降级:Redis 不可用时自动回退到内存方案,服务不崩溃,只是限流精度下降
  3. 做减法:在线状态用"懒过期"而非 WebSocket,"被动心跳"而非独立端点,用最简单的方式解决够用的问题------工程不是炫技

完整代码

本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。

项目地址:https://github.com/binbin3828/user

本系列 14 篇完整目录:

① 从面条代码到三层架构 ② API 安全洋葱模型 ③ 配置管理与密钥保护 ④ 单元测试 ⑤ 可观测性

⑥ 部署进化 ⑦ 好友请求状态机 ⑧ Redis 实战 ⑨ 中间件链 ⑩ Geohash

⑪ API 响应设计 ⑫ 优雅关闭 ⑬ GORM 避坑 ⑭ Makefile