资讯详情

资讯详情

商超小程序后台开发实战:多门店库存、自提配送与订单状态机设计

1. 商超小程序后台到底在做什么先把这个项目的边界说清楚。商超门店小程序前端是用户扫码或搜索进入的微信小程序后端要扛住两件事到店自提和同城配送。听起来简单但真正落到代码上它牵扯的是库存、订单、支付、配送调度、门店分单、售后这一整条链路。我接这个商单的时候甲方是一家本地连锁超市六家门店SKU 大概四千多个日均订单量预估在三百到五百单之间。这个体量不算大但足够把后台设计里的坑一个个踩出来。为什么独立开发者容易在这种项目上翻车因为商超场景和普通电商不一样。普通电商是中心仓发货库存逻辑相对单一商超是多门店、多库存、多配送范围同一个商品在 A 店有货、B 店没货用户下单时到底从哪个店出这背后是一套分单逻辑。再加上自提和配送两种履约方式混在一起订单状态机比想象中复杂得多。这篇文章适合谁看如果你正在接类似的小程序商单或者准备做本地生活类的后台开发这里面的踩坑记录能帮你省掉至少两周的返工时间。我会从整体设计思路讲起然后拆核心细节、实操过程、问题排查最后给一些我实际用下来觉得靠谱的方案。不堆概念只讲能直接抄作业的东西。2. 整体架构设计与技术选型思路2.1 为什么没有一上来就上微服务很多同行一接到商超项目第一反应是拆微服务订单服务、库存服务、配送服务、用户服务各一个。我一开始也动过这个念头但算了一笔账之后放弃了。六家门店、日均几百单用微服务意味着要维护服务注册、配置中心、链路追踪、分布式事务光是运维成本就够呛。独立开发者一个人扛出问题的时候排查链路会非常痛苦。最后我选的是单体应用 模块化分层。用 Spring Boot 起一个服务内部按 domain 划分包结构order、inventory、delivery、store、user。每个模块之间通过明确的 Service 接口调用不直接跨模块访问 Mapper。这样既保证了边界清晰又避免了分布式带来的复杂度。等业务量真的涨到单机扛不住的时候再按模块拆出去也不迟因为边界已经划好了。数据库层面MySQL 单库单表起步订单表按月份做逻辑分表用order_202601这种命名查询时根据时间路由。这个方案在日均千单以内完全够用而且后期迁移到分库分表中间件也方便。2.2 自提和配送的订单模型怎么统一这是设计阶段最关键的一个决策。自提和配送在业务上差异很大自提没有收货地址但有自提码和核销流程配送有地址、有配送费、有骑手或第三方运力。如果做成两套订单表后期统计和对账会非常麻烦。我的做法是一张订单主表 一张履约扩展表。主表存公共字段订单号、用户 ID、门店 ID、商品总额、支付状态、订单状态、创建时间。履约扩展表存差异字段履约类型SELF_PICKUP / DELIVERY、自提码、收货地址、配送费、配送员信息、预计送达时间。订单状态机也做了统一抽象状态码状态名自提场景配送场景10待支付适用适用20已支付待接单门店备货门店备货30备货完成待自提待配送40履约中已核销配送中50已完成已提货已送达60已取消适用适用70售后中适用适用这样设计的好处是所有订单相关的查询、统计、对账都能在主表上完成履约差异只在需要的时候 join 扩展表。后来甲方要加门店自提 送货上门混合模式的时候我只改了一个履约类型枚举没动主流程。2.3 库存扣减的时机选择库存什么时候扣这个问题我和甲方掰扯了很久。方案有三种下单减库存、支付减库存、发货减库存。每种都有坑。下单减库存的问题是恶意占库存用户下了单不付款库存被锁死。支付减库存的问题是超卖两个用户同时支付库存可能不够。发货减库存的问题是门店备货时才发现没货体验极差。我最终选的是下单预占 支付确认 超时释放。具体来说用户提交订单时在 Redis 里对每个 SKU 做预占用 Lua 脚本保证原子性同时写一条预占记录到数据库支付成功后把预占转为实际扣减如果 15 分钟未支付定时任务释放预占。这个方案兼顾了防超卖和防占库存代价是需要维护预占记录和定时任务。提示Redis 预占的 key 设计很关键我用的是stock:reserve:{storeId}:{skuId}value 是预占数量。释放的时候要校验订单号避免重复释放。3. 核心细节解析与实操要点3.1 多门店库存同步的坑商超最麻烦的地方在于同一个 SKU 在六个门店的库存是独立的。用户在小程序里看到的库存应该是基于定位或手动选择的门店的库存。但这里有个隐藏问题用户下单时选的是 A 店但 A 店库存刚好在那一刻被线下收银扣掉了怎么办我的处理逻辑是下单预占时如果 Redis 预占失败会回源到数据库查一次实际库存如果确实不足返回明确的缺货提示并推荐同城其他有货的门店。这个推荐门店的逻辑后来成了甲方很满意的一个点因为它把流失订单挽回了一部分。库存同步方面线下 POS 系统的库存变动通过定时任务每 5 分钟同步一次到线上。这里要注意同步方向线上预占和实际扣减要回写到 POS否则线下会超卖。我用的方案是线上扣减后发一条消息到队列POS 侧消费后更新本地库存。消息队列用的是 RabbitMQ保证至少一次投递POS 侧做幂等处理。3.2 配送范围与运力调度的实现同城配送的范围怎么定甲方一开始说全城送我直接否了。全城送意味着配送成本不可控而且远距离订单的履约体验很差。最后定的是基于门店的电子围栏每个门店画一个多边形区域用户下单时判断收货地址是否在围栏内。电子围栏的实现我用的是高德地图的行政区划 自定义多边形。具体做法是在后台管理端用地图组件画围栏保存为 GeoJSON 存到数据库下单时用射线法判断点是否在多边形内。这个算法不复杂但要注意边界情况点刚好在边上、多边形自交、坐标系不一致高德是 GCJ-02GPS 是 WGS-84。我踩过的坑是坐标系没统一导致围栏判断偏移了几百米用户明明在范围内却提示超区。运力调度方面甲方没有自建骑手团队用的是第三方配送。我对接了两家运力平台的开放接口下单后根据门店和收货地址自动选择价格最低且时效达标的一家。这里的关键是回调处理运力平台的状态回调必须做签名校验和幂等否则会出现重复更新订单状态的问题。3.3 自提码的生成与核销自提码看起来简单但细节不少。我一开始用的是 6 位数字随机码结果发现会有重复而且用户报码时店员输入容易出错。后来改成订单号后 6 位 校验位既保证了唯一性又方便店员核对。核销流程是这样的用户到店后出示自提码店员在小程序商家端输入或扫码系统校验订单状态是否为备货完成如果是则更新为已核销同时触发库存实际扣减和积分发放。这里要注意并发核销的问题同一个自提码被两个店员同时核销必须用数据库行锁或分布式锁保证只有一次成功。注意自提码的有效期要和订单状态绑定订单取消后自提码立即失效。我见过有同行忘了做这个导致用户取消订单后还能用旧码提货。3.4 支付回调的幂等与对账支付回调是后台开发的老大难。微信支付的回调可能会重复推送如果不做幂等订单会被重复处理。我的做法是回调进来先查订单状态如果已经是已支付就直接返回成功不重复处理。同时用out_trade_no做唯一索引数据库层面兜底。对账方面每天凌晨拉取微信支付的账单和本地订单表做比对。差异订单分三类本地有微信无可能是支付失败但状态没更新、微信有本地无可能是回调丢失、金额不一致严重问题需要人工介入。这个对账任务我写成了一个独立的定时任务跑完发邮件通知。4. 实操过程与核心环节实现4.1 环境准备与项目初始化项目用的是 Spring Boot 2.7 MyBatis-Plus Redis RabbitMQ MySQL 8.0。开发工具是 IDEAJDK 版本 1.8甲方服务器环境限制没办法。小程序端用的是原生开发没有上 uni-app因为甲方要求性能优先而且不需要跨端。项目结构是这样的supermarket-admin ├── supermarket-common // 公共工具、常量、异常 ├── supermarket-dao // 数据访问层 ├── supermarket-service // 业务逻辑层 ├── supermarket-web // 接口层小程序 API 管理后台 API └── supermarket-job // 定时任务初始化的时候有几个配置要注意。数据库连接池用的是 HikariCP最大连接数设的是 20因为甲方服务器是 4 核 8G连接数太多反而会拖慢。Redis 用的是单机模式没有上集群因为预占逻辑对一致性要求高单机反而更简单可靠。4.2 订单创建的核心代码实现订单创建是整个系统最核心的环节我把它拆成了几个步骤参数校验、库存预占、订单落库、支付发起。这里贴一段库存预占的 Lua 脚本这是保证原子性的关键-- KEYS[1]: 库存 key -- ARGV[1]: 预占数量 -- ARGV[2]: 订单号 local stock redis.call(GET, KEYS[1]) if not stock then return -1 -- 库存不存在 end if tonumber(stock) tonumber(ARGV[1]) then return -2 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) redis.call(HSET, reserve: .. ARGV[2], KEYS[1], ARGV[1]) return 1 -- 预占成功这段脚本在 Redis 里执行是原子的不会出现并发扣减导致超卖。预占成功后订单落库时状态设为待支付同时写一条预占记录到stock_reserve表用于后续释放。订单号生成用的是雪花算法但做了一点改造机器 ID 用门店 ID 的低位这样从订单号就能看出是哪个门店的单方便排查问题。订单号是 18 位 Long 型前端展示时转成字符串。4.3 配送费计算与运力对接配送费的计算规则甲方定的是3 公里内 5 元每超出 1 公里加 1 元满 99 元免配送费。这个规则看起来简单但实现时要考虑距离的计算方式。我用的是高德地图的骑行路径规划接口返回实际骑行距离而不是直线距离。因为直线距离和实际配送距离可能差很多用直线距离算费会导致甲方亏钱。运力对接这块我封装了一个DeliveryProvider接口不同运力平台实现这个接口。下单时根据配置的策略选择 provider调用其createOrder方法。回调统一走/api/delivery/callback/{provider}路径每个 provider 自己实现签名校验。public interface DeliveryProvider { DeliveryResult createOrder(DeliveryRequest request); boolean verifyCallback(String sign, String body); void handleCallback(DeliveryCallback callback); }这个设计的好处是后来甲方要换运力平台我只加了一个实现类没动主流程。4.4 定时任务的实现与避坑系统里有几个关键的定时任务超时订单取消、预占库存释放、库存同步、对账。我用的是 XXL-JOB因为甲方已经有这个调度平台了不用额外部署。超时订单取消的逻辑是每 5 分钟扫描一次order表找出创建时间超过 15 分钟且状态为待支付的订单批量取消并释放预占。这里要注意批量处理的大小我设的是每次 100 条避免一次处理太多导致数据库压力过大。库存同步任务每 5 分钟跑一次从 POS 拉取最新库存更新到线上。这里有个坑如果 POS 接口超时任务会卡住。我的处理是设置 3 秒超时超时后跳过本次同步记录日志下次再同步。宁可少同步一次也不能让任务堆积。5. 常见问题与排查技巧实录5.1 订单状态不一致的排查思路这是最常见的问题。用户反馈我明明付款了订单还是待支付。排查步骤是这样的先查微信支付账单确认这笔订单是否真的支付成功。如果支付成功查回调日志看是否有收到回调。如果收到回调但状态没更新查回调处理逻辑的日志看是否抛异常。如果没收到回调查微信支付的回调地址配置是否正确以及服务器是否可达。我遇到过一次是因为回调地址用了 HTTP 而不是 HTTPS微信支付直接不推送。改成 HTTPS 后正常。还有一次是因为回调处理里查订单用了从库主从延迟导致查不到订单改成查主库解决。5.2 库存超卖的定位与修复超卖问题一旦出现影响很大。排查的时候先看 Redis 预占记录和数据库实际库存是否一致。如果不一致可能是预占释放逻辑有问题。我遇到过一次是因为释放预占时没有校验订单状态导致已支付的订单预占被释放库存虚增。修复方案是释放预占前先查订单状态只有已取消或超时未支付的订单才释放。同时加了一个对账任务每天凌晨比对 Redis 库存和数据库库存不一致的告警出来人工处理。5.3 配送回调重复处理的解决运力平台的回调可能会重复推送如果不做幂等订单状态会被重复更新。我的做法是在delivery_callback_log表里对回调 ID 做唯一索引重复的回调直接插入失败捕获异常后返回成功。问题现象可能原因排查方法解决方案订单状态不更新回调未收到查回调日志检查回调地址和网络库存超卖预占释放逻辑错误比对 Redis 和 DB加订单状态校验配送回调重复未做幂等查回调日志表回调 ID 唯一索引自提码重复核销并发未加锁查核销日志分布式锁围栏判断偏移坐标系不一致对比地图统一 GCJ-025.4 小程序端接口性能优化小程序端对接口响应时间很敏感超过 1 秒用户就会觉得卡。我做了几件事一是商品列表接口加了 Redis 缓存缓存时间 5 分钟二是订单列表接口做了分页每页 10 条三是图片全部走 CDN不走服务器。还有一个容易被忽略的点是接口的并发控制。秒杀类的活动如果不做限流数据库会被打挂。我用的是 Guava 的 RateLimiter 做单机限流配合 Redis 做分布式限流。限流的阈值是根据压测结果定的单机 QPS 设的是 200。6. 一些实际用下来的经验体会这个项目从签约到上线大概用了两个月中间返工了三次主要都是库存和订单状态的问题。如果让我重新做一遍我会在前期花更多时间在状态机的设计上把所有可能的状态流转画清楚而不是边写边改。另外一点体会是和甲方的沟通比写代码更重要。配送费规则、自提流程、售后政策这些业务细节一定要在开发前确认清楚并且写成文档让甲方签字。我吃过亏甲方口头说的规则和后来实际要求的不一样导致返工。技术选型上独立开发者接商单稳定压倒一切。不要为了炫技上一些不成熟的技术出了问题没人帮你兜底。Spring Boot MySQL Redis 这套组合虽然老但足够解决 90% 的问题而且资料多遇到问题能快速找到答案。最后分享一个小技巧所有涉及金额和库存的操作一定要打详细日志包括入参、出参、耗时。出问题的时候日志就是你的救命稻草。我在订单创建和库存扣减的关键路径上都加了日志后来排查问题的时候省了很多时间。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →