
简介一套基于 ASP.NET WebForms 的网络商品销售管理系统源码面向 Web 开发初学者、计算机专业学生以及需要快速搭建电商后台的开发者。项目采用典型的三层架构由 SellOnInternetModels、SellOnInternetDAL、SellOnInternetBLL 三个工程组成清晰划分了实体模型、数据访问和业务处理并通过 Default.aspx、AddDefault.aspx 等页面实现商品列表与添加功能覆盖商品查询、录入、分类管理等核心场景能直观展示 ASP.NET 页面生命周期和后端代码交互方式。资源共 55 个文件以 C# 源码.cs、页面文件.aspx、编译库.dll和 SQL 脚本为主另含解决方案与项目配置文件压缩包仅 88KB体量轻巧适合快速下载与本地调试。已有 156 人学习下载可用于课程设计、毕业设计或作为二次开发的基础模板。通过源码可深入学习分层架构的落地方式、数据库表结构设计以及商品维护的完整流程对理解真实电商后台的起步模型很有帮助。1. 网络商品销售管理系统从订单流到库存扣减先想清楚边界网络商品销售管理系统说白了就是给传统线下生意做线上化时最常用到的那套完整闭环商品上架、用户下单、库存扣减、支付回调、订单履约、后台对账全部串在一个系统里。它的复杂度不在某个单点功能而在状态与状态之间的流转——一个订单从待支付到已支付会牵动库存、流水、报表三处数据的一致性。适合谁来读要么是刚接手一个电商后台项目、想把订单链路一次做对的开发者要么是团队里负责技术选型、想评估自研还是买现成方案的人。这个系统的关键决策其实在下笔写代码之前就定了SPU/SKU怎么拆、订单状态机怎么画、库存是下单扣还是支付扣。这三个边界没定清楚后面所有功能都在给前期设计还债。下面的内容就按一线落地路径展开先建模再实现再排坑。2. 先建模再写代码把商品、订单、库存的边界定清楚写代码前花半天把数据模型和状态流转画出来比开发到一半推翻重来划算得多。这一章解决三个建模问题商品怎么分层、订单状态怎么流转、库存怎么扣。2.1 SPU与SKU拆分一张商品表撑不起多规格销售SPUStandard Product Unit是标准化产品单元SKUStock Keeping Unit是库存单元。以手机为例iPhone 14是一个SPUiPhone 14黑色256G是一个SKU。价格、库存、营销活动都必须挂在SKU上SPU只承载标题、图文、分类这些公共属性。很多入门项目把商品字段全塞进一张表单规格跑着没事一上多规格就崩。我一般会直接拆两张表CREATE TABLE spu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_code VARCHAR(32) NOT NULL UNIQUE COMMENT 商品编码业务维度唯一, title VARCHAR(128) NOT NULL COMMENT 商品标题, category_id BIGINT NOT NULL COMMENT 分类ID冗余方便列表查询, status TINYINT NOT NULL DEFAULT 0 COMMENT 0下架 1上架 2待审核 3审核拒绝, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品SPU表; CREATE TABLE sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_id BIGINT NOT NULL COMMENT 所属SPU, sku_code VARCHAR(32) NOT NULL UNIQUE COMMENT SKU编码库存、价格、订单明细都挂这个字段, spec_json VARCHAR(512) NOT NULL COMMENT 规格JSON如{颜色:黑,存储:256G}, price DECIMAL(10,2) NOT NULL COMMENT 销售价单位元, cost_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 成本价只用于后台报表不参与前台展示, status TINYINT NOT NULL DEFAULT 1 COMMENT 0停售 1在售, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_spu_id (spu_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品SKU表;这里有几个参数值得说明。price和cost_price都用DECIMAL(10,2)绝对不要用FLOAT或DOUBLE二进制浮点存金额会产生0.10.2不等于0.3的问题这在订单金额计算里是致命的。spec_json用JSON字符串存规格MySQL 5.7以上在应用层解析获取不要在SQL里用json_extract做过滤条件否则索引失效。status字段在两个表里含义不同SPU的status是商品整体状态SKU的status是单个规格的售卖状态两者要分开维护这点容易被忽略。如果你做的确实是单规格商品可以暂时不拆表但建议保留SPU/SKU两层结构。后续要加颜色、尺码、版本时拆表成本比现在拆字段成本高得多这是血泪经验。2.2 订单状态机提前把流转规则画清楚能少返工一大半订单状态是整个系统最容易翻车的地方。常见状态有待支付、已支付待发货、已发货、已完成、已取消、退款中、已退款。很多项目的订单模块烂掉不是因为SQL写得差而是状态流转没有统一约束到处都能改status字段最后数据变得自相矛盾。我用枚举把状态和流转规则集中管理public enum OrderStatusEnum { PENDING_PAYMENT(0, 待支付), PAID(1, 已支付待发货), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELLED(4, 已取消), REFUNDING(5, 退款中), REFUNDED(6, 已退款); private final int code; private final String label; OrderStatusEnum(int code, String label) { this.code code; this.label label; } public boolean canTransitTo(int target) { switch (this) { case PENDING_PAYMENT: return target PAID.code || target CANCELLED.code; case PAID: return target SHIPPED.code || target REFUNDING.code; case SHIPPED: return target COMPLETED.code || target REFUNDING.code; case REFUNDING: return target REFUNDED.code; default: // COMPLETED、CANCELLED、REFUNDED 是终态不允许再跳变 return false; } } }这个设计把状态流转规则收敛到一个方法里任何业务入口在更新状态之前先调用canTransitTo校验非法流转直接抛异常。参数说明code是数据库里存的整型值label只用于展示为什么用int不用String因为int配合枚举有编译期检查String拼写错误是运行时才发现。对外API返回时再映射成字符串枚举名即可。状态机要注意一点已取消的订单不能变成已发货已退款的订单不能再次申请退款。这两个场景在真实项目里都出现过——运营手工改单时选错了状态系统没有拦截导致仓库给已取消的订单发了货。所以凡是状态更新必须走统一入口不要在每个Mapper里直接UPDATE status。2.3 库存扣减预占、回滚与超卖的取舍库存扣减有三种常见方案它们的取舍决定了超卖风险和用户体验方案超卖风险用户体验时序复杂度下单减库存高好下单成功基本有货低支付减库存低差可能下单成功但支付时无货中预占支付扣减低好锁定库存不卖给别人高我做过的项目里B2C商城普遍用预占模式下单时锁定库存支付成功后才真正扣减超时未支付则释放锁定。核心SQL是两条条件更新-- 下单预占可售库存减1锁定库存加1条件是可售库存足够 UPDATE sku_stock SET available_stock available_stock - 1, locked_stock locked_stock 1 WHERE sku_code #{skuCode} AND available_stock 1; -- 支付成功后正式扣减锁定库存减1可售库存不动 UPDATE sku_stock SET locked_stock locked_stock - 1 WHERE sku_code #{skuCode} AND locked_stock 1;参数说明第一条SQL里的available_stock 1是防超卖的关键InnoDB对同一行UPDATE是串行加锁的两个并发请求同时执行时只有一个满足条件另一个受影响行数为0由此判定库存不足。第二条SQL的locked_stock 1防止锁定库存被扣成负数。两条SQL都必须把条件写在WHERE里不能用先SELECT再UPDATE的方式那会在查询和更新之间留下并发窗口。预占模式还要处理超时释放。常见做法是定时任务扫描创建时间超过30分钟且仍处于待支付的订单把锁定库存释放回去。释放操作同样要走条件更新并且以订单状态为过滤条件避免同一个订单被两个任务重复释放。我见过释放库存没有判状态结果支付回调和定时释放并发执行库存被加了两遍库存账面和实际差了几百件。3. 用 Spring Boot MySQL 在本地跑通最小订单链路建模定清楚之后落地实现就顺了。这一章给出一套最简但完整可运行的技术方案单体Spring Boot应用加一个MySQL库不引入Redis和消息队列先把链路跑通。日均几千单的场景这套架构完全够用等量大了再考虑分库分表和异步化。3.1 建表SQL订单、明细、库存、支付流水、幂等五张表订单表是核心订单明细表做商品快照库存表单独放支付流水表承接回调幂等表防重复下单。下面先给订单主表结构CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号业务唯一, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, pay_time DATETIME DEFAULT NULL COMMENT 支付成功时间对账用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_created (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;order_no不要用数据库自增ID那会把销量暴露给竞争对手。常见做法是雪花算法生成或者时间戳加用户ID后四位加随机数长度控制在32位以内。索引设计上uk_order_no唯一索引是必须的按用户查订单列表走idx_user_created联合索引注意联合索引的字段顺序等值条件放前面范围条件放后面所以user_id在前、created_at在后。订单明细表要冗余商品快照字段CREATE TABLE t_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, sku_code VARCHAR(32) NOT NULL, sku_name VARCHAR(128) NOT NULL COMMENT 商品名称快照, price DECIMAL(10,2) NOT NULL COMMENT 成交单价快照, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL, KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;sku_name和price必须是下单时从SKU表复制过来的不能联表实时查。SKU表后续会改价、改标题历史订单和财务报表不能被影响。这条规则写死在代码规范里比靠开发人员的自觉靠谱。支付流水表是回调幂等的基础设施CREATE TABLE t_pay_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, transaction_id VARCHAR(64) NOT NULL COMMENT 支付渠道流水号, order_no VARCHAR(32) NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL COMMENT 1成功 2失败 3退款, callback_time DATETIME NOT NULL, UNIQUE KEY uk_transaction_id (transaction_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付流水表;uk_transaction_id的唯一约束是第一道幂等防线。支付渠道同一笔交易可能推多次回调没有这个约束流水表会插入重复数据。3.2 下单接口事务与锁的边界在哪儿下单接口是链路的入口要同时处理幂等、库存预占、订单创建三个问题。代码逻辑如下Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderRequest req) { // 1. 幂等校验同一个业务请求ID不能重复下单 IdempotentRecord idem idempotentMapper.selectByBizId(req.getBizId()); if (idem ! null) { throw new BizException(duplicated request); } // 2. 预占库存条件UPDATE返回0说明库存不足 int updated stockMapper.lockStock(req.getSkuCode(), req.getQuantity()); if (updated 0) { throw new BizException(stock not enough); } // 3. 插入订单主表 OrderEntity order buildOrder(req); orderMapper.insert(order); // 4. 写入幂等表与订单插入同事务 idempotentMapper.insert(req.getBizId(), order.getOrderNo()); return OrderVO.from(order); }事务内的操作顺序我习惯固定为先校验幂等再锁库存再插订单最后写幂等表。全局统一这个顺序能显著减少死锁。如果有两个并发事务一个先锁库存A再锁库存B另一个先锁库存B再锁库存A就可能互相等待。统一顺序后资源总是按同一方向加锁死锁概率降到最低。幂等校验有个细节selectByBizId在并发时可能两个线程都查到null都往下走。解决办法是幂等表用INSERT IGNORE或者捕获唯一索引冲突异常发现冲突就说明是重复请求。我一般用try-catch捕DuplicateKeyException在catch里返回已经存在的订单号。3.3 支付回调处理重复投递与状态校验都是硬骨头支付回调是外部系统触发的你控制不了它的调用次数和时间。回调处理的核心目标是幂等代码要能扛住重复投递和乱序到达Transactional(rollbackFor Exception.class) public void handlePayCallback(PayCallbackDTO callback) { // 1. 按渠道流水号查重已处理过直接返回成功 PayFlowEntity flow payFlowMapper.selectByTransactionId(callback.getTransactionId()); if (flow ! null) { log.warn(duplicated pay callback: {}, callback.getTransactionId()); return; } // 2. 校验订单状态只允许待支付 - 已支付 OrderEntity order orderMapper.selectByOrderNo(callback.getOrderNo()); if (order null || order.getStatus() ! OrderStatusEnum.PENDING_PAYMENT.getCode()) { throw new BizException(order status not payable); } // 3. 金额核对回调金额必须等于订单金额 if (callback.getAmount().compareTo(order.getTotalAmount()) ! 0) { throw new BizException(pay amount mismatch); } // 4. 正式扣减锁定库存 stockMapper.deductLockedStock(order.getSkuCode(), order.getQuantity()); // 5. 更新订单状态为已支付CAS条件更新防并发覆盖 int cnt orderMapper.casUpdateStatus( order.getOrderNo(), OrderStatusEnum.PENDING_PAYMENT.getCode(), OrderStatusEnum.PAID.getCode()); if (cnt 0) { throw new BizException(order status changed concurrently); } // 6. 插入支付流水唯一索引兜底 payFlowMapper.insert(buildFlow(callback)); }参数说明第1步查重不是简单的提前返回重复回调时返回给支付渠道的结果必须是成功否则渠道会一直重试重试风暴会把接口打挂。第2步状态校验用代码里的枚举常量不要写魔法数字。第3步金额对比用BigDecimal的compareTo不能用equals因为equals会比较精度2.0和2.00会被判为不同。第5步的CAS更新是防并发覆盖的关键WHERE条件里带原状态值受影响行数为0说明状态已经被别人改了。支付流水表插入放在事务最后一步如果前面任何一步抛异常流水不会落库回调会重试数据依然一致。整个事务边界清晰要么全部生效要么全部回滚。4. 后台与管理端销售管理系统里最容易被低估的四个模块前台卖货的功能好写后台的管理模块才是运营真正天天用的。权限、对账、审核、审计这四个模块决定系统能不能稳定运营起来也经常是技术方案里被一带而过的地方。4.1 权限模型RBAC与数据权限要拆成两层用户表、角色表、菜单表加两张关联表是最常见的RBAC五表模型。查询用户权限的SQL很直观SELECT DISTINCT m.permission_code FROM t_user u JOIN t_user_role ur ON u.id ur.user_id JOIN t_role r ON ur.role_id r.id AND r.status 1 JOIN t_role_menu rm ON r.id rm.role_id JOIN t_menu m ON rm.menu_id m.id WHERE u.id #{userId} ORDER BY m.permission_code;纯RBAC只解决了功能权限解决不了数据权限。销售一部的经理和销售二部的经理都能看订单菜单但一部经理只能看一部的订单。这个限制不能写在菜单上要在订单查询SQL里追加数据权限条件。常见做法是用户表里存dept_id查询订单时通过部门层级过滤。如果部门层级深在建表时就冗余dept_path字段比如“/1/10/102”用LIKE前缀匹配比递归查部门树快得多。会话管理方面后台管理系统我很少用纯JWT因为JWT注销比较麻烦要维护黑名单。用Redis存会话登录成功后生成token作为key用户信息作为value过期时间设8小时注销时直接删key可控性更好。4.2 销售报表与对账时间维度不统一月底必吵架报表模块最容易出的问题就是时间口径。订单的创建时间和支付时间可能差几天如果按创建时间统计销售额会把大量未支付订单算进去销售额虚高。正确做法是按支付时间统计SELECT DATE(pay_time) AS biz_date, COUNT(DISTINCT order_no) AS order_cnt, SUM(total_amount) AS sales_amount FROM t_order WHERE status IN (1, 2, 3) AND pay_time #{startDate} AND pay_time #{endDate} INTERVAL 1 DAY GROUP BY DATE(pay_time) ORDER BY biz_date DESC;状态过滤用IN (1,2,3)即已支付、已发货、已完成把已退款和已取消排除在外。退款订单要单列一张退款报表不要直接在销售额里做减法因为退款有账期延迟混在一起会影响销售趋势判断。对账的逻辑是拉取支付渠道的账单文件与本地t_pay_flow表逐笔比对。要注意渠道账单里的时间是支付成功时间本地也要用pay_time去对而不是created_at。两边时间口径不一样时会出现大量误报差异财务会天天来问。本地有流水但渠道没有的情况多半是回调处理时异常导致流水没落库渠道有但本地没有通常是回调丢失需要按订单号人工触发一次查询补单。4.3 商品上下架与审核流状态与生效时间分开管理商品状态和生效时间是两个维度不能混在一个字段里。运营周四设置周五零点上架如果不支持定时生效就只能等周五手动点而且很容易忘。我给商品表加了effect_time字段后定时任务每分钟扫一次UPDATE spu SET status 1 WHERE status 0 AND effect_time IS NOT NULL AND effect_time NOW();这里status分两层审核状态和上架状态。我习惯用status表示审核状态用listed字段表示是否上架但更简单的做法是把状态拆成0下架、1上架、2待审核、3审核拒绝。定时任务只把待审核通过且到了生效时间的商品置为1。效果上审核通过和定时上架被拆成两个动作运营配置好时间后不用再管系统到点自动执行。定时下架同理加一个disable_time字段到点把status从1改回0。注意UPDATE条件要带上当前状态防止两个任务并发操作时互相覆盖。审核记录表需要单独建记录审核人、审核意见、审核时间这与操作日志是两回事。4.4 操作日志谁在什么时间改了什么价格运营改错价格导致损失开发拿不出证据这种事发生过一次就长记性了。操作日志表结构如下CREATE TABLE t_oper_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, operator_id BIGINT NOT NULL, operator_name VARCHAR(32) NOT NULL, module VARCHAR(64) NOT NULL COMMENT 模块名如商品、订单、价格, action VARCHAR(32) NOT NULL COMMENT 动作如UPDATE_PRICE、UPDATE_STATUS, biz_id VARCHAR(64) NOT NULL COMMENT 业务ID如sku_code或order_no, detail_json JSON NOT NULL COMMENT 变更前后值, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_module_biz (module, biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT操作日志表;实现方式我推荐在Service层用AOP注解加SpEL表达式把方法参数里的关键字段自动拼成detail_json而不是在每个业务方法里手写日志代码。有个原则要记牢日志写入不能影响主流程日志表写入失败不能导致业务失败。所以日志记录要异步化最简单的方式是丢到线程池里写或者用Spring的事件发布机制主事务提交后再发事件。日志表会涨得非常快建议按月分表保留12个月超过的归档到冷存储。价格变更、订单状态变更、权限分配这三个模块必须无条件记录省略任何一个都是给自己埋雷。5. 避坑清单并发下单、支付回调与价格变更的五个翻车现场下面这五条是跑过线上项目后反复遇到的真问题每个都按现象、原因、解决的顺序写清楚。新项目照着检查一遍能省掉不少上线后的深夜告警电话。5.1 超卖支付时才发现库存不足现象下单时显示有货支付成功后仓库发货时发现实际库存不足available_stock变成了负数。原因下单接口先查库存再扣减没有用条件UPDATE。两个并发请求都查到剩余1件然后都执行了扣减逻辑库存就变成了-1。解决把扣库存改成UPDATE ... WHERE available_stock quantity受影响行数为0就是库存不足直接拒绝下单。第2章里给出的SQL就是标准解法。另外要注意下单减库存和支付减库存两种模式的超卖窗口不同。支付减库存模式下下单时并不锁库存两个用户可能都下单成功后支付的用户扣不动所以必须做下单预占。5.2 支付回调重复投递导致重复发货现象订单已经发货了支付渠道隔天又推了一次成功回调系统再次触发发货动作仓库发了两件。原因回调处理没有做幂等每收到一次回调就执行一次状态更新和发货动作。解决支付流水表唯一索引加订单状态校验双重兜底。处理逻辑是先查transaction_id已存在直接返回成功订单状态已经是PAID也直接返回成功。这里有个细节即使重复回调也要返回成功不能抛异常否则支付渠道会无限重试重试风暴会顺着回调接口打进来。5.3 订单状态并发更新互相覆盖现象用户同时点取消订单和支付后端两个请求并发处理最后订单变成既已支付又已取消。原因两个线程都读到订单状态是待支付都通过了状态校验然后各自UPDATE后提交的覆盖了先提交的。解决状态更新用CAS式SQLUPDATE ... WHERE order_no ? AND status 预期值。受影响行数为0时重新查订单状态判断是并发冲突还是状态本身不合法。不要用先SELECT再UPDATE的方式那等于没有锁。5.4 商品改价后历史订单金额跟着变现象上个月的订单明细里金额变成了现在的价格财务报表对不上财务拒绝对账。原因订单明细表没有做商品快照查询时联表取了SKU表当前的价格。SKU表改价后历史订单展示也跟着变了。解决下单时把商品名称、成交单价、规格参数原样复制到t_order_item表。后续SKU表随便改历史订单不受影响。这条规则适用于所有订单要展示的商品信息包括标题、主图URL、规格文本。5.5 分页深翻页性能骤降现象订单管理页翻到第500页时接口耗时从几十毫秒涨到几秒数据库CPU飙升。原因LIMIT 49980, 20的语义是扫描前49980行再丢掉翻得越深扫得越多索引也救不了。解决后台列表改游标分页只支持上一页和下一页。方案是WHERE id 上一页最后一条ID ORDER BY id DESC LIMIT 20。如果产品一定要保留任意跳页就限制只能查最近三个月的数据并且强制走时间范围索引。我一般直接把后台列表都改成游标分页运营对跳页的需求没那么强性能收益却非常直接。6. 上架前夜用压测与 Mock 把边界问题提前暴露上线前最后一个晚上别急着部署先把下面三件事做了。它们不需要复杂的平台工具一个JMeter和一段Mock代码就能完成。6.1 用JMeter压下单接口看TP99而不是平均值JMeter里建一个线程组100个线程循环10次请求下单接口。聚合报告里重点看TP99和错误率不是看Average。平均值好看但掩盖长尾问题线上故障往往发生在P99那批慢请求上。如果TP99超过500毫秒先用慢SQL日志看有没有全表扫描再看数据库行锁等待时间。下单接口的瓶颈通常是库存表的行锁竞争压测时可以把并发数调高到500观察锁等待曲线是否线性增长。6.2 Mock支付网关把超时、重复回调、金额不符都测一遍自己写一个Mock回调接口模拟支付渠道把各种异常场景全跑一遍PostMapping(/mock/pay/callback) public String mockCallback(RequestBody PayCallbackDTO dto) { // 同一个transactionId连发5次验证幂等 for (int i 0; i 5; i) { payService.handlePayCallback(dto); } // 再发一个金额不一致的回调验证会被拒绝 PayCallbackDTO badAmount dto.clone(); badAmount.setAmount(new BigDecimal(0.01)); payService.handlePayCallback(badAmount); return ok; }这段代码跑完后检查订单状态、支付流水、库存三个数据源是否一致。订单应该只有一笔流水库存只扣减一次金额不一致的回调被拒绝了。6.3 用一条SQL扫出脏数据上线前在测试环境跑一遍一致性检查-- 查重复订单号正常结果为空 SELECT order_no, COUNT(*) FROM t_order GROUP BY order_no HAVING COUNT(*) 1; -- 查状态为已支付但pay_time为空的数据正常结果为空 SELECT id, order_no FROM t_order WHERE status 1 AND pay_time IS NULL;这两条SQL能扫出事务边界没写对的问题。我之前有个项目上线前没测重复回调上线当天支付渠道的重试机制触发了两次发货半夜被叫起来补物流状态。从那以后我把幂等三件套——唯一索引、状态校验、CAS更新写进了所有写接口的默认规范。这套流程看着琐碎但它能把上线后的意外从“事故”降级成“无事”。希望帮到你。本文还有配套的精品资源点击获取