直接用Gin POST路由处理签到会丢请求,因未做并发保护,多个goroutine同时读旧值+1写回导致竞态;正确做法是用Redis+Lua将判断、更新、累加压为原子操作。

为什么直接用 Gin 的 POST 路由处理签到会丢请求
因为默认 Gin 没做任何并发保护,多个请求同时更新同一用户的 last_check_in_time 或累加 streak 时,会出现竞态:两个 goroutine 都读到同个旧值,各自+1后写回,最终只涨了 1 次。这不是 Gin 的问题,而是业务逻辑没隔离。
常见错误现象:CheckInCount 增长慢于实际请求数、连续签到天数突然归零、Redis 中 INCR 返回值跳变。
- 别在 handler 里直接查 DB → 改字段 → 写 DB(三步非原子)
- 避免用
SELECT ... FOR UPDATE锁整行——高并发下锁冲突严重,延迟飙升 - 别依赖客户端传的
timestamp判断“是否今日”——时钟不同步或重放会导致逻辑错乱
用 Redis + Lua 保证单用户签到原子性
核心思路:把“判断是否已签、更新时间、累加计数”这三步压进一个 Redis Lua 脚本执行,由 Redis 单线程保证原子。Gin 只负责转发和返回结果。
示例脚本(存为 checkin.lua):
立即学习“go语言免费学习笔记(深入)”;
local key = KEYS[1]
local now = tonumber(ARGV[1])
local today = math.floor(now / 86400) * 86400
local last_time = tonumber(redis.call('HGET', key, 'last')) or 0
local last_day = math.floor(last_time / 86400) * 86400
<p>if last_day == today then
return {0, 'already_checked_in'}
end</p><p>redis.call('HSET', key, 'last', now, 'streak', last_day == today - 86400 and tonumber(redis.call('HGET', key, 'streak')) + 1 or 1)
return {1, 'success'}Go 中调用:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
script := redis.NewScript(checkinLuaContent)result, err := script.Do(ctx, rdb, []string{fmt.Sprintf("user:%d", uid)}, time.Now().Unix()).Result()if err != nil { /* 处理连接/超时 */ }res := result.([]interface{})code := int(res[0].(int64))
Gin 路由层要做哪些减负操作
高并发下,Gin 自身不是瓶颈,但 handler 里随便打日志、拼 JSON、校验 token 不复用,都会吃掉大量 CPU 和 GC 压力。
- 用
c.ShouldBindJSON(&req)替代c.GetRawData()+ 手动json.Unmarshal(前者复用 buffer) - token 解析提取 user_id 后,直接塞进
c.Set("uid", uid),下游 handler 直接取,别重复解析 - 禁用全局日志中间件对签到路由生效;真要记日志,用异步 channel + worker 批量写入,避免阻塞
- 给签到接口加限流:用
golang.org/x/time/rate做 per-user 限流(比如 5 次/分钟),防刷
DB 写入可以异步,但不能丢
Redis 签到成功只是第一步,最终还得落库。但等 DB 写完再返回,RT 会从几毫秒拉到几十毫秒。折中方案:Redis 成功即返回 200,DB 更新走消息队列或后台 goroutine。
关键约束:
- 异步写 DB 必须带重试(最大 3 次)+ 死信记录(写失败时记到
checkin_failed表,供人工对账) - DB 字段必须含
checked_at(用 Redis 返回的时间戳,而非 DB 服务器 NOW())——否则时钟不一致会导致统计偏差 - 不要用 goroutine 直接
go insertToDB(...):进程重启时未完成的 goroutine 就丢了
更稳的做法是写入本地 WAL 日志文件(如 bufio.Writer 追加),再由独立守护进程读取并入库——简单、可控、可追溯。

















