资讯动态

商城积分系统设计:账户一致性、幂等发放与并发抵扣

发布时间:2026/10/27 4:44:16 来源:尧图企业网站定制
简介商城积分系统是一套面向课程设计、毕业设计与.NET WebForms入门练习的完整源码包围绕会员积分获取、查询、兑换、商品销售与后台管理展开适合需要理解积分规则、数据库设计与Web页面交互的开发者参考。压缩包共93个文件、约2.31MB以35个C#源文件、13个aspx页面、12个ascx用户控件为主并含master母版、config配置、SQL Server数据库文件、界面图片和一份PPT说明目录按前台、后台、控件与数据层组织便于对照阅读。已有3096人学习下载。读者可从中获得会员注册登录、积分信息维护、积分兑换规则、商品销售记录、会员查询与后台管理等模块的实现样本也能借鉴积分有效期、等级特权、兑换流程与数据库表结构的设计思路适合做功能拆解、二次开发或答辩展示的基础项目。1. 商城积分系统到底在解决什么问题一笔订单背后的三次账户变动用户下单付款 199 元按规则返 199 积分一个月后他拿 200 积分抵了 2 块钱再后来这笔积分到期被系统静默回收。看起来只是三个数字的变化落到工程上却是三套完全不同的链路发放要算规则、要防重复抵扣要抢并发、要防超扣回收要定时扫、要按先进先出精确扣。商城积分系统做的就是把这三次变动记清楚、算准确、查得到。它适合正在给电商后台补会员能力的中小团队也适合想拿一个完整业务闭环练手事务、并发和消息幂等的开发者。别被“积分”两个字骗了它本质是一个轻量级的账户系统难点从来不在加法而在余额和流水永远对得上。2. 技术选型与工程结构从账户表到调度任务的一次落地拆分2.1 为什么是 Spring Boot MySQL Redis 消息队列这套组合积分系统的读写比大概是 20:1查询远多于写入但写入的每一次都要求强一致——余额算错一分钱客服第二天就得加班。所以存储层不能省MySQL 承账户余额和流水明细Redis 只做两件事缓存用户积分概览页的展示数据、以及抵扣时的并发预扣。常见做法是 Spring Boot 打底MyBatis-Plus 做单表 CRUDRocketMQ 或 RabbitMQ 承接下单成功事件XXL-Job 或 Spring 自带的Scheduled跑过期回收。这里有个取舍要讲清楚为什么不把余额只放 Redis因为 Redis 的持久化在极端情况下是有窗口的积分可以当钱花丢了就是资损。所以我一般把 Redis 定位成“加速层和限流层”真正的账在 MySQL 里Redis 里的值允许通过重算恢复。缓存这块也踩过坑早期把balance和frozen合成一个 key 存抵扣时先减 Redis 再减库中间服务重启一次缓存和数据库就对不上了后来老老实实改成“库为准、缓存带过期时间”。消息队列的作用不是解耦那么简单它解决的是“下单主链路不能被积分逻辑拖死”。下单服务只发一条order.paid事件就返回积分服务自己消费、自己算慢一点没关系但绝对不能因为积分服务挂了导致用户付不了款。2.2 目录结构按业务动作切包不按技术层切这个项目的代码结构我倾向于按“动作”分包而不是传统的 controller / service / mapper 三层平铺因为积分系统的业务动作非常明确平铺之后找代码全靠全局搜索。point-system ├── point-api # 对外暴露的接口层含 DTO 和参数校验 │ ├── PointQueryController.java │ └── PointDeductController.java ├── point-application # 业务流程编排 │ ├── grant/ # 发放规则计算、流水写入、余额变更 │ ├── deduct/ # 抵扣预扣、确认、回滚 │ ├── expire/ # 过期扫描、FIFO 核销 │ └── query/ # 查询余额、流水分页、可用积分 ├── point-domain # 领域对象与规则 │ ├── entity/ # PointAccount、PointFlow、PointRule │ └── rule/ # 规则解析器按场景分发 ├── point-infrastructure # 存储与外部依赖 │ ├── mapper/ │ ├── cache/ │ └── mq/ └── point-job # 定时任务入口 └── PointExpireJob.java这么切的直接好处是新人进来问“积分怎么扣的”你只要指deduct包就行不用在三个层之间来回跳。代价是包之间会有一些重复的 DTO 转换我认为这笔账划算。2.3 三张核心表账户、流水、规则积分系统的表设计原则是“流水是事实账户是快照”。账户表可以被流水分毫不差地重算出来这条原则决定了后面所有的对账逻辑。-- 积分账户一个用户一条balance 是冗余快照 CREATE TABLE point_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, balance INT NOT NULL DEFAULT 0 COMMENT 当前可用余额, frozen INT NOT NULL DEFAULT 0 COMMENT 冻结中抵扣未确认, total_earned INT NOT NULL DEFAULT 0 COMMENT 累计获得只增不减, total_used INT NOT NULL DEFAULT 0 COMMENT 累计消耗, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user (user_id) ) COMMENT 积分账户; -- 积分流水每一条变动都留痕永不物理删除 CREATE TABLE point_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flow_no VARCHAR(64) NOT NULL COMMENT 流水号业务侧生成, user_id BIGINT NOT NULL, change_amount INT NOT NULL COMMENT 正数入账负数出账, flow_type TINYINT NOT NULL COMMENT 1获得 2消耗 3过期 4回滚, biz_no VARCHAR(64) NOT NULL COMMENT 业务单号用于幂等, balance_after INT NOT NULL COMMENT 变动后余额快照, expire_at DATETIME NULL COMMENT 该笔积分的过期时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0有效 1已核销, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_no, flow_type), KEY idx_user_expire (user_id, status, expire_at) ) COMMENT 积分流水; -- 积分规则按场景配置支持热更新 CREATE TABLE point_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scene VARCHAR(32) NOT NULL COMMENT 场景ORDER_PAID/REVIEW/SIGN_IN, ratio DECIMAL(10,4) NOT NULL COMMENT 每元返多少分, max_limit INT NOT NULL DEFAULT 0 COMMENT 单次上限0 表示不限, valid_days INT NOT NULL DEFAULT 365 COMMENT 有效期天数, enabled TINYINT NOT NULL DEFAULT 1 ) COMMENT 积分规则配置;三张表的分工很清晰point_rule决定“给多少”point_flow记录“给了没、什么时候过期”point_account提供“还剩多少”的快速读取。注意uk_biz这个唯一索引它是后面所有幂等逻辑的最终兜底也是防止重复发放的最后一道闸门。2.4 配置项把容易写死的数字挪出来启动前要确认的几项配置建议全部外置到配置中心或application.yml不要硬编码在代码里配置项说明建议值point.grant.max-retry消息消费失败重试次数3point.expire.batch-size过期任务单批扫描条数500point.expire.cron过期任务执行时间每天凌晨 2 点point.deduct.freeze-minutes抵扣冻结超时自动释放15point.cache.ttl-seconds账户缓存过期时间60freeze-minutes这个值需要留意它大于下单页面的会话超时时间即可。设得太短用户还在付款页犹豫冻结已经释放回头再抵扣就容易出现“明明显示够、点下去不够”的体验问题。3. 核心链路拆解积分发放、抵扣扣减与过期回收的代码实现3.1 积分发放先写流水再改余额发放是积分系统里最常被调用的方法也是重复发放事故的高发区。我的写法固定为“先插流水、再动账户”因为流水有唯一索引冲突会直接抛异常天然挡住重复请求反过来先改余额一旦插流水失败余额就多出来了只能靠对账兜。Service public class PointGrantService { Resource private PointRuleResolver ruleResolver; Resource private PointFlowMapper flowMapper; Resource private PointAccountMapper accountMapper; /** * 发放积分整个过程在一个本地事务里 * param cmd 包含用户ID、业务单号、订单金额、场景 */ Transactional(rollbackFor Exception.class) public void grant(GrantCmd cmd) { // 1. 按场景解析规则算出本次应发积分 PointRule rule ruleResolver.resolve(cmd.getScene()); int points rule.calc(cmd.getAmount()); if (points 0) { return; // 金额太小或规则关闭直接返回不产生流水 } // 2. 写入流水uk_biz 唯一索引负责幂等 // 重复消费时这里会抛 DuplicateKeyException由上层捕获后忽略 PointFlow flow new PointFlow(); flow.setFlowNo(IdGen.next(G)); flow.setUserId(cmd.getUserId()); flow.setChangeAmount(points); flow.setFlowType(FlowType.EARN.getCode()); flow.setBizNo(cmd.getBizNo()); flow.setBalanceAfter(0); // 先占位改完余额再回填 flow.setExpireAt(LocalDateTime.now().plusDays(rule.getValidDays())); flowMapper.insert(flow); // 3. 账户可能还不存在先保证有一行 accountMapper.initIfAbsent(cmd.getUserId()); // 4. 原子累加余额不读出来再写回去 int rows accountMapper.increase(cmd.getUserId(), points); if (rows ! 1) { throw new IllegalStateException(积分入账失败, userId cmd.getUserId()); } // 5. 回填变动后余额方便前端按流水展示 int balanceAfter accountMapper.selectBalance(cmd.getUserId()); flowMapper.updateBalanceAfter(flow.getId(), balanceAfter); } }rule.calc()里只做乘法和上限截断不碰数据库这样规则可以被单元测试完整覆盖。第 4 步是关键increase用的是balance balance #{points}这种数据库内计算而不是应用层先查后写避免了典型的丢失更新。第 5 步的回填在同一个事务里如果回填失败整体回滚不会出现“有流水、余额对不上”的中间态。3.2 幂等控制唯一索引兜底业务层只做友好提示消息队列至少投递一次重复消费是常态。我的处理是让数据库唯一索引当最终裁判业务层只负责把它翻译成可读的结果。Component public class OrderPaidListener implements MessageListener { Resource private PointGrantService grantService; Override public void onMessage(OrderPaidEvent event) { GrantCmd cmd new GrantCmd(); cmd.setUserId(event.getUserId()); cmd.setBizNo(event.getOrderNo()); // 订单号作为幂等键 cmd.setAmount(event.getPayAmount()); cmd.setScene(ORDER_PAID); try { grantService.grant(cmd); } catch (DuplicateKeyException e) { // 已经发过了直接确认消费不要重试 log.info(重复的积分发放事件, orderNo{}, event.getOrderNo()); } } }这里有个细节DuplicateKeyException必须捕获后正常返回不能往外抛。一旦抛出去MQ 会认为消费失败并重试重试还是冲突几轮之后消息进死信队列需要人工处理纯属给自己找麻烦。另外幂等键要选业务上唯一的字段订单号、评价 ID 都是好选择别用时间戳或者 UUID那等于没有幂等。3.3 积分抵扣冻结先行确认后扣超时自动回滚抵扣的并发风险比发放高一个量级用户频繁点击、页面重试、多端同时下单都会撞在一起。直接balance - points加balance points条件虽然能防超扣但挡不住“冻结了却没付款”的悬挂。所以流程拆成三步冻结、确认、取消。Service public class PointDeductService { Resource private PointAccountMapper accountMapper; Resource private PointFlowMapper flowMapper; /** * 第一步冻结积分不真正扣减 */ Transactional(rollbackFor Exception.class) public String freeze(FreezeCmd cmd) { // CAS 更新余额足够才冻条件写在 WHERE 里由数据库保证原子性 int rows accountMapper.freeze(cmd.getUserId(), cmd.getPoints()); if (rows 0) { throw new BizException(可用积分不足); } String freezeNo IdGen.next(F); flowMapper.insertFreezeFlow(freezeNo, cmd.getUserId(), cmd.getPoints(), cmd.getBizNo()); return freezeNo; // 返回给前端后续凭此确认或取消 } /** * 第二步支付成功后确认扣减 */ Transactional(rollbackFor Exception.class) public void confirm(String freezeNo) { FreezeRecord record flowMapper.selectByFreezeNo(freezeNo); if (record null || record.getStatus() ! 0) { return; // 已处理过幂等返回 } // 冻结转实扣frozen 减、balance 减一次搞定 accountMapper.confirmDeduct(record.getUserId(), record.getPoints()); flowMapper.markConfirmed(freezeNo); } }配合下面这条 SQLfrozen points balance这个条件保证了并发下不会出现冻结金额超过余额的情况-- 冻结余额足够才允许冻结 UPDATE point_account SET frozen frozen #{points}, version version 1 WHERE user_id #{userId} AND balance - frozen #{points};冻结记录需要一个超时释放机制用延迟消息或者定时任务扫status 0 且 create_time now - 15min的记录把frozen减回去。这条兜底链路千万不能省否则用户取消订单后积分会被永久锁死客服工单会堆成山。3.4 过期回收分批扫描 按流水逐条核销过期不能按账户总额一刀切因为用户的积分是分批进来的有效期各不相同必须按流水先进先出核销。实现上分两步先扫出到期的有效流水再逐条扣减并标记已核销。Component public class PointExpireJob { Resource private PointFlowMapper flowMapper; Resource private PointAccountMapper accountMapper; /** 每天凌晨执行单批 500 条循环直到扫完 */ Scheduled(cron 0 0 2 * * ?) public void expire() { while (true) { // 按过期时间升序取一批先到期的先处理 ListPointFlow batch flowMapper.selectExpired(batchSize(500)); if (batch.isEmpty()) { break; } for (PointFlow flow : batch) { // 只扣用户当前还剩的部分余额不足时扣到 0 为止 int deduct Math.min(flow.getChangeAmount(), accountMapper.selectAvailable(flow.getUserId())); if (deduct 0) { accountMapper.decrease(flow.getUserId(), deduct); flowMapper.insertExpireFlow(flow, deduct); } flowMapper.markUsed(flow.getId()); // 标记已核销不再重复扫 } } } }selectExpired的 SQL 要带上status 0和expire_at NOW()走idx_user_expire索引避免全表扫。分批的意义在于控制单次事务时长一批 500 条大概几十毫秒不会长时间持锁影响正常抵扣。另外过期也要写一条flow_type 3的负向流水这样账户余额随时可以由流水重算验证不用担心哪天对不上账查不出原因。4. 排查与避坑积分系统上线后最容易翻车的五处4.1 余额和流水对不上差几分钱现象运营对账时发现某个用户的balance比流水汇总多出 3 分用户自己也说不清什么时候多的。原因早期有一版代码在发放时先select出余额、在 Java 里加完再update回写两个并发请求同时读到旧值后写的覆盖了先写的。这种丢失更新在低并发下几乎测不出来上线后大促一压就暴露。解决所有余额变更一律走数据库内计算balance balance #{delta}禁止应用层读出再写回。同时写一个对账脚本每天扫point_account和point_flow的汇总差异差值超过阈值的用户进告警列表。这个脚本我建议做成可以随时手动触发出问题的时候它是唯一的黑匣子。4.2 并发抵扣出现超扣余额被扣成负数现象两个浏览器标签同时提交订单都用了 100 积分账户里只有 150结果两单都抵扣成功余额变成 -50。原因扣减前先查了一次余额判断“够不够”然后才执行更新。查询和更新之间存在时间窗两个请求都通过了检查。解决把余额判断塞进WHERE条件里让数据库的行锁来保证原子性UPDATE ... WHERE balance - frozen #{points}受影响行数为 0 就说明不够。别在应用层做这个判断也别指望 Redis 的分布式锁能救你锁粒度一大性能就垮而且锁续期又是新问题。4.3 过期任务把刚到账的积分提前吃掉了现象用户昨天刚获得的积分今天登录发现被清零投诉说系统偷积分。原因expire_at字段在发放时没有赋值数据库默认 NULL而扫描 SQL 写的是expire_at NOW()某些数据库对 NULL 的比较行为不符合直觉加上任务里没过滤status把新流水也扫进去了。解决发放时强制写入expire_at落在point_rule.valid_days上扫描 SQL 显式加expire_at IS NOT NULL AND status 0。任务上线前先在测试库造一批历史数据跑一遍把处理前后的余额和流水条数都打日志确认无误再上生产。这类任务一旦误删数据基本没有后悔药。4.4 改了规则比例历史订单重算结果全变了现象运营把返积分比例从 1% 调到 2%结果有开发同学重跑了一遍历史发放任务老订单全部按新比例补发了积分。原因规则只存了当前值没有版本概念重算时读到的永远是配置表里的最新一行。解决point_rule加version和effective_time发放时把当时用的rule_id或比例快照写进流水表备注字段。重算只针对没有流水的订单已有流水的一律跳过——流水的唯一索引本来就该挡住这种重放前提是幂等键没写错。4.5 消息堆积积分延迟到账现象大促期间用户付款半小时后积分还没到客服电话被打爆。原因积分消费线程池太小单条消费里还夹了一次远程调用查规则吞吐上不去加上消费失败重试没有上限个别毒消息把队列堵了。解决规则解析结果本地缓存 5 分钟消费逻辑里只留数据库操作重试次数配成 3 次超过就落库到异常表异步补偿不要死磕。监控上加消费延迟和堆积量两个指标超过阈值直接告警到值班群别等用户投诉才知道。5. 二次开发把返积分比例换掉只是开始拿到这套代码之后最常见的改造需求是“我们的积分规则不一样”。如果只是想改比例和有效期直接改point_rule表就行PointRuleResolver会按scene取到新配置不用动代码。但如果你要做的是阶梯返积分、会员等级加成、积分现金混合支付那就得动point-domain/rule这一层。我的做法是定义一个规则接口把“算多少”和“能不能用”拆开public interface PointRuleHandler { /** 该处理器负责的场景 */ String scene(); /** 计算本次应发积分 */ int calc(GrantContext ctx); /** 校验本次抵扣是否合法比如最低消费门槛 */ boolean checkDeduct(DeductContext ctx); }阶梯返积分就新写一个实现按订单金额分档返回不同积分注册进 Spring 容器后由scene分发原来的代码一行不用改。会员等级加成同理在calc里读一下用户的等级倍率即可。改造时有三点要盯住。第一新规则上线前用灰度开关控制先放 1% 的订单走新逻辑对比新旧两条链路的流水差异差值必须为 0 才算通过。第二任何涉及“补发”或“重算”的脚本必须先在从库上跑一遍把预计影响的用户数和积分总额导出来人工核对确认无误再在主库执行。第三改造完毕后跑一次全量对账sum(point_flow.change_amount)和point_account.balance必须严格相等一个用户都不能放过。我印象最深的一次是给一个活动加了“积分翻倍”规则写得没问题但忘了在扣减侧同步上限结果用户用翻倍得来的积分抵扣时超出了单笔上限订单直接卡在支付回调里。从那以后我每次改规则都会把发放和抵扣两条链路一起过一遍并且强制跑一遍对账脚本才允许上线。积分这东西用户平时不关注一旦出错就是信任问题。希望这套结构能帮到你少走几段弯路。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑