很多系统做会员积分,第一版就是 users 表加一个 points 字段,签到加几分、消费加几分,直接 UPDATE users SET points = points + ?。上线初期没问题,跑几个月就开始出现对账对不上、用户重复点击多领、积分兑换时扣成负数。本文不讨论积分运营规则,只把积分账户在工程上最容易出错的三个点讲透:为什么必须有流水表、怎么做幂等入账、怎么从根上防超兑。
一、只用一个余额字段为什么一定会乱
积分和钱一样,属于"账户类"数据,它有两个天然要求:每一笔变动可追溯、任意时刻余额可重算。只存一个余额,等于只有结果没有过程:
- 用户投诉"我积分怎么少了",你查不到是哪一笔扣的;
- 程序 bug 把积分加错,无法定位、无法回滚;
- 并发下"读余额—判断—写回"会丢更新。
正确做法是拆成两张表:账户表只存当前余额,流水表存每一笔变动,余额永远等于流水之和。
CREATE TABLE points_account (
user_id BIGINT PRIMARY KEY,
balance INT NOT NULL DEFAULT 0, -- 当前可用积分
version INT NOT NULL DEFAULT 0, -- 乐观锁版本
updated_at TIMESTAMP
);
CREATE TABLE points_ledger (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
change_num INT NOT NULL, -- 正为入账,负为扣减
biz_type VARCHAR(32) NOT NULL, -- sign/order/refund/exchange
biz_no VARCHAR(64) NOT NULL, -- 来源业务单号
balance_after INT NOT NULL, -- 该笔之后的余额快照
created_at TIMESTAMP,
UNIQUE KEY uk_biz (user_id, biz_type, biz_no) -- 幂等关键
);
uk_biz 这个唯一约束是整套设计的地基:同一用户、同一业务类型、同一业务单号只能有一条流水。重复请求第二次插入会直接撞唯一键,而不是再加一次。
二、幂等入账:把"加积分"变成可重试的安全操作
网络重试、用户连点、消息队列重复投递,都会让同一笔入账请求来多次。幂等的含义是:同一笔业务无论执行多少次,结果都和执行一次一样。
入账用"先插流水、用唯一约束兜底、再更新余额"的顺序,并放在一个事务里:
-- 1. 尝试插入流水;若该业务单已入账,唯一键冲突,直接返回成功(幂等)
INSERT INTO points_ledger (user_id, change_num, biz_type, biz_no, balance_after)
VALUES (?, ?, 'order', ?, ?);
-- 若抛 Duplicate key,说明已处理过,查询旧结果返回,不再加余额
-- 2. 插入成功才更新余额,用乐观锁防止并发覆盖
UPDATE points_account
SET balance = balance + ?, version = version + 1
WHERE user_id = ? AND version = ?;
几个要点:
- 幂等键要带业务语义,不能用请求的随机 UUID(每次重试 UUID 都不同,等于没幂等),要用上游稳定的订单号、签到日期这类"同一业务重试不变"的标识。
- 撞唯一键时返回成功而不是报错,因为调用方要的就是"这笔到账了"这个结果。
- 流水里冗余
balance_after,排查问题时不用从头累加,一眼看到每笔之后的余额。
三、防超兑:扣减必须在数据库层兜底
积分兑换是积分体系里唯一会"减少"积分的场景,也是最容易出事故的地方——高并发下两个兑换请求同时读到余额 100,各自判断"够兑",最后扣成 -100。
绝对不能在应用层先 SELECT 出余额、用代码判断够不够、再 UPDATE 写回,这在并发下必穿。正确做法是让数据库的条件更新做最后防线:
-- 扣减时把"余额足够"写进 WHERE,影响行数为 0 就是余额不足,天然防超兑
UPDATE points_account
SET balance = balance - ?, version = version + 1
WHERE user_id = ? AND balance >= ?;
- 返回影响行数 = 1:扣减成功,再插一条负向流水;
- 返回影响行数 = 0:余额不足,兑换失败,提示用户。
这条语句是原子的,数据库行锁保证不会有两个事务同时穿过 balance >= ? 这道判断。应用层的预校验只用于提前给用户友好提示,真正的防线永远在这条带条件的 UPDATE 上。
如果还要冻结积分(下单先冻结、支付成功再实扣、取消则退回),就在账户表加 frozen 字段,冻结、解冻、实扣各对应一种 biz_type 流水,状态流转同样靠条件 UPDATE 保证原子。
四、踩坑清单
- 只存余额不存流水:无法对账、无法回溯,尽早补流水表;
- 幂等键用了每次都变的随机值,重复请求照样重复入账;
- 撞唯一键时抛错,导致 MQ 消费者无限重试;
- 扣减在代码里判断余额、并发下扣成负数;
- 退款扣回积分时不做幂等,重复退款把积分扣穿;
- 流水表和余额表不在一个事务,出现"有流水没余额"或反之。
五、工程落地建议
积分账户本质是一套简化的账务系统,落地时建议先把"账户表 + 流水表 + 业务唯一键 + 条件扣减"这套骨架搭对,再往上叠签到、等级、兑换商城等玩法;对账任务每天用流水重算一次余额,和账户表比对,不一致就告警。成型的门店会员工具通常已经把这套账户模型、幂等和防超兑封装好(如基于成型平台的会员积分能力,如乔拓云门店系统),自研时重点保证流水与余额的一致性即可。
六、上线自检清单
- 每一笔积分变动是否都有流水、且有业务唯一键;
- 同一业务单号重复请求 10 次,余额是否只变一次;
- 并发兑换到余额临界值,是否绝不可能扣成负数;
- 余额是否等于该用户全部流水之和;
- 退款、取消等逆向操作是否同样幂等。
积分账户看着简单,实则是"账户一致性"问题的缩影。把流水、幂等、条件扣减这三件事做扎实,后面无论玩法怎么变,账都不会乱。以上为个人工程实践分享,具体实现请结合自身业务与数据库特性设计。