资讯动态

商城积分系统设计:账本模型、幂等控制与并发扣减的工程实践

发布时间:2026/10/20 5:42:52 来源:尧图企业网站定制
简介这是一套面向商城积分业务的ASP.NET项目源码与学习资料适合正在做会员体系或毕业设计的学生、初级开发人员参考。资源围绕积分获取、积分管理、商品兑换、会员等级与后台维护等核心模块展开配套数据库、页面和配置文件可帮助理解电商会员积分系统的完整实现流程。包体共93个文件以cs后端逻辑、aspx/ascx页面与用户控件、config配置文件为主另含mdf/ldf数据库备份和演示PPT整体仅2.31MB便于下载与本地部署。已有3095人浏览学习。通过源码可掌握积分发放与扣减算法、兑换规则设置、会员特权升级、后台数据统计等关键实现也能借鉴其目录结构与界面设计直接用于课程设计或小型商城项目改造。1. 商城积分系统不是记数字它是一套需要幂等与对账的资产账本拿到“商城积分系统”需求时多数人第一反应是把一张user_id, points表做增删改查。可你真的按这个思路上线等第一个大促节点就会遇到一个诡异现场用户积分后台显示对账不平同一笔订单被上游回调通知了三次积分就发了三份。积分系统本质是一套资产账本不是记分牌。注册送、签到送、下单返只是入口真正决定它能跑多久的是账户模型、流水约束、幂等控制和扣减条件。我在拆这套商城积分系统源码时把完整链路从头到尾过了一遍这篇笔记就讲清楚这四件事怎么做、参数怎么调、哪里有坑。2. 先把账本设计想清楚四张表和两条线的边界约束积分账本的核心是“账户余额”和“明细流水”两条线。余额线负责快照当前可用值流水线负责记录每一次变动。两份数据必须能互相校验而不是靠某一张表单独支撑。我拆这套源码包时先是把sql/init.sql里的建表语句翻了一遍整理出的核心表有账户表、明细流水表、规则表和批次表下面逐个说设计意图。2.1 账户表为什么“可用”和“冻结”必须拆成两个字段积分消费最怕业务做了一半最后订单取消积分被扣了又得补回来。如果用“先扣全部再回补”的做法流程里一旦中间环节断掉积分就丢了。源码包里账户表用了可用积分与冻结积分两个字段把“锁定中”和“可花”分隔开。CREATE TABLE t_point_account ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID多租户场景建议再联合tenant_id, available bigint NOT NULL DEFAULT 0 COMMENT 可用积分参与扣减判断的唯一余额, frozen bigint NOT NULL DEFAULT 0 COMMENT 冻结积分抵现发起后订单未确认前占用, total_earn bigint NOT NULL DEFAULT 0 COMMENT 累计获得积分只累加不减, total_spend bigint NOT NULL DEFAULT 0 COMMENT 累计消耗积分只累加不减, status tinyint NOT NULL DEFAULT 1 COMMENT 账户状态1正常0冻结, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号并发更新时作为安全条件, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user (user_id) ) ENGINEInnoDB;逻辑说明所有扣减动作只判断available冻结部分不参与消费判断。total_earn和total_spend不作为余额计算依据只给后台审计提供只读指标每次变动都通过更新 SQL 在同一行完成。version字段在并发更新时参与条件判断比如更新时带上version #{oldVersion}确保自己修改的不是别人已经改过的数据。参数说明积分字段我建议直接用bigint虽然看起来浪费但业务一旦发到上千万int的 21 亿上限并不遥远。status字段保留 0 值用于风控冻结用户账户不要删除字段否则后续出问题只能靠删数据。2.2 流水表不可变记账用唯一键挡住重复请求流水是积分系统的审计底座。每次余额变化都要写一条流水业务单号在流水表里必须唯一否则重复请求就是漏洞。CREATE TABLE t_point_detail ( id bigint NOT NULL AUTO_INCREMENT, detail_no varchar(40) NOT NULL COMMENT 流水号建议雪花ID或日期自增组合, user_id bigint NOT NULL, biz_type varchar(20) NOT NULL COMMENT 业务类型EARN_ORDER/EARN_SIGN/CONSUME_REDEEM/EXPIRE/FREEZE/UNFREEZE, biz_id varchar(64) NOT NULL COMMENT 外部业务单号如订单号、签到日期、活动编号, change_amount bigint NOT NULL COMMENT 变化量正数为增加负数为扣减, balance_after bigint NOT NULL COMMENT 操作完成后可用积分快照, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_biz (biz_type, biz_id), KEY idx_user_time (user_id, create_time) ) ENGINEInnoDB;逻辑说明uk_biz唯一索引是幂等控制的第一道闸。同一订单编号重复发送发放消息时第二次插入直接触发唯一键冲突业务层捕获后正常返回。balance_after字段记的是变动后的可用积分快照这是后面写对账脚本时最核心的对照值。流水表不允许做更新操作如果发现写错了纠正方式是再写一条冲正流水而不是改原纪录。这个规矩能保证流水永远是历史事实不会被事后篡改污染对账结果。2.3 规则表返积分比例做成配置不写死在代码里商城积分系统的积分规则变化频繁大促期间运营可能要调整返积分比例开发不应该为改一个数字发一次版。源码包里把规则拆到了t_point_rule表并对接了一个简单的规则匹配引擎。CREATE TABLE t_point_rule ( rule_id bigint NOT NULL AUTO_INCREMENT, rule_type varchar(30) NOT NULL COMMENT 规则类型SIGN/ORDER/REGISTER/ACTIVITY, base_line decimal(10,2) NOT NULL COMMENT 触发基线例如实付满100元才参与返积分, rate decimal(6,4) NOT NULL COMMENT 返积分比例0.0100代表1%即100元返1积分, upper_limit bigint NOT NULL DEFAULT 0 COMMENT 单笔返积分上限0表示不限, status tinyint NOT NULL DEFAULT 1, create_time datetime NOT NULL, PRIMARY KEY (rule_id) ) ENGINEInnoDB;规则匹配伪代码如下这段逻辑在源码包的rule-engine模块里可以直接看到public long computeEarn(long userId, BigDecimal orderAmount) { PointRule rule ruleRepository.matchRule(ORDER, userId); if (rule null) { return 0L; } if (orderAmount.compareTo(rule.getBaseLine()) 0) { return 0L; } BigDecimal earn orderAmount.multiply(rule.getRate()).setScale(0, RoundingMode.DOWN); if (rule.getUpperLimit() 0) { return Math.min(earn.longValue(), rule.getUpperLimit()); } return earn.longValue(); }逻辑说明base_line做门槛过滤实际返积分计算按订单金额与rate相乘最终用向下取整。这样做的好处是积分永远是整数不会出现 100.5 分的情况。upper_limit控制单笔返积分的上限避免大额订单一次性把预算打穿。2.4 有效期与批次积分不是总额是不同过期时间的资产包很多系统把过期时间放在账户表上这会让所有积分享用同一个过期时间不符合实际业务。常见做法是在账户与流水之间加一张积分批次表按批次管理有效期消费时优先消耗最早过期的批次也就是先进先出。CREATE TABLE t_point_batch ( batch_id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, available_amount bigint NOT NULL COMMENT 本批次剩余可用, total_amount bigint NOT NULL COMMENT 本批次原始积分总量, expire_time datetime NOT NULL COMMENT 本批次过期时间, status tinyint NOT NULL DEFAULT 1 COMMENT 1可用2已过期3已耗尽, PRIMARY KEY (batch_id), KEY idx_user_expire (user_id, expire_time, status) ) ENGINEInnoDB;逻辑说明每次发放积分往批次表插入一行每次消费按expire_time升序逐批扣减。这种方式能在过期任务里轻松定位“哪些批次已过期但还有可用量”避免用一条用户级expire_time字段把所有积分一刀切。如果业务量不大不想引入批次表也可以在消费时按时间段聚合计算但代码复杂度会上来不少。3. 核心链路下单送积分的幂等抵现扣减的并发控制账本结构定好之后重点落在两条主链路上积分怎么发出去积分怎么扣得安全。这两条链路覆盖了商城积分系统里 80% 的并发场景。3.1 下单送积分消息驱动先写流水再更账户积分发放如果直接放在订单支付回调里同步执行回调重试时就会重复发放。源码包里用了消费订单支付成功消息的方式把发放逻辑做成消息消费者利用流水表的唯一键挡掉重复消息。Component Slf4j public class OrderPaidConsumer { Autowired private PointDetailMapper pointDetailMapper; Autowired private PointAccountMapper pointAccountMapper; Autowired private RuleEngineService ruleEngineService; Transactional(rollbackFor Exception.class) public void onOrderPaid(OrderPaidEvent event) { long earn ruleEngineService.computeEarn( event.getUserId(), event.getOrderAmount()); if (earn 0) { return; } PointDetail detail new PointDetail(); detail.setDetailNo(snowflake.next()); detail.setUserId(event.getUserId()); detail.setBizType(EARN_ORDER); detail.setBizId(event.getOrderNo()); detail.setChangeAmount(earn); try { pointDetailMapper.insert(detail); } catch (DuplicateKeyException e) { // 同一个订单号已经处理过重复消息直接忽略 return; } int rows pointAccountMapper.addAvailableAndTotalEarn( event.getUserId(), earn); if (rows ! 1) { throw new IllegalStateException(account update failed); } } }逻辑说明核心顺序是先插流水再更新账户。流水插入成功代表这条业务单号从未被处理过账户更新失败时抛异常回滚消息队列重新投递后流水插入再次触发唯一键冲突直接返回不会重复加积分。如果先更新账户再写流水账户更新成功但流水插入失败账就永远对不上了。参数说明EARN_ORDER是业务类型orderNo是幂等键两者组合成唯一索引。消息队列里的业务 ID 一定要用订单号不能用消息自身的 messageId因为上游重试时每次生成的 messageId 都不同无法做幂等。3.2 抵现扣减把“余量足够”写进 update 条件积分抵现走的是扣减链路最容易出现的问题是用户在高并发场景下两次请求同时读到余额足够最后扣成负数。常见做法是不用 select 判断余额再 update而是把余额判断直接写进 update 语句的条件里。UPDATE t_point_account SET available available - #{cost}, total_spend total_spend #{cost}, version version 1, update_time NOW() WHERE user_id #{userId} AND available #{cost} AND status 1;逻辑说明available #{cost}是执行时的真实判断条件数据库行锁会让第二个并发请求在第一个请求提交后再执行此时available已经被扣减条件不满足影响行数返回 0。代码层拿到返回 0就知道这次扣减失败返回“积分不足”即可不需要分布式锁。参数说明cost必须是正整数业务层要拦截传入负数的情况。如果走冻结逻辑那么这一步是先把可用积分转入冻结积分SQL 条件仍然是available cost但扣减字段要换成同步扣available并加frozen。3.3 并发热点用户的 Redis 预扣快路径与最终结果分离积分扣减如果只走数据库热点用户在秒杀场景下会产生大量行锁等待。进阶做法是把可用余额在 Redis 里放一份扣减先走 Lua 脚本再把结果异步落库。源码包的redis-acc模块实现了这段脚本。-- KEYS[1] 是用户积分账户的Redis Key -- ARGV[1] 是本次要扣减的积分数量 local key point:account: .. KEYS[1] local available tonumber(redis.call(HGET, key, available)) if not available or available tonumber(ARGV[1]) then return 0 end redis.call(HINCRBY, key, available, -tonumber(ARGV[1])) return 1逻辑说明Lua 脚本在 Redis 里是原子执行的不会出现两个请求同时通过判断的情况。扣减成功后业务再把扣减明细写入数据库并对账户表做最终扣减。Redis 里的值只作为热路径预扣最终以 MySQL 流水为准。参数说明这个方案适合高净值、高频操作的用户。Redis 与 MySQL 的数据一致性要额外做补偿任务每 5 分钟扫描一次差异把 Redis 值与数据库流水汇总结果对齐。如果你们是中小型商城QPS 没到单用户每秒几次的规模没有必上 Redis直接更新数据库反而更可靠。3.4 过期清理把过期当成一条扣减流水处理积分过期不是简单的字段置零它是一个业务行为需要产生流水让用户后台能看到“本期过期xx积分”。源码包的expire-job模块跑一个定时任务找出已过期批次按用户维度生成 EXPIRE 流水并扣减余额。public void doExpire() { ListPointBatch expiredBatches batchMapper.listExpired( LocalDateTime.now(), PAGE_SIZE); for (PointBatch batch : expiredBatches) { if (batch.getAvailableAmount() 0) { batchMapper.markExpired(batch.getBatchId()); continue; } try { expireService.expireBatch(batch); } catch (DuplicateKeyException e) { // 该批次过期流水已生成跳过 } } }逻辑说明清理任务跑批时必须分页处理否则单次查询数据量过大任务执行时间一长下一次任务叠上来又把同一批数据处理一遍。每一批数据生成 EXPIRE 流水biz_id用批次号靠流水表唯一键保证重复跑批不会重复扣积分。参数说明过期时间建议统一用expire_time now()不要用equals或者按自然日截断避免数据库时间和应用服务器时间存在秒级偏差导致跨零点边界漏扫。4. 积分系统的四个常见问题现象、根因与排查顺序这一章是从实际运行中踩过的坑整理出来的四个问题基本覆盖了积分系统上线后最容易翻车的场景。每一条都按“现象、原因、解决”来展开可以直接对照排查。4.1 重复发放同一个订单给用户加了两次积分现象用户反馈账户里积分多了后台按订单号查积分明细发现同一个订单号存在两条EARN_ORDER流水。原因支付回调接口被上游重复通知或者补偿任务扫到了已经发放过的订单。发放逻辑里没有把订单号做成唯一键单纯靠判断订单支付状态并不可靠因为状态在两次通知之间可能都处于“已支付”。解决给流水表加(biz_type, biz_id)唯一索引发放逻辑必须把“写流水”放在最前面。第二次插入触发DuplicateKeyException时直接忽略。补偿任务即使重复扫描也无法在流水表插进第二条数据。4.2 并发抵现导致可用积分变负数现象一个用户在两台设备同时下单都选择积分抵现最后账户available余额变成负数。原因代码用了“先查询余额判断足够再更新扣减”的三段式逻辑。两个并发请求同时读出可用积分 120都认为抵现 100 可以执行先后更新时就出现了一 20。解决把余额判断写进 update 语句的条件里WHERE available #{cost}。数据库行锁保证第二个请求执行时重新检查条件它看到余额不足就返回 0不会扣成负数。这是一个语法层面的改动却能彻底解决读改写竞态。4.3 过期积分像薛定谔的猫有时能用有时下一秒才清零现象用户看积分有效期当天还可以用但下单抵扣时提示积分不足第二天发现积分被清空了后台流水却没有对应记录。原因过期清理任务与用户实时查询不在同一条时间线上。清理任务按expire_time now()扫描但任务运行时用户正好在下单读取的余额还没来得及被清理任务处理。两边都对但给用户的感觉就是积分“被偷走”了。解决在用户实时积分查询与消费接口里加一道“懒过期”逻辑先检查最近批次是否已过expire_time如果是则先执行过期扣减再返回剩余可用积分。这样清理任务负责批量兜底实时接口负责精确兜底两边数据不会打架。4.4 对账不平可用余额与流水累计值对不上现象每日对账任务发现部分用户available与SUM(change_amount)差值对不上多一笔或少一笔。原因最常见的是事务边界没包住。账户更新成功但流水插入失败或者反过来。事务只包住了更新语句流水表插入放在事务外。解决将“写流水”和“改余额”收敛到同一个Transactional方法里两个操作要么都成功要么都回滚。对已经脏掉的数据写一个对账修复脚本按用户汇总流水反推账户应得余额再按差值生成一条ADJUST类型流水修正。这个修正流水要把balance_after设置为修正后的快照值。5. 把“能用”升级成“稳”对账脚本与并发压测参数最后分享一个我每次上线前都会执行的验证习惯。先把积分对账脚本放进定时任务每天凌晨跑一次把所有对不上的用户捞出来。这里的关键是 SQL 怎么写得更高效避免全表关联把库拖垮。SELECT a.user_id, a.available AS acct_available, COALESCE(d.calc_balance, 0) AS calc_balance FROM t_point_account a LEFT JOIN ( SELECT user_id, SUM(change_amount) AS calc_balance FROM t_point_detail WHERE create_time #{startTime} GROUP BY user_id ) d ON a.user_id d.user_id WHERE a.available COALESCE(d.calc_balance, 0) LIMIT 100;逻辑说明这条 SQL 统计的是时间窗口内的流量差。如果账户是从零开始建号的窗口起始时间取账户创建时间即可如果系统迁移过老数据则要在calc_balance上加上期初导入余额。对账结果不为空时优先检查最近一小时有没有跑批任务或补偿脚本在执行。压测参数方面我用过简单有效的组合一台 4 核 8G 的服务器节点并发线程 50每个线程循环 1000 次扣减接口调用。重点不是看 QPS 有多高而是看压测结束后数据库里有没有出现负数余额或者流水缺失。压测时不要只并发不同用户要专门用同一用户反复砸接口这才是最容易暴露竞态的场景。如果需要验证幂等效果可以直接把订单支付消息重复投递两次或三次观察积分是否只增加了一次。这套源码包里带了一个 mock 订单回调工具改一下配置就能模拟重复通知。我第一次做商城积分系统时图省事把发积分逻辑直接写在支付回调里没有做消息队列和幂等设计。结果上线第二天就遇到上游重复通知导致一批订单积分翻倍只能写脚本批量修正。从那以后我每次给积分相关代码上线都强制先跑一遍对账脚本和同一用户的并发压测两道全绿再切流量。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑