☰
能扛大促的商城积分账本:并发、幂等与对账设计
2026/11/4 2:41:03 网站建设 项目流程

简介:这套商城积分系统源码面向学习ASP.NET与SQL Server的开发者,以及需要课程设计、毕业设计参考的学生,围绕会员积分的获取、累计、查询、兑换和后台维护给出较完整的实现思路。资源共93个文件,以35个C#后端代码、13个ASPX页面、12个ASCX用户控件为主,并配有6个配置文件、样式表、图片素材及SQL Server数据库文件,压缩包约2.31MB,结构上覆盖前台会员操作、后台管理与数据存储。已有3098人学习下载,说明其作为中小型积分管理案例具有一定参考价值。读者可从中了解购物积分、注册奖励、积分有效期、商品兑换、会员等级与权限控制等模块如何落地,也能借鉴母版页、用户控件和数据库表设计的组织方式;附带的演示PPT有助于快速梳理系统功能与业务流程,适合在此基础上进行功能扩展或二次开发。

1. 商城积分系统:一套能扛住大促的积分账本长什么样

运营在群里甩过来一张截图:同一个订单,用户积分到账两次;另一个用户账户里明明只有 300 分,却成功扣掉了 500 分。这类问题在商城积分系统里几乎每周都会出现一次,代码看上去都写对了,问题却出在并发、幂等和事务边界上。这个项目要解决的就是这三件事:把「加积分、扣积分、积分过期」做成一本能对得上账的流水账,而不是在用户表上挂一个points字段反复加减。适合正在做会员体系、订单返积分、积分抵扣的后端同学,也适合想搞清楚「钱类业务到底怎么写才安全」的开发者。

2. 工程骨架拆解:六个 Maven 模块和三张核心表怎么分工

先把技术选型摆出来。这套代码是典型的 Java 后端组合:Spring Boot 2.x 打底,JDK 8 起步,MyBatis-Plus 做持久层,MySQL 8 存主账本,Redis 扛热点余额和防重,定时任务用 Spring 自带的@Scheduled,量再大就换成分片调度。为什么不让 Redis 当主存储?因为积分本质上是「钱」,扣减必须强一致,缓存丢了可以重建,账本丢了就是事故。所以这套代码的原则很硬:MySQL 是唯一真相来源,Redis 只是加速层,任何时候 Redis 全挂,业务降级到直接读库也能跑。

读写比例大概是 20:1,查积分明细、查余额是高频,真正发生变动的只有下单、退款、过期这几个口子。所以设计上把「写」收得很窄,全部走同一个应用服务,读则允许走缓存和从库。

2.1 六个模块的职责边界

多模块不是为了好看,是为了让「谁能改账本」这件事在编译期就被约束住。

mall-points ├── pom.xml # 父 POM,统一依赖版本 ├── points-common # 通用返回体、错误码、枚举、工具类,谁都能依赖 ├── points-domain # 领域模型:账户、流水、规则,纯 POJO + 领域方法 ├── points-infra # MyBatis Mapper、Redis 客户端、MQ 生产者 ├── points-application # 应用服务:发放、扣减、冻结、查询的编排 ├── points-api # Controller + DTO + 参数校验,对外唯一入口 └── points-job # 定时任务:过期清理、对账、补偿

依赖方向是单向的:api → application → domain ← infra。domain谁都不依赖,infra反过来实现domain里定义的仓储接口。这么拆的好处很直接——写单元测试的时候,PointsAccount的加减逻辑不需要启动 Spring,也不用连数据库,直接 new 出来就能跑。我见过太多项目把余额计算写在 Service 里,测一次要起一整套环境,最后干脆不写测试,出事全靠日志捞。

points-job单独拆出来还有个现实原因:定时任务容易拖垮主应用。过期清理一跑几十分钟,占着数据库连接不放,接口线程池跟着遭殃。独立成一个进程,出问题最多是任务延迟,不至于把线上交易打挂。

2.2 三张核心表和它们的字段

表不多,但每个字段都有存在的理由。先看账户表:

CREATE TABLE `points_account` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID,一人一条', `total_points` BIGINT NOT NULL DEFAULT 0 COMMENT '累计获得,只增不减', `available` BIGINT NOT NULL DEFAULT 0 COMMENT '可用积分,扣减只动这个字段', `frozen` BIGINT NOT NULL DEFAULT 0 COMMENT '冻结积分,下单占用时用', `used` BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗', `expired` BIGINT NOT NULL DEFAULT 0 COMMENT '累计过期', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分账户';

available和frozen必须分开。用户下单时用积分抵扣,但订单还没支付成功,这时候积分不能直接扣掉,否则用户取消订单就得加回来,一来一回全是脏数据。做法是先冻结,支付成功再真正扣减,超时未支付就解冻。

流水表是整套系统的核心:

CREATE TABLE `points_ledger` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号,幂等靠它的唯一索引', `user_id` BIGINT UNSIGNED NOT NULL, `scene_code` VARCHAR(32) NOT NULL COMMENT '场景:ORDER_PAY / REFUND / EXPIRE', `change_type` TINYINT NOT NULL COMMENT '1 增加 2 扣减 3 过期 4 冻结 5 解冻', `points` BIGINT NOT NULL COMMENT '本次变动值,恒为正数', `balance_after` BIGINT NOT NULL COMMENT '变动后可用余额,对账用', `expire_at` DATETIME DEFAULT NULL COMMENT '过期时间,只对增加方向有值', `remain` BIGINT NOT NULL DEFAULT 0 COMMENT '这条流水还剩多少没被消耗', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 有效 2 已失效', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz` (`biz_no`), KEY `idx_user_time` (`user_id`, `created_at`), KEY `idx_expire` (`expire_at`, `status`, `remain`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分流水,唯一真相来源';

这里有两个字段值得单独说。biz_no的唯一索引是幂等的最后一道防线,任何重复请求打到数据库都会被它挡回去,不依赖 Redis 是否可用。remain是为过期和先进先出扣减准备的——这个项目按「先到期的先扣」来消耗积分,每条增加流水记一个剩余量,扣减时从最早到期的流水开始扣,remain归零就把status置为失效。

规则表最简单,但决定了发放逻辑的灵活度:

CREATE TABLE `points_rule` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `scene_code` VARCHAR(32) NOT NULL COMMENT '场景码', `ratio` DECIMAL(10,4) NOT NULL COMMENT '每 1 元换多少积分', `max_points` BIGINT NOT NULL DEFAULT 0 COMMENT '单笔上限,0 表示不限', `valid_days` INT NOT NULL DEFAULT 365 COMMENT '有效期天数', `status` TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (`id`), UNIQUE KEY `uk_scene` (`scene_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分发放规则';

ratio用DECIMAL而不是DOUBLE,这是血泪经验。用浮点算「消费金额 × 0.1」,很容易算出 499.9999999,取整之后用户少拿一分,投诉电话就打过来了。

2.3 本地起服务的四步操作

按顺序来,别跳步:

# 1. 建库建表,脚本在 db/schema.sql 和 db/data.sql(含几条默认规则) mysql -uroot -p -e "CREATE DATABASE mall_points DEFAULT CHARSET utf8mb4;" mysql -uroot -p mall_points < db/schema.sql mysql -uroot -p mall_points < db/data.sql # 2. 改本地配置,主要是数据库和 Redis 地址 vim points-api/src/main/resources/application-local.yml # 3. 编译,注意跳过测试,本地没起 Redis 测试会红 mvn clean package -DskipTests # 4. 启动 API 和任务两个进程 java -jar points-api/target/points-api.jar --spring.profiles.active=local java -jar points-job/target/points-job.jar --spring.profiles.active=local

配置文件里几个关键项:

points: cache: enabled: true # 本地调试可以关掉,直接读库,省得排查缓存干扰 ttl-seconds: 300 deduct: max-retry: 3 # 乐观锁冲突重试次数 expire: batch-size: 500 # 过期任务每批处理条数 max-batch-per-round: 20

points.cache.enabled这个开关在排查问题时特别有用。线上出现「余额显示不对」,第一件事就是把它关掉看是否还复现,能复现就是账本问题,不能复现就是缓存问题,直接省掉一半的排查时间。

提示:本地调试建议先把points-job停掉,过期任务会把你手工造的数据悄悄改掉,等发现余额对不上时已经找不到原因了。

3. 发放与扣减链路:一次下单加积分从 Controller 到 SQL 的完整路径

发放和扣减是这套系统里唯二会改余额的入口,其他所有逻辑都是围绕它们做保护。这一章把两条链路完整走一遍,重点看「为什么这么写」,不看语法。

链路顺序是固定的:Controller 做参数校验 → 应用服务做幂等前置判断 → 领域服务算积分和余额 → 仓储落库 → 事务提交后再发消息。任何一步的顺序调换都可能出问题,比如把发消息放进事务里,事务回滚了消息已经发出去了,下游凭空多一条记录。

3.1 发放:规则计算加唯一索引兜住幂等

先看发放的入口。订单支付成功后,支付回调会调这个接口,而支付回调天然会重复——支付平台超时重试、网络抖动重发,同一个bizNo一天来三次都正常。

@Service @RequiredArgsConstructor public class PointsGrantService { private final PointsRuleRepository ruleRepository; private final PointsAccountMapper accountMapper; private final PointsLedgerMapper ledgerMapper; /** * 发放积分。 * cmd.bizNo 必须是全局唯一的业务单号,重复调用只生效一次。 */ @Transactional(rollbackFor = Exception.class) public GrantResult grant(PointsGrantCmd cmd) { // 1. 前置幂等:命中已有流水直接返回,避免后面白跑一遍锁竞争 PointsLedger exist = ledgerMapper.selectByBizNo(cmd.getBizNo()); if (exist != null) { return GrantResult.duplicated(exist.getId()); } // 2. 规则计算,金额一律走 BigDecimal,最后取整到整数积分 PointsRule rule = ruleRepository.match(cmd.getSceneCode()); long points = rule.calc(cmd.getPayAmount()); if (points <= 0) { return GrantResult.ignored("金额未达到发放门槛"); } // 3. 先写流水。biz_no 唯一索引是真正的幂等防线, // 并发下两个线程都过了第 1 步,这里只会有一个成功 PointsLedger ledger = PointsLedger.ofGrant(cmd, points, rule.validDays()); try { ledgerMapper.insert(ledger); } catch (DuplicateKeyException e) { return GrantResult.duplicated(null); } // 4. 账户余额用增量更新,不查出来再 set,避免读改写丢更新 accountMapper.increase(cmd.getUserId(), points); // 5. 回填余额快照,对账脚本靠它定位差异出现的时点 PointsAccount acc = accountMapper.selectByUserId(cmd.getUserId()); ledgerMapper.fillBalanceAfter(ledger.getId(), acc.getAvailable()); return GrantResult.success(ledger.getId(), points); } }

逐段说。第 1 步只是「快速失败」优化,不是幂等保证,因为查和插之间有窗口期,并发下两个线程都可能查不到。第 3 步才是真防线:uk_biz唯一索引让第二个 insert 直接抛DuplicateKeyException,捕获后当成正常返回。这种「先靠数据库挡,再靠代码兜」的写法,比 Redissetnx可靠得多——Redis 挂了、主从延迟了、key 提前过期了,都会让幂等失效,唯一索引不会。

第 4 步的increase是一条增量 SQL:

<update id="increase"> UPDATE points_account SET available = available + #{points}, total_points = total_points + #{points}, version = version + 1 WHERE user_id = #{userId} </update>

注意这里没有version条件。增量更新本身是原子的,MySQL 行锁保证并发安全,加乐观锁反而会让高并发场景下大量请求失败重试。乐观锁只用在「必须读到旧值才能算新值」的场景,比如扣减。

第 5 步回填balance_after看起来多余,但它是对账的关键。用户投诉「我的积分少了」,运维只要拉出流水,看每条记录的balance_after是不是连续的,就能判断是系统算错了还是用户记错了。

3.2 扣减:把余额判断写进 where 条件

扣减比发放危险,因为发放最多多发,扣减扣错了就是直接损害用户资产。核心思路一句话:判断和扣减必须在同一条 SQL 里完成。

@Transactional(rollbackFor = Exception.class) public DeductResult deduct(PointsDeductCmd cmd) { // 1. 先落流水占位,靠唯一索引挡住重复提交 PointsLedger ledger = PointsLedger.ofDeduct(cmd); try { ledgerMapper.insert(ledger); } catch (DuplicateKeyException e) { return DeductResult.duplicated(); } // 2. 扣账户。available >= points 写在 WHERE 里, // 余额不足时影响行数为 0,不需要先 select 再判断 int rows = accountMapper.deduct(cmd.getUserId(), cmd.getPoints()); if (rows == 0) { // 抛异常让事务回滚,第 1 步插入的流水一起撤掉 throw new PointsNotEnoughException(cmd.getUserId(), cmd.getPoints()); } // 3. 按先到期先消耗的顺序核销流水,回写 remain expireAllocator.allocate(cmd.getUserId(), cmd.getPoints()); return DeductResult.success(cmd.getPoints()); }

对应的 Mapper:

<update id="deduct"> UPDATE points_account SET available = available - #{points}, used = used + #{points}, version = version + 1 WHERE user_id = #{userId} AND available &gt;= #{points} </update>

AND available >= #{points}这一行是整个扣减逻辑的灵魂。很多人习惯先select查余额,代码里if (balance < points) throw,然后再update。这个写法在单线程下没问题,一上并发就翻车:两个请求同时读到 500,各扣 400,最后余额变成 -300。把判断塞进 WHERE,交给 InnoDB 的行锁去串行化,影响行数为 0 就说明余额不够,干净利落。

第 3 步的expireAllocator负责先进先出核销。它的逻辑是按expire_at升序取出remain > 0的流水,一条条扣减remain,直到本次扣减量分配完。这里要注意加锁顺序——同一用户的流水必须按主键升序更新,否则两个并发扣减可能交叉持锁,撞上死锁。我一般会在 Mapper 里强制ORDER BY id ASC,并且在应用层按user_id排序后再批量处理。

还有一种情况要处理:Redis 里缓存了余额,扣减时要不要先扣缓存?我的做法是不扣。缓存只做读加速,写路径完全不碰它,扣减成功后直接DEL掉这个 key,下次读自然回源。这样即使删缓存失败,最坏结果是有一次读到旧值,但账本永远是对的。反过来「先更新缓存再更新库」的写法,一旦库更新失败,缓存里就是一笔不存在的余额,用户能拿它下单,损失是真金白银。

3.3 缓存层的读写顺序

余额查询是最高频的接口,商品详情页、购物车、结算页都要调。这套代码的缓存策略很保守:

public long queryAvailable(long userId) { if (!cacheProperties.isEnabled()) { return accountMapper.selectByUserId(userId).getAvailable(); } String key = "points:acc:" + userId; String cached = redis.opsForValue().get(key); if (cached != null) { return Long.parseLong(cached); } PointsAccount acc = accountMapper.selectByUserId(userId); // 空账户也要缓存,防止穿透打库;用短 TTL 兜住 redis.opsForValue().set(key, String.valueOf(acc.getAvailable()), Duration.ofSeconds(cacheProperties.getTtlSeconds())); return acc.getAvailable(); }

写路径统一是「先写库,事务提交后删缓存」。删缓存放在TransactionSynchronization.afterCommit里,不能放在事务方法体内——事务还没提交就删缓存,另一个线程立刻回源读到旧值又写回缓存,脏数据就留在那里了,这是个特别隐蔽的坑。

注意:不要给余额缓存设太长的 TTL。用户刚付完款发现余额没变,体验很差。5 分钟是个比较稳妥的值,配合写后删除,实际不一致窗口基本在毫秒级。

4. 过期与对账:定时任务怎么把历史积分一笔一笔清干净

积分有有效期,这是运营的基本诉求——不然用户攒了三年积分,一次性兑换把库存打空。但过期这件事在实现上很麻烦:不能一次性全表扫,不能重复扣,不能因为某一条失败就整批回滚。这一章讲这套代码怎么把过期做成可控的分批任务,以及账对不上时怎么查。

4.1 过期任务的扫描与分批

先看整体结构。任务每 10 分钟跑一次,每次最多处理 20 批、每批 500 条,跑完就退出,剩下的下一轮继续。这个设计是为了防止单次执行时间不可控——如果哪天积压了几百万条待过期数据,一次性跑完会占着连接池半小时,接口全部超时。

@Component @RequiredArgsConstructor public class PointsExpireJob { private final PointsLedgerMapper ledgerMapper; private final PointsExpireService expireService; /** 每 10 分钟一次,单轮最多 20 批,跑不完下一轮接着来 */ @Scheduled(cron = "0 0/10 * * * ?") public void expire() { long cursor = 0L; for (int round = 0; round < 20; round++) { // 用主键游标翻页,不用 OFFSET,深分页会越来越慢 List<PointsLedger> batch = ledgerMapper.selectExpireBatch(cursor, 500); if (batch.isEmpty()) { return; } for (PointsLedger ledger : batch) { // 每条独立事务,一条失败不影响其他条 expireService.expireOne(ledger.getId()); cursor = ledger.getId(); } } } }

分页用主键游标而不是LIMIT offset, size,这是必须的。OFFSET在百万级数据下会扫过前面所有行再丢弃,越翻越慢,最后整个任务卡死。用WHERE id > #{cursor} ORDER BY id ASC LIMIT 500,每次都能走主键索引,耗时稳定。

单条过期用独立事务:

@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void expireOne(long ledgerId) { PointsLedger ledger = ledgerMapper.selectById(ledgerId); // 双重检查:可能上一轮已经处理过,或者被扣减核销掉了 if (ledger.getStatus() != 1 || ledger.getRemain() <= 0) { return; } long amount = ledger.getRemain(); // 1. 扣账户,同样把余额判断写进 WHERE,防止扣成负数 int rows = accountMapper.expire(ledger.getUserId(), amount); if (rows == 0) { // 余额已经不够了,说明账本有异常,记下来人工处理,不要硬扣 alarmService.warn("expire_balance_mismatch", ledgerId); return; } // 2. 写一条过期流水,biz_no 用 "EXPIRE_" + ledgerId,天然幂等 ledgerMapper.insert(PointsLedger.ofExpire(ledger, amount)); // 3. 把原流水置为失效,remain 清零 ledgerMapper.markExpired(ledgerId); }

REQUIRES_NEW是关键。如果所有过期都在一个大事务里,中途任何一条抛异常,前面已经处理过的几百条全部回滚,下一轮又从头开始,永远跑不完。用独立事务,处理一条提交一条,失败的那条记录下来单独重试。

biz_no的构造也有讲究,用EXPIRE_加上原流水 ID,天然唯一。这样即使任务因为重启重复扫到同一条,插入流水时会被唯一索引挡下来,不会重复扣。

4.2 对账脚本:流水汇总和账户余额的差值

再严密的代码也可能因为一次手工改库、一次异常回滚不彻底而出问题。所以对账是必须的,而且要是能自动跑的对账,不能靠用户投诉才发现。

思路很简单:账户上的available应该等于所有有效流水按方向汇总的结果。不一致的就是有问题的账户。

-- 找出账户余额和流水汇总对不上的用户 SELECT a.user_id, a.available, IFNULL(SUM(CASE WHEN l.change_type = 1 THEN l.points -- 增加 WHEN l.change_type IN (2, 3) THEN -l.points -- 扣减、过期 ELSE 0 END), 0) AS calc_available FROM points_account a LEFT JOIN points_ledger l ON l.user_id = a.user_id AND l.status = 1 GROUP BY a.user_id, a.available HAVING a.available <> calc_available;

这条 SQL 在百万用户量级下跑不完,所以实际执行时按user_id取模分片,一次跑一个分片,结果落到points_check_result表里。任务每天凌晨两点执行,跑完把差异条数打到监控上,超过阈值就告警。

查出来差异之后怎么定位?看这个用户的流水balance_after是否连续:

SELECT id, biz_no, change_type, points, balance_after, created_at FROM points_ledger WHERE user_id = #{userId} AND status = 1 ORDER BY id ASC;

正常情况下,第 N 条的balance_after应该等于第 N-1 条的balance_after加减第 N 条的points。哪一条断了,问题就出在那一条附近,再结合biz_no去查业务日志,基本十分钟内能定位。

提示:对账脚本一定要能重复执行且不产生副作用。我一般把它写成纯查询加结果落表,绝不自动修数据。修复必须人工确认,因为差异可能来自业务上的合法操作,比如运营手工补发。

4.3 任务失败了怎么补

定时任务不可能永远顺利,机器重启、数据库连接抖动、发版打断,都会导致某一轮没跑完。这套代码用了两个补偿手段。

第一个是任务执行记录表。每轮开始写一条points_job_log,记录起始游标和时间,结束时更新状态。任务重启后先查上次的游标,从断点继续,而不是从 0 重新扫。这个细节能把重复处理量降到最低。

第二个是失败流水重试队列。expireOne里扣减失败的那条,写进points_expire_retry表并记上重试次数,另一个补偿任务每小时扫一次,重试次数超过 3 次就转人工。为什么不用消息队列重试?因为过期任务本身的幂等已经靠biz_no保证了,再引入 MQ 只会多一个可能出问题的组件。简单的事情不要复杂化。

手动补跑的入口也留了一个:

# 按时间范围补跑过期,用于发版期间任务中断后的补偿 curl -X POST 'http://localhost:8081/job/expire/repair' \ -H 'Content-Type: application/json' \ -d '{"from":"2024-05-01 00:00:00","to":"2024-05-02 00:00:00","dryRun":true}'

dryRun参数很重要,先跑一遍只统计不落库,确认影响范围再真正执行。我见过直接在生产上跑修复脚本把几十万用户余额清零的事故,加了dryRun至少能拦住一半的手快。

5. 避坑与排查:积分系统上线后最容易翻车的五个地方

代码写对只是及格线,真正让人睡不着觉的是那些「看起来没问题、量一上来就爆」的地方。下面五条都是这套代码在演进过程中真实踩过的坑,按「现象 → 原因 → 解决」写清楚,照着对照自己的项目基本能提前规避。

5.1 幂等只靠 Redis,服务重启就重复发放

现象:压测时正常,上线第二天发现部分订单积分发了两遍,重发的都是服务重启前后的那批请求。

原因:幂等用的是redis.setnx(bizNo, "1", 24h),容器发版时 Redis 里那批 key 还在,但新实例的连接池初始化有几百毫秒窗口,这期间的请求setnx抛异常被吞掉了,直接走到发放逻辑。更隐蔽的是 Redis 主从切换,主节点写入还没同步就宕机,key 直接丢了。

解决:Redis 只做前置快速失败,真正的幂等靠数据库唯一索引。points_ledger.biz_no上加UNIQUE KEY,捕获DuplicateKeyException后按正常返回处理。改完之后同样的重启场景再没复现过。

5.2 先查余额再扣减,并发下扣成负数

现象:大促当天有几十个账户余额变成负数,用户拿去兑换商品,等于白送。

原因:扣减逻辑写成了「查余额 → 判断够不够 → update 扣减」,三步之间没有锁。两个请求同时读到 500,各自判断 400 够扣,然后各自执行set available = 100,实际只扣了一次,但发出了两笔抵扣。

解决:把available >= #{points}写进UPDATE的WHERE条件,用影响行数判断成功与否。同时给账户表加CHECK (available >= 0)作为最后一道保险,虽然 MySQL 8 之前对 CHECK 支持不好,但至少能在测试环境暴露问题。

5.3 过期任务全表扫,把数据库拖垮

现象:任务上线第一周,每天凌晨两点数据库 CPU 打满,接口 P99 从 80ms 涨到 3s。

原因:过期任务写成了SELECT * FROM points_ledger WHERE expire_at <= NOW() AND remain > 0,一次把所有待过期数据捞进内存。数据量到两百万时,单次查询要几十秒,还占着几 GB 内存,GC 直接飙红。

解决:改主键游标分批,每批 500 条,单轮最多 20 批。同时在(expire_at, status, remain)上建联合索引,让查询能走索引范围扫描。改完之后任务实际耗时从 40 分钟降到 3 分钟以内,CPU 峰值降到 15%。

5.4 事务里发消息,回滚后下游多一条记录

现象:退款场景下,积分已经退回给用户了,但下游的等级计算服务收到两次消息,用户等级被多加了一级。

原因:applicationEventPublisher.publishEvent()写在@Transactional方法体里,事件是同步派发的,但监听器里做了 MQ 发送。事务后来说因为唯一索引冲突回滚了,MQ 消息已经发出去了,收不回来。

解决:所有消息发送挪到TransactionSynchronization.afterCommit()里,事务提交成功才发。更彻底的做法是本地消息表——消息和业务数据同一个事务落库,另一个任务扫表发送,发送成功标记状态。这套代码用的是前一种,因为消息量不大,出问题补偿也简单。

5.5 用 double 算积分,用户少拿一分

现象:用户反馈「我消费 199.9 元,按 10% 应该给 19 分,怎么只给了 19 分」,看着对,但另一批用户是 20 分,规则一样结果不一样。

原因:ratio字段早期用的DOUBLE,199.9 * 0.1算出来是19.989999999999998,(long)强转直接截断成 19。而200.0 * 0.1正好是 20.0,所以同样是「满 20 分」的用户,有人拿到有人拿不到。

解决:ratio改DECIMAL(10,4),Java 侧统一用BigDecimal,计算完setScale(0, RoundingMode.DOWN)明确取整方式。规则里把「向下取整」写死在代码里而不是靠默认行为,避免以后有人改成HALF_UP导致发放量整体上涨。

6. 二次开发:在现有积分账本上加兑换商城和等级体系

拿到这套代码之后,最常见的两个改造需求是「加积分兑换」和「加会员等级」。这两个需求看起来都是在现有基础上加功能,但动的地方完全不同。

积分兑换本质上是扣减的一种,只是扣完之后要对接商品和订单。我的建议是不要新写一套扣减逻辑,而是复用PointsDeductService,把scene_code设成EXCHANGE,兑换单号作为biz_no。这样幂等、并发控制、流水记录全部继承下来,只需要在兑换成功后调订单服务发货。反过来,如果另起一套「兑换专用扣减」,两套逻辑迟早会分叉,出问题时对账都对不明白。

会员等级则完全不该动账本。等级是「累计获得积分」的函数,不是「当前余额」的函数——用户花掉积分不应该掉级,否则体验极差。所以等级计算应该订阅发放流水,用一个独立的服务算,结果落一张user_level表。这套代码里points-application已经预留了PointsGrantedEvent,加个监听器就能接上。

改造点涉及表涉及类主要风险
积分兑换商品复用points_ledger、points_accountPointsDeductService、新增ExchangeOrderService兑换和扣减不在一个事务,出现扣了积分没发货
会员等级体系新增user_level、level_rule新增LevelCalculateListener用余额算等级,用户消费后掉级
积分抵现新增points_discount_configPointsDeductService、订单结算抵扣比例没上限,被薅羊毛
积分转赠points_ledger加peer_user_id新增PointsTransferService双向扣加没在同一事务,一方成功一方失败

改完之后怎么验证?我一般做三件事。第一,跑一遍全链路回归:下单 → 发放 → 抵扣 → 退款 → 过期,每步都查points_ledger的balance_after是否连续。第二,用对账 SQL 跑全量,差异必须为 0。第三,压测扣减接口,把并发调到 200,看有没有出现负数余额和乐观锁重试超限。

最后说个习惯。这套代码我第一次改的时候,图省事直接在deduct方法里加了一段「兑换场景特殊处理」的分支,结果三个月后另一个同事在发放侧改规则,完全没注意到这个分支,导致兑换用户的积分算错了一批。从那以后我每次动PointsAccount相关的类,都强制先跑一遍对账脚本再提交,宁可多花十分钟,也不想半夜被叫起来查账。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询