必须用数据库事务包裹积分更新与流水记录,否则服务崩溃会导致积分与流水不一致;需用带条件的UPDATE RETURNING原子校验并获取结果,配合唯一索引实现幂等,且流水表须建复合索引保障查询性能。

为什么直接用 db.Exec 更新积分会出事
因为积分变动不是简单加减,它必须和流水记录强一致——要么两者都成功,要么都不发生。用 db.Exec 单独更新用户积分、再 db.Exec 插入流水,中间若服务崩溃或网络中断,就会出现“积分变了但没留痕”或“留了痕但积分没变”的撕裂状态。
常见错误现象:UPDATE users SET points = points + 100 WHERE id = 123 执行成功,但后续 INSERT INTO point_logs... 因 panic 失败;或者事务未开启,两条语句跨连接执行,根本无法回滚。
- 必须用数据库事务包裹整个操作,且事务生命周期要覆盖“查当前值→校验→更新→记流水”全过程
- 不要在事务外做前置校验(如先
SELECT points FROM users),否则存在并发覆盖风险 - Gin 中推荐在 handler 内显式启停事务,避免 middleware 泄露事务上下文
怎么用 Gin + sqlx 实现原子化积分变更
核心是把校验逻辑下推到 SQL 层,用一条带条件的 UPDATE ... RETURNING 完成“扣减是否足够 + 更新 + 返回影响行数”,再配合事务插入流水。
示例场景:用户兑换了 500 积分,需确保其当前积分 ≥ 500:
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
UPDATE users SET points = points - 500 WHERE id = $1 AND points >= 500 RETURNING points, version
如果返回空行,说明余额不足,直接拒绝;否则用返回的 points 和 version(乐观锁字段)继续插入流水。
- 务必在
users表加version字段用于乐观锁,每次更新时SET version = version + 1,防止并发重复扣减 - 流水表
point_logs必须包含user_id、before_points、after_points、change、reason、created_at字段,不可省略前后值 - 使用
sqlx.NamedExec绑定结构体参数,避免手拼 SQL 引入注入风险
如何防止同一笔业务被重复提交导致积分多扣
前端防抖只是辅助,后端必须有幂等控制。不能只依赖客户端传来的 request_id 做去重——它可能丢失或伪造,真正可靠的是业务维度唯一键。
例如兑换码使用、订单支付回调、签到任务完成,每种场景都有天然唯一标识:order_id、coupon_code、date_20240520_user_123。
- 在
point_logs表上建唯一索引:UNIQUE (user_id, biz_type, biz_id),其中biz_type是字符串标识(如"exchange"),biz_id是该业务的 ID - 插入流水时用
ON CONFLICT DO NOTHING(PostgreSQL)或INSERT IGNORE(MySQL),捕获唯一约束冲突错误,直接返回“已处理” - 不要在事务外先查再插,否则查与插之间仍有竞态;必须靠数据库唯一约束兜底
流水查询慢?别让 point_logs 变成全表扫描黑洞
积分流水表增长极快,一个月百万级很常见。不做索引优化,按用户查历史、按时间范围导出都会拖垮接口。
- 强制复合索引:
CREATE INDEX idx_user_created ON point_logs (user_id, created_at DESC),这是 90% 查询路径的基础 - 高频按类型筛选(如只查“签到”流水),加
(user_id, biz_type, created_at)索引 - 避免在流水表里存冗余字段(如用户昵称),需要时关联
users表查;否则更新昵称要批量改流水,锁表风险高 - 超过一年的冷数据建议归档到分区表或单独库,主表只保留热数据
最易被忽略的是:所有流水写入必须走事务,所有流水查询必须走索引,这两条线一旦断开,安全性和性能就同时崩塌。

















