ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

积分制零食自选平台026源码剖析:积分商城并发扣减与幂等设计

2026/9/9 9:44:07 拓冰建站 浏览量
积分制零食自选平台026源码剖析:积分商城并发扣减与幂等设计 第一次把积分制零食自选平台 026 这套源码完整跑起来的时候我其实有点不以为然——积分商城嘛听起来就是商品表、订单表、积分表三板斧。但真正把业务流程走完两遍又用压测工具模拟了 200 人同时兑换的动作之后我发现这个项目最值钱的部分根本不在“商城”这两个字而在积分余额、库存水位、订单状态这三者之间的强一致协调。这篇文章不打算做泛泛的功能罗列而是把我梳理 026 版本源码时的完整思路、数据库设计、并发扣减方案、防刷治理以及本地部署步骤一次性讲透适合正准备做积分商城、企业福利平台、零食自选柜系统的后端开发同学拿来当参考。1. 业务模型拆解积分从哪来自选流程怎么走1.1 积分来源与消耗的完整闭环积分制系统的第一课是先把积分的“来龙去脉”定义清楚。026 版本里的积分来源分为四类每日签到、运营任务、消费返利、活动赠送。每一条来源都会走统一的 points_rule规则表规则表里存着积分类型、分值、每日次数上限、启用状态。这样设计的好处是运营想改积分策略时不用改代码后台改一条配置即可。积分消耗的出口则相对集中零食自选兑换。用户用积分余额在下单时充当“虚拟货币”整单不涉及现金支付所以这个平台的订单表比电商订单简单不少省掉了支付单、退款单那一大套但也正因如此积分扣减的准确性就成了整个系统最不能出错的地方因为积分一旦多发或重扣用户立刻就能感知到不像现金支付还能走退款流程兜底。1.2 “自选”不是普通商品兑换很多新手会把“自选平台”理解成“商品列表加购物车”其实 026 版本的产品逻辑要更细一层。它的核心是平台先给用户一个积分预算额度比如本周福利 50 积分用户在这个额度内自由组合零食系统实时计算已选商品的总积分实时校验是否超预算最终一次性生成一张“自选兑换单”。这里的自选单和购物车订单的差异在于自选单本身不涉及商品价格只涉及积分价格而且同一个商品可能在不同活动周期里有不同的积分定价所以商品和积分价格是分开管理的。源码里通过goods表和goods_points_config表解耦积分价单独存一张表改价不影响商品主数据这个设计在实际运营中非常实用因为零食的积分定价经常随库存和效期波动。1.3 026 版本的迭代重心从版本号能看出来026 至少经历了二十六轮迭代。前十几版基本都在加功能、调前端样式而从 020 之后代码层面明显把重心转向了稳定性从 commit 记录里能看到“修复高并发下重复扣减积分”“新增每日对账任务”“兑换接口幂等改造”这类提交。这其实也反映了大多数业务系统的真实演进路径——功能永远是最容易做的难的是在并发上来之后还能保持数据一致。你在读源码时会发现service层里有大量的synchronized、update ... where balance 、request_no唯一索引这些都是后来补进去的“补丁”而恰恰是这些“补丁”才是这套源码的精华。2. 技术选型逻辑为什么是单体 Spring Boot 而不是微服务2.1 后端框架与核心组件026 版本的技术栈是 Spring Boot 3.2 MyBatis-Plus 3.5 MySQL 8.0 Redis 6.2 RabbitMQ 3.12JDK 要求 17。选 Spring Boot 单体而不是微服务理由非常实际这个平台的业务体量撑不起微服务的复杂度单体应用一个事务就能搞定订单和积分扣减拆成微服务反而要引入分布式事务增加成倍的故障点。选 MyBatis-Plus 而不是 JPA是因为项目里有大量复杂的库存更新 SQL 和对账 SQLMyBatis 的 SQL 可控性更好。积分扣减那段经典代码Update(UPDATE points_wallet SET balance balance - #{points}, total_consumed total_consumed #{points}, update_time NOW() WHERE user_id #{userId} AND balance #{points}) int deductPoints(Param(userId) Long userId, Param(points) Integer points);这种带条件判断的原子更新是防超扣的核心MyBatis 写起来比 JPA 直观得多。2.2 前端与端侧实现管理后台用的是 Vue 3 Element Plus Pinia用户端则是 uni-app 打包成 H5 和小程序。为什么要用 uni-app因为零食自选这种场景天然适合在微信小程序里跑用户不用下载 App点开就能选零食、看积分余额、生成兑换码去自提柜领取。同时平台方需要一个管理后台来维护商品、积分规则和订单Vue 3 生态成熟招聘成本低和后台接口联调效率也高。2.3 源码目录结构与模块职责整套源码分为snack-server后端、snack-admin-web管理后台、snack-uniapp用户端三大块。后端内部按业务模块分包而不是按技术分层分包这一点值得借鉴snack-server/ ├── snack-common/ # 通用工具、异常、常量 ├── snack-framework/ # 框架配置Redis、MQ、线程池 ├── snack-system/ # 用户、权限、登录 ├── snack-points/ # 积分规则、积分流水、积分钱包 ├── snack-goods/ # 商品、库存、积分定价 ├── snack-order/ # 自选单、兑换单、领取记录 ├── snack-job/ # 定时任务对账、过期释放、库存同步 └── snack-admin/ # 后台管理接口模块按业务域分包的好处是改积分相关代码时只需要进snack-points不会像传统三层架构那样pojo、mapper、service、controller 满屏都是找一段逻辑要翻七八个目录。2.4 关键依赖版本参考组件版本用途Spring Boot3.2.5基础框架MyBatis-Plus3.5.7ORM 与分页MySQL8.0.x主存储Redis6.2.x缓存、限流、分布式锁RabbitMQ3.12.x异步通知、积分到账Sa-Token1.38.0登录认证与权限Hutool5.8.25工具集vue-element-plus-admin2.x后台管理前端模板这里单独说明为什么选 Sa-Token 而不是 Shiro 或 Spring Security。Sa-Token 胜在轻量和易上手一个注解就能完成登录校验和权限拦截对于这个项目规模完全够用而且它对多端登录后台管理员 用户端支持得比较自然。3. 数据库设计核心积分钱包与流水必须分离3.1 积分钱包表余额冗余但不作为唯一事实积分钱包表points_wallet是每个用户一条记录核心字段包括user_id、balance当前可用余额、frozen_points冻结积分、total_earned累计获得、total_consumed累计消耗、version乐观锁版本号。这里把累计获得和累计消耗都冗余在钱包表里是为了让个人中心页面展示“总积分”时不需要走聚合查询直接读这一行就行。但要注意这张表只承担读优化和扣减入口的职责真正的“事实来源”是积分流水表points_detail。这是账务系统里非常经典的做法——像银行一样每一笔余额变动都对应一条流水流水表一旦缺失整个系统的对账就没法做。026 版本里有一个每日定时任务专门校验钱包余额和流水汇总是否一致不一致立刻告警这就是冗余设计加上对账机制的配合。3.2 积分流水表唯一约束是防重扣的生命线points_detail流水表的设计是整个数据库里最有讲究的一张表我截取核心字段CREATE TABLE points_detail ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID, biz_type tinyint NOT NULL COMMENT 1签到 2任务 3兑换扣减 4过期 5活动赠送, change_points int NOT NULL COMMENT 变动积分正负表示增扣, balance_after int NOT NULL COMMENT 变动后余额用于快速回溯, biz_order_no varchar(64) DEFAULT NULL COMMENT 关联业务单号, request_no varchar(64) NOT NULL COMMENT 请求流水号唯一, remark varchar(255) DEFAULT NULL, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_request_no (request_no), KEY idx_user_id_time (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分流水表;request_no的唯一索引是整个幂等方案的地基。无论是签到回调、任务完成事件还是兑换请求业务方在发起时都会生成一个全局唯一请求号写入流水时如果遇到唯一键冲突说明这个请求已经处理过了直接返回成功即可。这个方法比查表再判断要可靠得多因为查表判断在并发下是存在竞态窗口的而数据库唯一约束是从底层兜底的。balance_after这个字段也很容易被忽略。有了它当线上出现积分不一致时你可以从某一条流水开始顺着查快速定位是在哪一笔操作之后余额开始对不上的这是排查历史问题的关键抓手。3.3 商品与自选单的表结构商品侧的表设计相对常规goods存商品基础信息goods_sku存规格比如不同口味的薯片是不同 SKUgoods_stock存库存。但有一点需要注意库存表不是简单的stock一个字段而是拆成了total_stock总库存、available_stock可用库存、locked_stock锁定库存三个字段。用户在自选单确认后系统先冻结库存等领取动作完成后才真正扣减这样避免了用户选完商品但迟迟不兑换导致库存被别人抢光的尴尬。自选单则拆成exchange_order主表和exchange_order_item子表。主表记录用户、总积分消耗、状态、核销码、领取方式子表记录每个商品的积分单价和数量。核销码的设计值得一提用户兑换成功后生成一个 12 位随机码自提柜或前台扫码核销源码里用的是Hutool的RandomUtil核销时走状态机校验防止同一个码被核销两次。3.4 不用外键的理由整库没有任何外键约束这是刻意为之。外键在低并发、小数据量时很省事但到了高并发插入场景会成为锁竞争的放大器而且后期做分库分表时外键会变成灾难。这个项目通过应用层的事务来保证一致性数据库只保留索引和唯一约束把所有关系维护逻辑都收敛到service层这也是绝大多数互联网级项目的通用做法。4. 并发控制链路一次自选兑换如何做到不超卖、不重扣4.1 兑换请求的完整链路一次兑现在前端的操作是用户勾选零食 → 点击兑换 → 确认弹窗 → 后端返回兑换成功。但这个看起来简单的流程落到后端要经历六步接口层校验登录态和请求幂等号读取最近一次自选单快照校验积分总价事务内校验并冻结积分余额事务内校验并锁定商品库存写入订单主表和订单明细表事务提交后发送 MQ 消息触发后续通知和领取码生成4.2 积分扣减与库存锁定的原子 SQL积分扣减用的是前面提到的那条UPDATE points_wallet SET balance balance - #{points} WHERE user_id #{userId} AND balance #{points}这里的关键是balance #{points}条件。它不是先查询余额再在应用层判断而是把余额判断下推到 SQL 层利用数据库行锁保证同一时刻只有一个请求能成功扣减。在并发 200 个请求同时兑换时这条 SQL 天然起到了“串行化”的效果而且不需要手动加锁性能远好于select ... for update。库存侧同理UPDATE goods_stock SET locked_stock locked_stock #{count}, available_stock available_stock - #{count}, update_time NOW() WHERE sku_id #{skuId} AND available_stock #{count}只要available_stock #{count}不成立更新影响行数就是 0应用层据此抛出“库存不足”回滚整个事务。这套条件更新方案比先查后改再 update 的常规写法在并发安全上有质的提升也是这套源码里最值得抄走的一段。4.3 事务边界积分、库存、订单必须在同一个事务里积分冻结、库存锁定、订单插入这三步操作必须放在同一个事务内要么全部成功要么全部回滚。026 版本的事务边界很清晰Transactional(rollbackFor Exception.class)标注在createExchangeOrder方法上方法内部不包含任何远程调用和 MQ 发送因为远程调用的不可控时间会拉长事务锁的持有时间导致数据库连接池被占满。MQ 消息的发送放在事务提交之后通过TransactionSynchronizationManager.registerSynchronization注册事务同步回调来实现。如果事务提交成功但消息发送失败则由定时任务扫描订单表里“已兑换但未通知”的记录做补偿这个设计兼顾了实时性和最终一致性比 MQ 事务消息要简单得多。4.4 幂等防重请求流水号贯穿始终前端在用户点击兑换按钮时就会生成一个requestNo时间戳加随机数后端拿到这个号后先在exchange_order表的request_no唯一索引上尝试插入如果插入报唯一键冲突直接返回原有订单数据用户不会看到重复下单。这个方案的思路是与其用 Redis 锁来判断“这个请求是不是处理过”不如让数据库自己说话唯一索引就是最可靠的幂等器。实测下来这个方案在重复请求场景下非常稳但有一个细节要注意request_no必须在订单主表创建时就确定而不能等订单号生成之后再回填否则并发重复请求可能在订单号生成阶段产生两笔不同的订单兜底失效。4.5 失败回滚与超时释放用户兑换成功但不去领取库存就会一直被锁定。026 版本里有一个定时任务每 5 分钟扫描一次状态为“已兑换待领取”且超过 24 小时未核销的订单自动将订单状态置为“已过期”同时回补可用库存和冻结积分。回补同样使用条件更新并用状态字段作为判断条件例如UPDATE exchange_order SET status 5 WHERE id ? AND status 2确保并发场景下不会重复回补。这段逻辑虽然不起眼但它是系统长期稳定运行的保险丝。5. 防刷治理与对账保障线上运营的隐形防线5.1 签到和任务接口的刷量问题积分系统最怕的不是并发超卖而是被脚本刷积分。签到接口如果只做“当天是否已签到”校验脚本完全可以注册一万个账号批量签到。026 版本的防刷方案分三层第一层是同一个 IP 的接口限流用 RedisINCR加过期时间实现第二层是同一个设备指纹限频前端在请求头里带上设备的 canvas 指纹后端针对指纹限流第三层是行为风控如果某账号在短时间内连续签到超过正常速度比如 1 秒内完成 3 次签到判定为异常账号自动触发人工复核和积分冻结。这三层方案单看都不复杂难的是组合使用的时机。以我实际运行的经验来说IP 限流最容易误伤公司同一出口 IP 下的真实员工所以限流阈值要设置得比正常使用高 3 到 5 倍重点还是依赖设备指纹这一层。5.2 积分异常波动检测除了接口层的防刷026 版本里还做了一个积分的“异常波动预警”功能。它并不是实时拦截而是每天凌晨对用户积分异动进行统计把“当天积分净增超过 2000”或“一周内积分来源全部集中在单一任务”的用户筛选出来生成名单推给运营人员和制后台。这样做的原因是有些刷量行为分散在几天内网关限流根本拦不住必须依靠事后分析来收敛。如果你要在这个基础上继续扩展可以接入更复杂的规则引擎但前期用一个凌晨 Job 加规则表就够了不要一上来就上流计算成本高且收益有限。5.3 每日对账任务对账是积分系统绝对不能省的一环。026 版本里的PointsReconcileJob每天凌晨 2 点执行核心校验逻辑分三步汇总points_detail中当天的正向流水和逆向流水计算出所有用户的理论余额变动对比points_wallet.balance的实际变动值用 MySQL 的临时表 HAVING语句一次性找出不一致的用户写入告警表并通过邮件/企业微信通知这个 Job 不是简单地“查一遍”它必须能支持“流水多、余额不对”这类敏感场景。实现上有个小技巧因为points_detail里有balance_after字段可以通过窗口函数LAG(balance_after)对比相邻两条流水的连续性任何断档都意味着丢流水这是只对总数察觉不到的问题。5.4 性能瓶颈与热点处理小体量的积分商城并不需要太复杂的性能优化但有一个问题在零食自选场景里会特别明显某些热门零食比如某品牌的薯片被大量用户同时加进自选清单库存查询压力会非常集中。026 版本的处理方式是把热门 SKU 的库存信息缓存到 Redis缓存 key 为stock:sku:{skuId}更新时先更新数据库再删缓存下单扣减时先用 Lua 脚本扣 Redis 缓存库存再异步落库。这样热商品的查询压力被 Redis 扛住了数据库只处理写入。这个方案有个一致性问题Redis 缓存和数据库库存可能在短时间内不一致。但因为下单后的校验最终还是会走数据库条件更新所以不会真的超卖最多是缓存里显示“有货”但实际下单时提示“库存不足”的尴尬不过在零食自选这种场景下完全可以接受。6. 本地部署与二次开发指南6.1 环境准备把源码跑起来需要提前装好这些环境JDK 17、Maven 3.8、MySQL 8.0、Redis 6.2、RabbitMQ 3.12、Node.js 18。如果你只是看后端逻辑RabbitMQ 可以先不启动代码里通过spring.rabbitmq.listener.auto-startupfalse临时关掉监听后续会漏掉 MQ 相关功能但不影响核心兑换流程的调试。需要注意 JDK 版本必须是 17 或以上因为 Spring Boot 3.x 底层依赖了 Jakarta EE 9JDK 8 编译运行会直接报错这一步卡住了不少第一次接触这个项目的开发者。6.2 后端启动步骤数据库初始化在snack-server/sql/目录下有两个脚本init.sql建库建表seed.sql写入演示数据。连接配置在application-dev.yml里改包括 MySQL 账号密码、Redis 地址、RabbitMQ 连接信息。启动命令如下cd snack-server mvn clean package -DskipTests java -jar snack-admin/target/snack-admin.jar --spring.profiles.activedev启动成功后访问http://localhost:8080/doc.html能看到整合了 Knife4j 的接口文档所有接口都可以在线调试这对二次开发非常友好。管理后台前端启动cd snack-admin-web npm install npm run dev默认管理员账号是admin密码admin123用户端测试账号是user01密码123456演示数据里预置了 100 积分可以直接拿来体验完整兑换流程。6.3 二次开发的三个建议第一个建议是接真实积分系统时把“积分扣减”做成可配置的实现。当前PointsDeductService是本地钱包直接扣减如果业务方要求对接 SAP、CRM 或第三方积分平台建议通过 Spring 策略模式新增一个实现类把本地扣减和远程扣减解耦不要改动上层订单服务。第二个建议是给points_detail表增加创建时间的MONTH分区虽然现在数据量不大但如果要做长期运营流水表很快就会上千万行提前分区能避免后期迁移的阵痛。第三个建议是自选规则的可配置化目前 026 版本的自选额度是后端硬编码在配置中心的建议把规则落到数据库表里支持“每人每周限额”“不同等级用户不同额度”“指定品类不可兑换”等扩展运营自由度会大很多。6.4 上线前必须验证的三个场景如果打算把这套源码拿到真实业务环境里建议在上线前重点验证三个场景。第一个是用户重复点击兑换按钮网络抖动导致同一个请求发了两遍看最终是否只生成一笔订单、只扣一次积分验证幂等逻辑是否生效。第二个是库存只剩 1 件时10 个用户同时下单看最终是不是只有一个人成功、其他请求全部返回库存不足且积分原路退回。第三个是模拟 RabbitMQ 挂掉的场景看兑换主流程是否不受影响、待领取通知有没有延迟补偿。这三个场景跑通了系统的核心稳定性就有保障了。我个人的实际体会是这套源码最值钱的部分不是精美的前端页面也不是花哨的功能而是那些写在 SQL 条件里的、写在事务边界里的、写在定时任务里的“防御性设计”。积分系统一旦上线任何一笔多扣或少扣都会直接演变成客诉而 026 版本用最朴素的数据库手段把这些风险都控制在了可控范围内。如果你正在做类似的积分商城项目把第四章的条件更新和第三章的幂等设计吃透至少能少踩一半的坑。