
我们是不是忘了怎么做设计从一次订单超时关闭方案引发的思考前几天在技术评审会上一个同学介绍订单超时关闭的改造方案。PPT 里放了架构图、类图、时序图看起来非常完整。但评审快结束时我问了一个问题为什么选定时任务扫表而不选延迟消息现场安静了几秒然后回答是大家都这么做的。这个场景让我想到一个更深层的问题我们的开发工具越来越强了脚手架越来越完善了AI 代码助手也能自动补全一大半业务代码了但真正做设计的能力反而在悄悄退化。这不是 UI 层面的设计而是软件设计——拿到一个模糊需求后如何拆解问题、如何权衡方案、如何定义接口、如何预判边界条件。回想一下你最近一次写代码是不是拿到需求后第一反应是搜一下有没有现成依赖而不是先花半小时把问题想清楚这篇文章想聊的就是为什么我们正在丢失设计能力以及如何把它找回来。如果你是刚工作一两年的开发这篇文章能帮你建立一套自己的设计思路如果你是带团队的技术负责人文章末尾有一些可以在团队里落地执行的建议。1. 这篇文章真正要解决的问题先给一个明确判断当前很多软件项目的技术债根源不是代码写得差而是在动手写代码之前设计环节被压缩甚至跳过了。为什么会出现这种情况我认为有三个直接原因。第一开发效率工具让试错变得太便宜。以前写一个模块要规划好接口、数据结构、异常处理因为改起来代价太高。现在有热重载、有自动化测试、有 AI 辅助编码改代码的成本大幅降低所以很多人倾向于先写出来再说。这本身没错但它悄悄改变了一个习惯——很多人不再花时间设计方案而是写一个版本跑起来然后等着 review 时发现问题再改。第二技术社区和开源生态让默认方案变得太容易获得。遇到限流就上 Sentinel遇到缓存就上 Redis遇到消息就上 Kafka。框架的默认配置、官方示例、社区模板让开发者很容易绕开设计决策。但容易获得不等于适合你的场景。我就见过一个日活不到一万的后端系统引了六个中间件每个中间件都有一堆自定义配置最后运维同学维护得非常痛苦。第三需求迭代节奏越来越快设计在项目管理里变成了奢侈品。在传统流程里设计阶段是明确的一个环节在现在的迭代节奏下很多团队把设计揉进了排期里看起来并行推进实际上就是没时间做。本文真正要回答的问题是在快节奏开发环境下设计到底应该做到什么程度什么样的设计产出是必要的如何让设计能力回到日常开发中这篇文章适合下面几类读者写业务代码但总觉得设计是架构师的事的开发者。正在带团队、发现组员交付的代码越来越难维护的技术负责人。想提升技术方案能力、准备晋升答辩的候选人。看完这篇文章你会得到一套可以在下一次迭代中直接使用的设计实践方法而不是空洞的要重视设计。2. 软件设计不是画架构图而是做决策要聊忘记设计先得给软件设计一个清晰的定义。很多人一听到设计脑子里浮现的是画满方框和箭头的架构图。这是一个很大的误解。架构图只是设计的呈现形式不是设计本身。真正的设计是一系列在约束条件下做决策的过程。举一个最简单的例子你的服务需要从上游读取一份数据这个上游可能是关系型数据库、Redis 缓存、第三方 HTTP 接口也可能是 Kafka 消息。不同来源意味着不同的延迟特征、一致性语义和故障模式。你选择用 HTTP 接口轮询还是订阅消息推送这就是一个设计决策。这个决策的影响会在这个系统存活期间一直存在。软件设计中的决策通常可以拆成几个层次需求层的设计。这个需求到底要解决什么问题边界在哪里哪些是真正的业务规则哪些是当前实现方式的产物很多设计失误是在需求还没澄清时就开始画架构图。架构层的设计。系统由哪些模块组成模块之间的依赖关系是单向还是双向数据在模块之间如何流动这一层做错了后面重构成本极高。接口层的设计。模块之间的边界用什么形式表达是 RPC 接口、消息事件、还是共享数据库表接口的语义是什么——是命令还是查询是强一致还是最终一致数据层的设计。数据模型怎么建状态如何流转数据如何存储、如何索引、如何归档这一层往往在业务代码写完之后才被重视但改起来最伤筋动骨。实现层的设计。具体类怎么划分、异常怎么处理、日志怎么记录、配置怎么管理。这一层最容易被 AI 代码助手替代也最容易出问题。你可以把这几个层次理解成建造房子时的不同环节。需求层是确认你要的是住宅还是厂房架构层是决定用钢筋混凝土还是木结构接口层是墙体之间怎么衔接数据层是水电管线的走向实现层是每一块砖怎么砌。现在多数开发者的问题在于把大量时间花在了最后一层实现层而前面几层基本靠直觉、靠惯性、靠别人都这么干。为什么会这样因为实现层有即时反馈——写完代码就能跑跑起来看到结果就有成就感。而前面几层没有即时反馈做对了没有感觉做错了要到很晚才暴露。人性天然倾向于做有即时反馈的事情于是设计环节被压缩也就是必然的了。有了这个认知再来看当前开发环境里的一些典型现象就更容易理解问题出在哪了。3. 设计能力退化的五个典型表现我不打算空谈要重视设计先列举五个非常具体的表现你可以对照自己的团队看看是否中招。表现一技术选型只选最熟悉的不选最合适的。问一个团队为什么用 MongoDB回答是我们一直用这个。再问为什么不用 MySQL回答是表结构老变MySQL 麻烦。这种回答把熟悉和合适混为一谈了。真正合适的选型应该基于对数据特征、访问模式、一致性要求、团队维护成本的综合判断。很多技术选型失误不是选错了技术而是根本没有做选型决策——只是沿用了上一个项目的方案。表现二接口设计只考虑当前调用方不考虑未来扩展。有一个很典型的例子登录接口返回用户信息当时只有一个调用方所以直接把数据库实体类序列化返回了。后来加了第二个调用方需要的字段不一样再加第三个调用方希望返回的字段格式不同。这时候接口已经没法改了因为改接口会破坏已有调用方于是只能加一个v2接口。这种场景在真实项目里每天都在发生。接口设计在最开始没有考虑这个接口的语义是什么、契约是什么、变化点在哪里后面就要用无数兼容代码来还债。表现三状态流转没有设计靠 if-else 硬写。电商订单、审批流、任务调度凡是带状态的业务几乎都能看到这种代码if (status 1 action cancel) { ... }。状态一多条件组合爆炸每个业务方法里都塞满了状态判断。真正的设计应该是把状态机抽出来——哪些状态允许哪些流转、每个流转需要什么条件、触发后做什么动作。没有状态机设计功能测试也难写因为你要靠猜才能知道当前状态 这个操作会产生什么结果。表现四错误处理没有设计异常全部向上抛。很多代码里的异常处理只有两种形态一是 catch 住打日志然后继续往下走二是什么都不 catch 让全局兜底。这两种做法都回避了关键问题这个操作的失败边界在哪用户能看到什么信息数据的一致性怎么保证需不需要重试需不需要补偿错误处理也是设计而且是最容易暴露设计缺失的地方。表现五几乎没有文档化决策记录。做过的技术选型、接口约定、架构调整散落在聊天记录、代码注释和 PPT 里。过了半年新同学加入问为什么这里要用消息队列没有人能完整回答。设计决策没有记录意味着团队在反复踩同一个坑。如果你在团队里看到了三个以上这样的现象那么可以确定这个团队的设计能力正在退化。好消息是设计能力是可以训练和恢复的不需要等招一个资深架构师来解决。接下来的部分我会用一个完整案例来说明恢复设计能力应该从哪里入手。4. 重新学一次设计订单超时关闭的完整推演这里用一个几乎所有电商系统都会遇到的需求把设计完整走一遍。这个案例的关键不是最后的代码而是每一步的决策过程。4.1 需求澄清先别急着想方案需求原文一般是这样的用户下单后如果 30 分钟未支付订单自动关闭。看起来很简单但如果你直接开始想技术方案你就跳过了最重要的需求澄清阶段。这个需求至少有以下几个模糊点30 分钟未支付的计时起点是什么下单时间还是创建支付单的时间订单关闭后如果用户再次发起支付怎么办库存是下单时锁定还是支付时预占如果下单时锁定库存关闭订单时需要释放库存这两个操作如何保证一致订单关闭需要通知用户吗通过什么渠道通知如果系统在判断需要关闭到真正关闭之间宕机了恢复后怎么补偿订单量大不大秒杀场景和普通电商场景方案完全不同。这些问题的答案直接决定后面选择哪个方案。你可以在纸上回答这些问题也可以把需求澄清的结果写成一段验收标准。这个环节看起来不产生代码但它决定了后面所有的设计方向。这里我给出本案例的假设订单在下单时预占库存支付超时后需要关闭订单并释放库存关闭操作需要发送通知系统允许恢复后补偿不允许重复关闭同一笔订单。4.2 方案对比三种常见做法的取舍澄清完需求进入方案设计。订单超时关闭业界常见的方案有三类。方案一定时任务扫表。每 N 分钟扫描一次订单表找出超时未支付的订单批量关闭。优点是实现简单、可控性强缺点是实时性差最多延迟一个扫描周期而且扫表对数据库有一定压力。在订单量很大的情况下扫表会越扫越慢。方案二延迟消息。用户下单后发送一条延迟 30 分钟的消息。消息队列在 30 分钟后投递给消费者消费者检查订单状态如果仍未支付则关闭。优点是可以做到准实时削峰效果好缺点是需要引入消息队列且要考虑消息丢失、重复投递等情况。方案三时间轮或内存延时队列。在应用内存里维护一个定时器到点触发检查。优点是轻量、实时性好缺点是系统重启会丢失内存中的定时任务如果订单分散在多台机器上还需要考虑任务分配问题。从设计角度看这三个方案不是选一个最先进的而是选一个最匹配当前场景约束的。假设你的系统订单量每天几万单数据库是 MySQL没有引入消息队列那么方案一可能反而是最合适的——因为引入消息队列的运维成本和架构复杂度对当前系统来说不太值当。如果订单量是每秒上千笔、要求准实时释放库存那方案二更合适但代价是系统多了一个强依赖。这就是设计决策的本质在约束条件下做权衡而不是选一个最好的标准答案。本案例为了同时演示设计过程和代码实现我选择方案一来展开并把扫描间隔、批量大小、补偿机制都设计进去。4.3 状态设计先定义清楚再说任何订单系统的核心都是订单状态的建模。这里给出一个最小但完整的状态设计状态含义可流转到PENDING待支付PAID, CLOSEDPAID已支付COMPLETED, REFUNDINGCLOSED已关闭无COMPLETED已完成REFUNDINGREFUNDING退款中CLOSED关键设计点在PENDING - CLOSED这条流转上只有待支付的订单才能被关闭。如果订单已经支付了关闭操作必须失败。这个状态机有几个容易踩坑的地方关闭操作必须是幂等的。也就是说同一笔订单连续调用两次 close第二次应该返回成功但不产生副作用。CLOSED是终态不允许任何状态再流转进去也不允许从CLOSED再流转回PENDING。状态变更必须带版本号或使用条件更新防止并发下把已支付的订单覆盖成已关闭。4.4 接口设计把边界定义清楚基于上面的状态机我们可以定义关闭订单的核心接口。这个接口会被定时任务调用也可能被用户取消操作调用未来还可能被管理后台调用。// 文件路径src/main/java/com/example/order/service/OrderClosingService.java public interface OrderClosingService { /** * 关闭超时未支付订单 * * param orderId 订单号 * return true 表示关闭成功false 表示订单状态不允许关闭 */ boolean closeExpiredOrder(String orderId); }这里有几个设计考量第一返回boolean而不是直接抛异常是因为订单状态不允许关闭在很多场景下是正常业务分支比如用户恰好在超时前完成了支付不该被当作异常处理。第二方法名用closeExpiredOrder而不是closeOrder是为了语义清晰这个接口只处理超时未支付场景。如果未来有用户主动取消场景应该单独定义接口而不是复用这个。第三真正修改订单状态时需要把判断状态 执行修改放在同一个事务/锁内避免并发覆盖。4.5 代码实现把设计落到代码接下来看一下核心实现。首先是实体类和状态定义。// 文件路径src/main/java/com/example/order/entity/Order.java public class Order { private String orderId; private OrderStatus status; private Long createdTime; private Long paidTime; private Long closedTime; private Integer version; // getter / setter 省略 } // 文件路径src/main/java/com/example/order/entity/OrderStatus.java public enum OrderStatus { PENDING, PAID, CLOSED, COMPLETED, REFUNDING }然后是关闭服务实现类。这里使用乐观锁来控制并发状态变更。// 文件路径src/main/java/com/example/order/service/impl/OrderClosingServiceImpl.java Service public class OrderClosingServiceImpl implements OrderClosingService { Resource private OrderMapper orderMapper; Resource private InventoryService inventoryService; Override Transactional(rollbackFor Exception.class) public boolean closeExpiredOrder(String orderId) { // 1. 查询当前订单 Order order orderMapper.selectById(orderId); if (order null) { return false; } // 2. 只有待支付状态才能关闭 if (order.getStatus() ! OrderStatus.PENDING) { return false; } // 3. 乐观锁更新状态version 防止并发覆盖 int updated orderMapper.compareAndSetStatus( orderId, OrderStatus.PENDING, OrderStatus.CLOSED, order.getVersion() ); if (updated 0) { // 说明状态已被其他事务修改 return false; } // 4. 释放预占库存 inventoryService.releaseStock(orderId); // 5. 发送通知异步或消息队列 notifyService.sendOrderClosedNotification(orderId); return true; } }对应的 Mapper 使用条件更新 SQL!-- 文件路径src/main/resources/mapper/OrderMapper.xml -- update idcompareAndSetStatus UPDATE t_order SET status #{targetStatus}, closed_time NOW(), version version 1 WHERE order_id #{orderId} AND status #{expectStatus} AND version #{version} /update这段 SQL 是核心WHERE条件里的status和version保证了只有状态还处于 PENDING 且版本号未变化时才能被更新为 CLOSED。如果同一时刻有两个线程都在执行关闭操作只有一个会更新成功。5. 定时任务的设计与实现不只是写个 Cron有了核心关闭逻辑还要设计定时任务本身。定时任务的常见设计错误有三种扫描范围没有按时间分区、单次扫描数据量无上限、任务重启后没有补偿机制。正确的做法是每次扫描只捞超时阈值之前创建、且仍处于待支付状态的订单并且限制扫描条数处理完后记录本次扫描的游标或时间点。5.1 扫描任务的配置# 文件路径src/main/resources/application.yml order: close: enabled: true expire-minutes: 30 scan-interval-seconds: 30 batch-size: 200配置说明expire-minutes超时阈值对应需求里的30 分钟。scan-interval-seconds扫描间隔决定关闭的实时性。这里设置了 30 秒。batch-size每批处理的最大订单数防止单次任务执行过久或给数据库造成压力。5.2 扫描任务的实现// 文件路径src/main/java/com/example/order/job/OrderCloseScheduler.java Component public class OrderCloseScheduler { Resource private OrderCloseService orderCloseService; Scheduled(fixedDelayString ${order.close.scan-interval-seconds}000) public void scanExpiredOrders() { if (!closeEnabled()) { return; } String beforeTime expireBeforeTime(); ListString expiredOrderIds orderCloseService.listExpiredOrderIds(beforeTime, batchSize()); for (String orderId : expiredOrderIds) { try { orderCloseService.closeExpiredOrder(orderId); } catch (Exception e) { // 单笔失败不阻塞整批记录日志后继续处理 log.error(close expired order failed, orderId{}, orderId, e); } } } }这里有一个非常容易被忽略的设计点fixedDelayString使用的是fixedDelay而不是fixedRate。区别在于fixedRate是固定速率——上一个任务执行超时下一个任务也会按时启动可能导致任务重叠fixedDelay是固定延迟——上一次执行完再等一个间隔才启动下一次天然避免任务重叠。5.3 补偿机制任务宕机了怎么办定时任务方案最常见的质疑是如果任务在扫描到订单、但还没执行关闭时宕机了那这笔订单是不是永远关闭不了答案是不一定但我们必须设计补偿。两种思路每个扫描周期都重新扫描已超时但仍为 PENDING的订单。这样上一轮漏掉或失败的下一轮会被再捞出来。在订单表里增加close_retry_count每次扫描时对close_retry_count超过阈值的订单做告警。第一种思路实现最简单也是本案例采用的做法因为关闭条件是超时且状态为 PENDING而不依赖是否被扫描过所以没处理完的订单会在下一轮继续处理。代价是可能有一小段延迟但配合告警机制完全可以接受。5.4 验证关闭功能代码写完后怎么验证可以写一个测试用例把超时时间改成很小的值比如 2 秒下单后等待几秒再检查订单状态是否变为 CLOSED。// 文件路径src/test/java/com/example/order/service/OrderClosingServiceTest.java SpringBootTest class OrderClosingServiceTest { Autowired private OrderClosingService orderClosingService; Test void shouldClosePendingOrderWhenExpired() { // 准备创建一笔 PENDING 状态的订单 String orderId createPendingOrder(); // 执行直接调用关闭接口 boolean result orderClosingService.closeExpiredOrder(orderId); // 断言关闭成功订单状态变为 CLOSED assertThat(result).isTrue(); assertThat(getOrderStatus(orderId)).isEqualTo(OrderStatus.CLOSED); } Test void shouldNotClosePaidOrder() { String orderId createPaidOrder(); boolean result orderClosingService.closeExpiredOrder(orderId); assertThat(result).isFalse(); assertThat(getOrderStatus(orderId)).isEqualTo(OrderStatus.PAID); } }这就是设计带来的直接收益因为状态流转清晰、接口语义明确、幂等条件清楚测试用例可以很快写出来。而如果你用的是散落在各处的一大堆 if-else测试会很难覆盖完整。6. 设计文档应该长什么样轻量设计记录模板很多开发者的心理负担是设计就要写很长的文档。这是另一个误区。设计文档的作用不是给领导看的而是帮团队记录决策、避免重复踩坑。所以它应该足够轻量能在半小时内写完。这里给一个可以直接用的模板每个设计决策控制在几百字# 设计决策记录订单超时关闭 ## 背景 用户下单后 30 分钟未支付订单需要自动关闭并释放库存。 ## 需求约束 - 关闭后不允许继续支付若用户支付请求到达需返回明确提示。 - 关闭操作必须幂等。 - 系统重启后未处理完的订单应能在下一轮扫描中被补偿。 ## 方案选型 - 方案一定时扫表选用 - 方案二延迟消息未选用 - 未选用原因系统当前未引入消息队列为单一场景引入新中间件成本过高。 ## 关键决策 1. 状态机PENDING - CLOSED 是唯一关闭路径。 2. 并发控制使用乐观锁 CAS 更新防重复关闭。 3. 补偿机制每轮扫描条件为超时且仍为 PENDING天然支持补偿。 ## 影响范围 - 新增接口OrderClosingService#closeExpiredOrder - 新增配置order.close.expire-minutes / scan-interval-seconds / batch-size - 涉及表t_order新增 closed_time 记录 ## 备选方案回顾 如果未来订单量达到每秒数千笔建议重新评估延迟消息方案并将关闭动作改为异步事件驱动。这个模板的核心是记录为什么这么做而不是记录做了什么。代码本身已经表达了做了什么设计文档的价值是保存那些代码里看不到的权衡过程。7. 常见问题与排查方法在实践设计回填和代码走查时下面这些问题出现频率最高。问题现象可能原因排查方式解决方案关闭订单后用户仍然支付成功关闭和支付没有并发控制两个操作同时执行查看订单状态变更日志确认操作顺序支付接口在扣款前校验订单状态并使用乐观锁或分布式锁同一笔订单重复关闭释放库存两次关闭接口不是幂等的查看库存流水表检查是否有两条释放记录关闭操作改为幂等重复关闭直接返回成功但不动作定时扫描慢数据库压力大扫描 SQL 没有按时间条件走索引或扫描全表查看慢查询日志执行计划增加(status, created_time)联合索引限制扫描条数任务执行时间超过扫描间隔任务重叠使用了 fixedRate 或没有加分布式锁查看任务日志是否有重叠改为 fixedDelay或在多实例部署时引入分布式锁订单关闭后库存一直没释放释放库存的调用失败但异常被吞掉了检查日志中是否有 releaseStock 的 error 记录释放库存改为事务性消息或用事务消息保证最终一致新同学看不懂状态流转逻辑状态判断散落在大量 if-else 中Code Review 时检查将状态机集中到枚举或独立类中写清楚流转矩阵这些问题的共同根源基本可以归结为两类一是设计阶段没有定义清楚状态和幂等边界二是实现阶段没有通过代码把设计守住。8. 如何在日常开发中恢复设计能力恢复设计能力不需要从零学一门设计学更不需要天天画架构图。下面这几件事是每个团队明天就可以开始做的。第一给每个业务需求留 30 分钟设计时间。无论需求大小在排期时留出 30 分钟到 1 小时的设计时间。这段时间不需要写文档只需要回答三个问题这个需求涉及哪些状态变化哪些操作需要幂等数据最终落在哪里、如何被读取这三个问题想清楚80% 的坑都可以避免。第二Code Review 时不只评论代码风格还要问设计问题。当前很多 Code Review 停留在变量命名是否有空指针要不要加日志这个层面。这些当然重要但更重要的是问为什么这个接口这样设计这个状态的流转条件是什么如果并发场景下两个请求同时到达会怎样一旦开始问这些问题团队的设计意识就慢慢回来了。第三用决策记录代替架构文档。不要求团队写大而全的设计文档但每个重要的技术方案至少留下一条决策记录。记录里写清楚选项、选择理由、放弃理由。半年后如果有人问为什么用这个方案直接看记录就行。第四让新人从读设计开始而不是从抄代码开始。新同学入职很多团队的做法是给一个简单的需求让他上手写。更好的做法是先让他读现有模块的决策记录和状态机设计再给他一个小需求要求他先写设计思路再写代码。这样训练半年新人的设计能力会明显高于平均水平。第五定期做设计复盘。每个迭代结束后挑一个线上问题或一个返工最多的需求回溯一下问题出在哪个设计环节是需求没澄清就动手还是方案选型没权衡还是接口定义不清楚这样的复盘比这周出了几个 bug更有价值。9. 总结与后续学习方向回到文章标题的问题我们是不是忘了怎么设计我的判断是不是彻底忘了而是被效率和工具推着走把设计当成了可以省略的步骤。但只要把设计重新定义为在约束下做决策而不是画架构图或写长文档它就会重新变得可执行、可训练、可落地。这篇文章真正想传达的有三件事第一设计能力退化的根源是流程和认知不是能力问题。工具的便利让我们更倾向于跳过设计但跳过设计的代价会在几周或几个月后以技术债的形式出现。第二以订单超时关闭为例完整展示了从需求澄清、方案对比、状态设计、接口设计到代码实现的过程。这中间最值钱的不是代码而是每一步的决策依据。第三恢复设计能力不靠大动作靠的是每天都做的小事需求评审时多问一句状态边界Code Review 时多问一句并发安全重要方案留下一段决策记录新人入职先读设计再写代码。如果你想让设计能力再深入一步下面几个方向值得继续学习状态机建模从订单、审批流等业务出发理解状态机如何帮助简化复杂业务逻辑。领域驱动设计DDD尤其是聚合、限界上下文的概念对你判断接口边界画在哪非常有帮助。分布式系统设计幂等、最终一致性、补偿事务这些不只是中间件特性更是设计原则。架构决策记录ADR一套更完整的决策记录格式适合用于团队沉淀架构资产。最后给一个实用的建议下次接到需求先别急着打开 IDE。拿张纸把这个需求的状态变化是什么、哪些操作可能重复、数据最终落到哪三个问题写下来。就这三步你已经比多数开发者多做了一层设计。