流水表必须带唯一业务单号trade_no并建唯一索引,用insert ignore或on duplicate key update防重;余额统一用bigint存最小单位,所有增减走原子update;对账分实时(查最近n条)与离线(每日全量sum比对)两层;事务须同库同表,禁止跨库跨服务。

流水表必须带唯一业务单号,不能只靠自增ID对账
余额不准,90%出在流水重复或漏写。自增 id 只是插入顺序标识,不是业务凭证——同一笔充值可能因重试插入两次,而 id 不同但业务单号相同。
- 流水表必须有
trade_no字段(如pay_202405211023456789),加唯一索引:UNIQUE KEY uk_trade_no (trade_no) - 写流水时用
INSERT IGNORE或ON DUPLICATE KEY UPDATE拦住重复单号,而不是靠应用层“查一遍再插” - 对账脚本比对时,只以
trade_no为基准,完全忽略id和插入时间
余额字段别存浮点数,用 BIGINT 存「分」或「最小积分单位」
MySQL 的 FLOAT / DOUBLE 会丢失精度,比如 0.1 + 0.2 ≠ 0.3,积分清零、发放、扣减全乱套。哪怕你用 DECIMAL(12,2),也扛不住高并发下的读写竞争和四舍五入误差。
- 统一用
BIGINT,单位是「1积分」,不带小数——用户看到的 12.5 积分,在库里存1250(单位=0.01分)或直接125(单位=0.1分),看业务粒度 - 所有增减操作走
UPDATE user_point SET balance = balance + ? WHERE user_id = ? AND trade_no = ?,避免先查后更新 - 余额校验脚本里,
SUM(amount)流水总和必须和balance完全相等,差1都不行——这是对账硬门槛
对账不是“跑个SQL”,得区分「实时核对」和「离线兜底」
线上服务不能每笔都查流水总和来校验余额,太慢;但完全不校,出错发现就晚了。得拆成两层:轻量实时校验 + 异步全量对账。
- 用户查余额时,只校验最近 N 条流水(如
WHERE user_id = ? ORDER BY created_at DESC LIMIT 10),快速发现明显异常(比如刚扣了1000积分,余额却涨了) - 真正对账走后台任务:每天凌晨用
SELECT user_id, SUM(amount) AS total_flow, MAX(balance) AS final_balance FROM point_log GROUP BY user_id关联用户表比对,结果写进reconcile_report表供人工介入 - 注意
point_log表要有(user_id, created_at)复合索引,否则按用户聚合极慢
事务边界要卡死:流水和余额更新必须同库同表同事务
有人把流水写 A 库、余额更新写 B 库,靠 MQ 补偿——这等于放弃强一致性。只要网络抖动、MQ 丢消息、消费者重复消费,余额立刻飞掉。
- 流水表和用户余额字段必须在同一个 MySQL 实例、同一个数据库里,用单条
UPDATE+INSERT包在BEGIN...COMMIT中 - 如果用分库分表,至少保证「单用户所有数据落在同一物理分片」,且该分片内事务可用;跨分片对账只能走异步+幂等+告警
- 禁止在事务里调外部 HTTP 接口(比如发通知)、写 Redis、发 MQ——这些失败会导致事务卡住或回滚不干净
最难的不是写对账SQL,而是让每一笔业务单号从下单、支付、到账、退款全程可追溯,且所有系统环节都把它当唯一身份证。少一个环节没透传 trade_no,后面就得人工翻日志捞数据。











