首页 / 资讯中心 / 文章详情

充电宝共享项目核心交易链路:状态机、并发锁与幂等支付实战

充电宝共享项目核心交易链路:状态机、并发锁与幂等支付实战 ★ FEATURED ARTICLE
最近这段时间在跟一个Java实战教程做充电宝共享项目前面的章节把工程初始化、数据库建模、用户登录这些基础环节都过了一遍项目跑起来是没问题但业务上其实是空的。真正让我觉得“这项目才开始有灵魂”的是第4部分把借用、归还、计费、支付这条核心交易链路完整打通的时候。这篇文章就针对第4部分的学习内容做一个整体复盘重点聊聊订单状态机怎么设计、借还流程里的并发问题怎么防、支付回调怎么做到幂等、超时订单又该怎么兜底顺带把我在写代码和压测过程中踩过的几个坑一并记录下来给同样在学这套项目或者做类似业务的朋友一点参考。1. 这一阶段的定位从“业务演示”到“核心链路闭环”1.1 前面几阶段完成了什么在进入第4部分之前整套项目已经搭好了基本骨架。工程层面是标准的Spring Boot分层结构控制层、服务层、数据访问层分得清清楚楚MyBatis-Plus集成好了基础的增删改查也没有问题。数据模型这边用户表、充电宝表、充电桩表、槽位表都已经建好用户注册登录也通了JWT这块基本能跑。但如果你仔细去看这个阶段的项目还只是一个“空壳”。充电宝表里有数据可它们永远处于“可用”状态用户能登录但登录之后不知道能干什么接口调来调去无非是查列表、查详情。就像一家超市装修完了、货架也摆好了但收银系统还没装顾客进来只能干瞪眼。1.2 第4部分要解决的问题第4部分的核心任务就是把共享充电宝的完整业务流程闭环做出来。所谓闭环我把它梳理成下面这条链路用户扫码充电宝 → 系统校验并借出 → 用户带走使用 → 归还可用的桩位 → 系统结算计费 → 用户支付费用 → 订单完成这里面每一步都不像看上去那么简单。借出时怎么保证同一台充电宝不会被两个人同时借走归还时费用怎么算遇到免费时段怎么处理支付回调来了怎么避免重复更新订单状态用户下单后一直不支付订单又该怎么处理这些问题全部堆在“业务逻辑”这一层也是第4部分最有含金量的地方。我自己的体感是写到这一阶段才真正把之前学的Spring Boot、MyBatis-Plus、Redis这些技术串起来不是一个个孤立的demo而是为了同一个业务目标互相配合。1.3 我会用到的技术栈说明在具体展开之前先交代一下第4部分涉及到的技术组件以及它们各自承担的角色这样后面看代码不会懵。技术组件解决的问题在本项目中的角色Spring Boot提供Web容器和依赖管理所有接口的宿主MyBatis-Plus数据持久化和条件构造订单、充电宝等表的读写MySQL数据最终存储订单记录、充电宝状态Redis缓存、分布式锁防止并发借出、缓存热数据支付SDK模拟第三方支付回调支付状态通知这套组合也是目前中小型Java后端项目很常见的技术栈学习价值不只是为了应付教程而是能直接迁移到同类业务里。下面各节我会按“设计 → 实现 → 踩坑”的顺序来梳理。2. 订单核心状态机设计是这块业务的地基2.1 订单状态怎么定借还、计费、支付这些业务动作最后都要落到订单上。订单状态设计得好不好直接决定后面所有逻辑写起来顺不顺手。这也是为什么我在第4部分最先死磕的就是状态机。我把订单状态拆成了下面这几个状态含义触发动作CREATED订单已创建用户扫码确认借出IN_USE使用中充电宝成功借出开始计时PENDING_PAYMENT待支付用户归还系统算完费用COMPLETED已完成支付成功或者免费订单直接完结CLOSED已关闭超时未支付或异常取消注意我在这里把“待支付”和“使用中”明确分开了。有的同学可能会觉得借出之后一直到支付完成才算是结束中间搞那么多状态是不是多余其实不是。归还动作和支付动作在时间上是分离的用户还了充电宝不代表他马上付钱他可能拖着也可能不付。如果没有独立的待支付状态后续的超时提醒、订单关闭、财务对账就都没有抓手。2.2 状态流转规则与防呆设计状态定义了流转规则也得跟着定下来不能让订单状态随便跳。我整理出的正常流转关系是这样的CREATED → IN_USE借出成功立即进入使用中CREATED → CLOSED用户下单但未借出成功或系统取消IN_USE → PENDING_PAYMENT用户归还费用计算完成IN_USE → CLOSED异常关闭比如充电宝丢失上报PENDING_PAYMENT → COMPLETED支付回调成功PENDING_PAYMENT → CLOSED超时未支付系统关闭为了避免逻辑散落在各个Service方法里我单独封装了一个状态流转校验方法。任何更新订单状态的地方都必须先做一次合法性检查非法跳转直接抛异常。private static final MapOrderStatus, SetOrderStatus ALLOWED_TRANSITIONS new EnumMap(OrderStatus.class); static { ALLOWED_TRANSITIONS.put(OrderStatus.CREATED, EnumSet.of(OrderStatus.IN_USE, OrderStatus.CLOSED)); ALLOWED_TRANSITIONS.put(OrderStatus.IN_USE, EnumSet.of(OrderStatus.PENDING_PAYMENT, OrderStatus.CLOSED)); ALLOWED_TRANSITIONS.put(OrderStatus.PENDING_PAYMENT, EnumSet.of(OrderStatus.COMPLETED, OrderStatus.CLOSED)); } public static void checkTransition(OrderStatus from, OrderStatus to) { if (!ALLOWED_TRANSITIONS.getOrDefault(from, Collections.emptySet()).contains(to)) { throw new BizException(非法的订单状态流转: from - to); } }这种集中式校验有个明显好处后续加新状态时只需要改这一处不容易漏。我在实际编码中因为图省事跳过这个校验结果有一次归还流程里更新状态顺序写错把待支付直接覆盖成已完成金额都没付还好是在测试环境发现的。2.3 订单表字段设计的细节取舍状态机之外订单表的字段设计也值得好好说。我最初建表偷懒想着能用就行字段能少就少后来发现根本不够用反复改了两次表结构。下面是经过第4部分业务验证后比较稳定的表结构。字段名类型说明idbigint主键order_novarchar(32)订单号唯一索引user_idbigint用户IDpower_bank_idbigint充电宝IDcabinet_idbigint借出充电桩IDslot_noint借出槽位号return_cabinet_idbigint归还充电桩ID可空return_slot_noint归还槽位号可空borrow_timedatetime借出时间return_timedatetime归还时间可空amountdecimal(10,2)应付金额statustinyint订单状态create_timedatetime创建时间update_timedatetime更新时间两个细节提醒一下。金额字段建议用decimal而不是float/double这个做支付相关业务的人都懂浮点数在金额计算里会产生精度问题第4部分的计费逻辑我一开始用double写过结果算出来的费用偶尔带出一串诡异的小数。时间字段建议统一用datetime应用层用LocalDateTime与它对应别混用Java的Date和数据库函数后面我会在踩坑章节展开讲。3. 借还流程的实现细节把并发和边界条件提前排掉3.1 扫码借出的完整链路借出接口是整个借还流程的入口。用户在前端点击“借用”后端接收充电宝ID、充电桩ID、槽位号这些参数后按顺序执行下面这几步校验充电宝是否存在且状态可用对充电宝ID加分布式锁在锁内再次校验充电宝状态将充电宝状态从“可用”更新为“借用中”将槽位从“空闲”更新为“占用”创建订单状态设为IN_USE返回订单号给前端这段流程里最核心的是第2步到第4步。如果少了加锁两个用户同时扫码完全可能同时读到“充电宝可用”然后各自创建一张订单充电宝被借出去两次这在真实业务里是要出大事故的。3.2 为什么必须用分布式锁而不是简单的状态判断有同学会问我在更新充电宝状态的时候加一个条件不就行了吗比如UPDATE ... WHERE status AVAILABLE这样两条并发请求只有一条能成功。方案本身没问题但它没办法解决另一个问题如果充电宝是A用户先借的紧接着B用户也要借两个请求几乎同时到达B可能是在A的订单还没创建完的时候就进来了。数据库层面的条件更新确实能拦住B对充电宝状态的修改但业务流程里可能已经产生了脏数据或者前端已经收到了“借用成功”的假象。所以常规做法是双重保险。先上Redis分布式锁保证同一时刻只有一个线程在处理同一个充电宝的业务再在数据库层面做条件更新作为兜底。锁我用的是最简单的SET NX EX方式String lockKey lock:powerbank: powerBankId; Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 执行业务逻辑 } finally { stringRedisTemplate.delete(lockKey); } }这里有个很容易踩的坑锁的过期时间不能设置得太短否则业务还没执行完锁就自动释放了下一个请求就能进来。但也不能太长万一线程执行过程中挂了锁要等很久才能被别的线程获取。课程里给的参考值是10秒对于这个项目的业务复杂度来说是够用的。生产环境如果单次操作耗时波动大可以考虑用Redisson的看门狗机制自动续期不过作为学习项目掌握基本写法更重要。3.3 归还流程和计费规则的落地归还接口的整体链路比借出要稍微复杂一点因为它涉及到费用计算和状态回写。我实现的归还流程是这样的根据订单号查询订单校验订单状态必须为IN_USE按订单号加分布式锁防止归还请求重复提交计算借出时长算出应收费用将充电宝状态更新为“可用”将归还桩位的槽位更新为“空闲”更新订单填归还时间、归还桩位、金额、状态如果费用为0订单直接变成COMPLETED否则进入PENDING_PAYMENT计费规则我是这样定的前5分钟免费超过后按小时计费每小时2元不足1小时按1小时算单日封顶20元。这里最需要注意的是“不足1小时按1小时算”的向上取整逻辑千万别直接对分钟数取整会少算钱或者多算钱。public BigDecimal calculateAmount(LocalDateTime borrowTime, LocalDateTime returnTime) { long minutes Duration.between(borrowTime, returnTime).toMinutes(); long freeMinutes 5; long billableMinutes Math.max(0, minutes - freeMinutes); long hours (billableMinutes 59) / 60; // 向上取整 BigDecimal amount BigDecimal.valueOf(hours).multiply(new BigDecimal(2)); return amount.min(new BigDecimal(20)); }免费时段的处理逻辑也值得留意。如果用户借出4分钟就归还费用是0我直接把订单置为COMPLETED而不是让它进入待支付状态。否则用户打开订单列表看到一个0元的待支付订单还得再去点一次“支付”体验很怪。这一点是我在自测的时候发现的改完之后整个流程顺畅很多。3.4 借用接口核心代码示例借用接口的核心逻辑我把精简后的代码贴出来。这版代码没有包含所有细节但主流程是完整可跑的。Transactional public BorrowResult borrow(Long userId, Long powerBankId, Long cabinetId, Integer slotNo) { String lockKey lock:powerbank: powerBankId; Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(充电宝使用中请稍后重试); } try { // 锁内查询并校验状态 PowerBank powerBank powerBankMapper.selectById(powerBankId); if (powerBank null || !AVAILABLE.equals(powerBank.getStatus())) { throw new BizException(充电宝不可借用); } // 条件更新状态返回0说明被并发抢先 int updated powerBankMapper.updateStatus( powerBankId, AVAILABLE, BORROWED); if (updated 0) { throw new BizException(手慢了充电宝已被借走); } // 槽位占用 slotMapper.updateSlotStatus(cabinetId, slotNo, FREE, OCCUPIED); // 创建订单 Order order Order.builder() .orderNo(generateOrderNo()) .userId(userId) .powerBankId(powerBankId) .cabinetId(cabinetId) .slotNo(slotNo) .status(OrderStatus.IN_USE.getValue()) .borrowTime(LocalDateTime.now()) .build(); orderMapper.insert(order); return BorrowResult.of(order.getOrderNo()); } finally { stringRedisTemplate.delete(lockKey); } }这段代码有个细节值得说锁的释放放在finally里保证即使业务抛异常锁也能释放掉。我在刚开始写的时候把释放动作放在了try里方法末尾结果有一次充电宝状态更新失败抛了异常锁一直没释放后续所有对这个充电宝的请求全部被挡在外面日志里一片“充电宝使用中”。4. 支付回调幂等都是底线4.1 支付交互的主流程归还完成后用户会看到一个待支付金额前端调起支付用户输密码完成付款。这里有一个非常关键的设计原则订单状态绝对不能在前端支付成功页面跳转时直接更新必须等支付平台的异步回调通知后端。原因是前端跳转是可以伪造的任何人只要懂一点前端知识都能模拟一个“支付成功”的页面跳转。真正可信的支付确认只能来自支付平台的后端回调。所以在第4部分的学习里我着重把回调接口的实现逻辑捋了一遍核心就三个词验签、对金额、幂等更新。4.2 回调幂等实现的两种方式支付平台为了保证通知送达会在一段时间内对回调接口进行多次重试。也就是说同一个支付成功的消息后端可能会收到好几次。如果每次收到回调都直接更新订单状态轻则重复更新产生无效SQL重则把已关闭的订单重新改成已完成造成资金风险。我采用的幂等方案是数据库条件更新。更新的时候带上期望的当前状态只有状态匹配才更新成功PostMapping(/api/pay/notify) public String payNotify(RequestBody PayNotifyRequest request) { // 1. 验签失败直接返回FAIL if (!payService.verifySign(request)) { log.error(回调验签失败: {}, request.getOrderNo()); return FAIL; } // 2. 查订单并校验金额 Order order orderMapper.selectByOrderNo(request.getOrderNo()); if (order null || order.getAmount().compareTo(request.getAmount()) ! 0) { log.error(订单不存在或金额不一致: {}, request.getOrderNo()); return FAIL; } // 3. 条件更新只有待支付状态才能更新为已完成 int updated orderMapper.updateStatusByOrderNo( request.getOrderNo(), OrderStatus.PENDING_PAYMENT.getValue(), OrderStatus.COMPLETED.getValue()); if (updated 0) { // 可能是重复回调也可能是状态异常都返回成功 log.info(重复回调或状态异常: {}, request.getOrderNo()); return SUCCESS; } return SUCCESS; }对应的SQL是这样UPDATE t_order SET status #{targetStatus}, update_time NOW() WHERE order_no #{orderNo} AND status #{expectStatus}第一次回调进来时订单状态是待支付条件更新成功返回SUCCESS。第二次同样的回调进来时订单已经是已完成条件更新影响行数为0这时候直接返回SUCCESS就行不能返回FAIL否则支付平台会认为通知失败继续无限重试。另一种常见的幂等做法是建一张支付流水表收到回调先插入流水靠流水表唯一索引去重成功插入才继续更新订单。两种方案我都试过条件更新更简单流水表更灵活。教学项目里我建议先用条件更新逻辑直观也容易讲清楚。4.3 回调处理时的一个易错点校验金额这一步很多人会忽略。我一开始也觉得支付平台都回调了还能有假吗后来课程里提醒才知道回调参数虽然经过平台签名但业务上仍然要自己拿订单金额和回调金额比对。万一订单金额和实际支付金额不一致说明有异常宁可返回FAIL也不要动订单状态。另外验签逻辑虽然在前面的代码里只写了方法调用但这个环节千万不能省。我自己测试时曾经为了省事把验签注释掉直接用Postman模拟回调把一笔0元的订单给硬改成已完成数据库里就出现了脏数据。后来恢复验签才发现支付平台的签名规则其实还涉及到请求头里的时间戳和随机字符串不是一个简单的参数拼接。5. 超时、异常与兜底产品能跑起来的关键5.1 下单未支付和未归还的兜底用户归还充电宝后订单进入待支付状态但用户可能一直不付钱。这种情况下订单不能无限期挂着需要一个超时关闭机制。我在项目里用的是定时任务扫描每5分钟执行一次找出创建时间超过30分钟且状态为待支付的订单把状态更新为CLOSED。Scheduled(fixedDelay 300000) public void closeTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListOrder timeoutOrders orderMapper.selectTimeoutPendingOrders(deadline); for (Order order : timeoutOrders) { orderMapper.updateStatusByOrderNo( order.getOrderNo(), OrderStatus.PENDING_PAYMENT.getValue(), OrderStatus.CLOSED.getValue()); } }生产环境里扫描式定时任务不是最优解因为存在最多一个扫描周期的时间差更及时的做法是用Redis延迟队列或者消息队列的延时消息。但作为学习项目定时任务已经足够而且能让你把注意力放在业务逻辑本身。还有一种更隐蔽的异常场景用户借出充电宝后一直不归还。这时候订单一直停在IN_USE状态计费在持续累加。课程里没重点讲这个场景但我自己实现了一个简单的提醒逻辑订单使用超过24小时就给用户发一条待归还提醒。如果超过72小时就把充电宝标记为“丢失”并在后台提醒运营人工介入。这个扩展做下来让我对这个业务的整体理解深了不少。5.2 归还环节的系统异常兜底归还环节最容易出问题的点是用户把充电宝插回柜机柜机上报了归还事件但后端处理到一半挂了或者网络超时。这时候数据库里充电宝可能还是“借用中”槽位还是“占用”用户却已经完成了物理归还。如果不管用户会被一直计费。我的处理思路是加一个对账定时任务。每天凌晨扫描一次充电宝状态为“借用中”但关联订单的更新时间已经超过24小时的记录把这类异常数据捞出来推送告警。运营人员在后台看到后可以手动确认充电宝是否已经在柜机里确认后就手动把订单完结。这种“自动处理人工兜底”的思路是从真实业务里学来的也是我认为教学项目里很值得主动扩展的一部分。因为真实世界里的异常永远比正常流程多能不能兜得住异常往往决定一个系统上线后会不会翻车。5.3 简易运营后台的必要性前面说了人工兜底那人工在哪里操作这就需要一个最简单的运营后台。我在第4部分结束之后顺手做了一个非常简陋的后台页面功能只有一个订单列表查询加上一个“人工完成订单”的按钮。后台接口看起来不复杂但有一个安全点必须注意这类运营接口不能只靠前端隐藏入口来保护必须做服务端鉴权。我一开始没做权限校验结果通过浏览器直接调后台接口就能改订单状态后来给所有运营接口加了一个简单的管理员Token校验。6. 实操过程中的坑和排查实录6.1 并发测试时同时借同一个充电宝订单重复了这块场景是我在第4部分印象最深的一个坑。有一版代码我为了测试分布式锁到底有没有必要故意把Redis加锁部分注释掉只保留了数据库的条件更新。然后我用模拟并发工具开了20个线程同时请求借用同一个充电宝。现象很快出来了日志里出现了两条订单充电宝ID对应两个订单号数据库里被插进了两条状态均为IN_USE的订单。按理说条件更新应该能挡住第二条请求但实际跑下来发现两条请求几乎在同一时刻通过了状态检查都走到了插入订单这一步。排查下来原因就是“检查状态”和“更新状态”不是原子操作。A线程检查完充电宝可用还没来得及更新状态B线程也完成了检查两边都认为充电宝可用于是都去更新最终只有一条更新成功而另一条已经走到了订单插入。目录里的那条脏订单就是多出来的那个。修复方式就是我在3.1节里写的先加分布式锁让同一个充电宝的请求串行化再配合条件更新兜底。恢复锁之后重新压测再也没出现过重复订单。6.2 计费差了6分钟时间存储和时区的坑有一段时间我在测试归还流程时发现一个问题借出5分钟内归还没有被测到免费而是被收了2元。本来以为是业务代码写错了后来发现是时间来源不一致。借出时间我用的数据库字段默认值也就是通过MyBatis-Plus插入时由数据库填的当前时间。归还时间用的是Java的LocalDateTime.now()。问题在于数据库连接串里配置的时区是格林尼治时间而应用服务器的时区是东八区两边差了8小时。虽然显示出来都是“当前时间”但对同一个订单来说借出和归还用的不是同一个时钟计算出来的时长自然就乱了。修复方式很简单把整个项目的时间获取统一起来全部使用应用层的LocalDateTime.now()并且连接串里显式指定时区。spring: datasource: url: jdbc:mysql://localhost:3306/chongdianbao?serverTimezoneAsia/Shanghai这个坑我记得很牢因为排查了很久。如果你在类似项目里遇到计费时间不准的问题先别急着怀疑代码去看看所有时间到底是不是同一个来源。6.3 支付回调重试把日志和数据库打爆回调接口刚写好的时候我犯过一个低级错误接口处理成功后返回的字符串少了一个字母导致支付平台认为处理失败。结果那笔订单的回调被支付平台重试了十几次每次进来都打一条日志还多次查询订单表。后来我把幂等逻辑加上重复回调的请求会直接返回SUCCESS不执行业务更新问题就解决了。这里也提醒一句凡是回调类接口一定要设计成“不管重复多少次都不能产生副作用”这就是幂等的意义所在。6.4 长事务引起的数据库锁等待归还接口刚开始实现时我把太多操作都塞进一个事务里查订单、算费用、更新充电宝、更新槽位、更新订单还调了一次支付平台的下单接口整个过程在慢网络下要好几秒。测试并发归还时在数据库的锁等待日志里看到了大量超时。问题本质是事务范围太大锁持有时间太长。解决方式是把外部调用移出事务或者用事务事件监听器在事务提交后再去调用外部系统。教学项目里最简单有效的做法是保证事务里只做数据库操作对外部系统的调用挪到事务外面。7. 阶段复盘与我的一点心得第4部分学下来最大的感受是写业务的难度不在某个单一技术点而在把所有环节拼在一起不出漏洞。订单状态少设计一个后面就要到处打补丁锁不加并发场景就出事回调不幂等支付数据就可能对不上。这些经验很难从教科书里直接学到必须自己在代码里栽过跟头才有体会。我也建议大家在学习这类实战项目时别只满足于把代码敲通跑起来。把分布式锁去掉跑一次并发故意把回调重复提交两次把时区配置改乱再去看计费结果这些“破坏性实验”才能帮你真正理解每一行代码为什么存在。第4部分的内容承载了太多这类细节值得反复刷几遍。后续如果继续往深处扩展我打算再看看运营统计、报表分析、用户积分这些方向到时候再单独写文章分享。
阅读完成 · 觉得有帮助?
咨询建站