
苍穹外卖这个项目前九天很多同学都能顺畅地跟着敲但到了day10、day11开始犯迷糊。不是代码变难了而是你第一次要面对“一个功能从用户端一路打到商家端”的完整链路。day10和day11这两天本质上只讲了一件事把“购物车”变成“订单”再把“订单”变成商家收银台里的一条待办。围绕这件事会涉及下单接口的设计、支付状态的流转、定时任务兜底超时订单以及WebSocket实时来单提醒。如果你也想搞清楚订单模块为什么这样设计建议花点时间把这两天的逻辑自己推演一遍。这篇内容适合正在跟项目进度、或者准备在简历上写订单系统的同学参考我会把关键设计和踩过的坑一起说清楚。1. 这两天的内容到底在打通什么从购物车到订单闭环1.1 day10和day11在整个项目里的位置苍穹外卖前面几天的安排大致是员工端基础功能、分类菜品管理、Redis缓存优化然后是用户端的小程序登录、地址簿、购物车。到day10之前用户能逛、能加购物车但业务链路是断的——用户买不了东西。day10和day11就是接上最后两段用户提交订单并支付商家端收到提醒并处理订单。学完之后你回头看整个项目会发现它是一个完整的业务闭环用户从浏览、加购、下单、支付到商家接单、制作、配送、完成。我自己当时最大的感受是前面学的是“一个个孤立接口”到了这两天才开始学“接口之间怎么协作”。比如下单接口要动购物车、订单表、订单明细表、地址簿、商品数据支付回调要动订单状态定时任务要扫描订单表WebSocket又把订单状态变化实时推给商家端。每个接口本身都不复杂但它们之间的事务边界、状态一致性、并发问题才是真正值钱的内容。1.2 一次完整外卖流程涉及的角色和模块我们模拟一次真实的下单过程看看涉及哪些模块用户选择地址勾选购物车商品点击提交订单。后端接收下单请求校验地址簿和购物车计算金额写订单主表和订单明细表清空购物车。用户进行支付支付结果回写到订单状态。商家端收到新订单提醒WebSocket推送商家查看订单详情并接单。如果用户下单后一直不支付定时任务扫描超时订单并自动取消。这条链路里最容易被忽略的是“状态”的传递。订单不是一张静态的表它有一整套生命周期待支付、已支付/待接单、已接单、配送中、已完成、已取消。day10和day11的所有代码本质上都是在维护这个状态机并保证状态迁移的正确性。1.3 一个核心矛盾用户端和商家端不是同一套接口做这个项目时有一个容易混淆的点用户端的订单接口和商家端的订单接口虽然都叫“订单”但服务对象完全不同。用户端关心的是我的订单到哪一步了、我要取消、我要再来一单商家端关心的是有没有新订单、怎么接单、怎么标记完成。day10偏向用户端的下单和支付day11偏向商家端的提醒和处理。分开理解会轻松很多如果混在一起看就会觉得订单功能怎么这么多接口。2. 下单链路的核心设计商品快照、事务边界与订单号2.1 下单接口的输入与参数校验先看下单接口接收什么。前端提交时一般传这些字段地址簿ID、付款方式、备注还有可能带上购物车ID列表。后端拿到请求后第一件事不是写订单而是做校验。苍穹外卖里的常规做法是查地址簿如果地址不存在直接抛异常再查购物车如果购物车为空说明用户可能重复提交了订单也要拦下来。这里我建议你额外留一个心眼前端传过来的金额后端一定要重新计算一遍。教学项目里很多地方直接信任前端传的amount但真实业务里金额必须以后端计算为准。购物车里每一项都有单价和数量遍历累加就能得到真实金额。如果前端传的金额和后端算出来的不一致比较稳妥的做法是以后端计算为准或者直接提示用户重新拉取购物车信息。这个习惯养成了以后做任何涉及钱的接口都受益。还有一点容易被忽略地址簿数据要“拷贝”到订单表而不是只存一个地址ID。为什么因为用户地址后续可能修改或删除而订单作为历史数据收货人姓名、手机号、详细地址必须保持下单那一刻的快照。这个思想叫“数据快照”后面商品名称和价格也同理。2.2 订单主表与订单明细表的设计思路苍穹外卖里订单相关有两张核心表订单表和订单明细表。订单表存的是汇总信息订单号、总金额、下单时间、支付方式、收货地址快照、订单状态等。订单明细表存的才是具体商品菜品名称、图片、单价、份数、小计。为什么非要拆成两张表因为一个订单可能包含多个商品如果都塞在一张表里要么冗余大量订单公共信息要么设计成逗号分隔的字符串字段。拆分之后订单表和明细表是一对多的关系查询一个订单的完整信息时先查订单主表再按订单ID查明细列表。这个设计也方便后续做数据统计比如算某个菜品的销量直接查明细表按菜品ID分组求和即可。写入顺序也有讲究。正确的做法是先插入订单主表拿到订单ID再循环插入订单明细表。注意明细表里要回填订单ID。有同学在这里踩过坑——先插入明细再插入主表结果明细表里的订单ID一直为空页面上订单详情就是空白。商品明细同样要用快照思想。菜品表里的名称、价格、图片都是会变的但用户下单那一刻看到的菜名和价格必须原样保留。所以订单明细表里不能只存一个菜品ID要把名称、图片、单价、份数都冗余进去。很多同学不理解为什么表里会有这些“重复”字段做过后台管理系统就会明白等你回头查历史订单时如果不做快照看到的是改过之后的菜名和价格客诉都没法处理。2.3 事务边界和订单号的生成下单接口必须是一个整体事务。手动校验地址簿、查询购物车、计算金额、插入订单主表、批量插入明细表、清空购物车这六步要么全部成功要么全部回滚。比如订单主表插入成功了但明细表插入过程中抛异常如果不回滚就会出现一个没有任何商品的“空订单”用户端和管理端都会看到脏数据。苍穹外卖里一般用Spring的Transactional注解标注在下单Service方法上。有一个细节值得注意事务粒度不要开得太大。如果事务里还做了大量的远程调用、WebSocket推送、外部接口请求事务会长时间占用数据库连接。下单这个场景事务范围就在本地数据库操作上推送消息这些事放到事务提交之后再做。这个经验我放到后面“容易翻车”部分详细说。再聊聊订单号。订单表里一般会有一个number字段这个字段不是主键ID而是展示给用户的订单编号。教学项目里常见的是“时间戳随机数”或者“时间戳用户ID尾巴”然后转成字符串。为什么不用数据库自增ID当订单号因为自增ID会暴露业务量用户也能猜到别人的订单号而且以后再对接第三方支付时外部系统回传的商户订单号也需要一个业务上唯一的字符串。订单号不要求全局绝对唯一但并发情况下不能重复时间戳加用户维度加随机数基本够用。3. 支付模块的务实做法模拟支付、回调与状态流转3.1 为什么项目里普遍用模拟支付真实接入支付渠道需要商户号、证书、回调域名、应用ID等一系列资质个人学习者很难快速搞定而且涉及资金流水出问题风险不小。所以苍穹外卖这类教学项目在day10和day11里通常用“模拟支付”来代替真实支付链路。所谓模拟支付就是提供一个接口前端点击支付按钮后直接调用这个接口把订单状态从未支付改成已支付不经过任何第三方支付平台。有同学会问那这样学到的支付逻辑还有用吗有用而且核心部分完全一致。真实支付链路里最难的不是调起支付而是处理支付结果回调和订单状态一致性。模拟支付把“支付结果必然到达”这个环节简化了但订单状态的更新逻辑、幂等保护、归属校验这些思想和真实业务一模一样。我在实操中的体会是模拟支付接口反而能让你更专注地理解“回调”的本质。真实世界里支付平台会异步通知你的服务器“某笔订单支付成功”你的服务器需要根据这个通知把订单状态改掉。模拟支付只是把这个异步通知变成了同步调用罢了。3.2 订单状态机的设计订单表里通常有一个status字段用数字表示不同状态。不同项目编号略有差异但状态设计思路大体一致状态值含义触发时机1待支付用户提交订单后2已支付 / 待接单支付成功3已接单商家接单4配送中 / 制作中商家操作5已完成订单完结6已取消用户取消或超时取消7退款售后场景为什么不用一个布尔字段比如is_paid来表示因为订单生命周期不只是“未支付/已支付”两个状态。一个已支付的订单可能被商家接单也可能被用户取消后需要退款一个超时未支付的订单和用户主动取消的订单虽然都叫“已取消”但原因不同后续处理也可能不同。状态机设计的好处是任何时刻订单都有且只有一个明确状态业务流程每一步都对应一次合法的状态迁移。写代码时一定要有“状态迁移合法性”的意识。比如支付接口里只有状态为1的订单才能变成状态2如果订单已经是6已取消就不能再支付成功了。如果忽略这层判断用户下单后去支付同时系统定时任务扫描该订单超时并取消两个操作并发执行就可能出现“订单已取消但支付也成功”的矛盾数据。避免这个问题的办法很简单更新时带上状态条件用类似UPDATE orders SET status 2 WHERE id ? AND status 1这样的SQL影响行数为0就说明状态已经被别人改了。3.3 模拟支付接口的幂等和归属校验看下模拟支付接口的常规实现。用户端点击支付传入订单ID后端先查订单判断状态是否为待支付然后改为已支付并记录支付时间。这个接口有两个必须做的保护第一个是幂等性支付通知可能重复下发用户也可能手快点了两次支付按钮。如果订单已经是已支付状态接口再执行一次不能把状态改回去也不能报一个让用户困惑的异常。正确做法是发现状态不是待支付直接返回“该订单已被支付”之类的提示不破坏已有状态。第二个是归属校验支付接口一定要确认这个订单属于当前登录用户。教学项目里如果只传订单ID有安全意识的同学会发现随便改一个订单ID就能操作别人的订单。解决方法是后端从当前登录用户上下文里拿到用户ID然后查询订单时同时带上用户ID条件或者查出来之后比对订单的userId字段。这个习惯在真实项目中是底线要求。再说一个细节支付成功之后订单表里除了改status还要记录支付时间字段。这个时间后面很多地方要用来判断“用户超时未支付”。如果只改状态不记录pay_time后面定时任务扫超时订单时容易错乱。苍穹外卖里订单表会预留pay_time、cancel_time、complete_time这类字段每发生一次关键状态变更就要对应更新时间字段。4. 定时任务兜底超时未支付订单的自动取消4.1 为什么需要定时任务来处理超时订单用户提交订单之后如果没有在规定时间内完成支付这笔订单会一直占着库存和运力。外卖场景通常设置15分钟超时未支付自动取消。这个逻辑不可能靠用户主动触发也不能靠用户端定时请求因为用户可能直接关掉小程序走了。可靠的做法是服务端通过定时任务不断扫描订单表把超时的订单挑出来统一改成已取消状态。Spring框架提供了现成的定时任务方案在启动类上加EnableScheduling注解然后在任务类的方法上加Scheduled注解配合cron表达式控制执行频率。苍穹外卖day10里面一般会做一个OrderTask类里面放一个方法专门处理超时订单另一个方法处理“商家一直没接单的订单”逻辑类似都是根据状态和时间的组合条件去扫描并推进状态。4.2 cron表达式和扫描SQL怎么写cron表达式看起来吓人拆开就好理解了。常见写法是0/30 * * * * ?意思是从第0秒开始每30秒执行一次。从左到右分别是秒、分、时、日、月、星期。星号表示任意值问号用于星期和日字段的互斥占位。写定时任务时执行频率要结合业务容忍度来定。超时订单取消晚几十秒对用户体验影响不大每30秒扫一次足够如果业务上要求秒级处理才需要缩短到秒级。扫描SQL的核心是查询某个时间点之前、且状态为待支付的订单SELECT * FROM orders WHERE status 1 AND order_time DATE_SUB(NOW(), INTERVAL 15 MINUTE);这里的时间字段是关键。如果你用了LocalDateTime存下单时间要注意数据库时间和应用服务器时间的时区是否一致。很多同学第一次跑定时任务时发现订单被立刻取消十有八九是时区问题——数据库存的是UTC时间Java端读出后转了本地时间两边差了8小时扫描条件就和直觉对不上了。另外要提一点性能意识如果订单表数据量很大每次全表扫描status1的订单会拖垮数据库。实际项目里一般会给status和order_time建联合索引并且定时任务分批处理比如每次只取100条处理完再取下一批。教学项目数据量小体现不出差别但把这条优化思路写进代码注释里面试时就能讲出亮点。4.3 定时任务执行要注意的隐性坑第一个坑是集群环境下的重复执行。如果项目部署了多个实例Spring的Scheduled默认会在每个实例上各跑一遍同一笔超时订单可能被多个实例同时取消。处理方式有几种最简单的是只在某个固定节点部署定时任务严谨一点可以引入分布式锁让同一时间只有一个实例执行任务再讲究一点数据库层面对订单ID加行锁配合状态条件更新也能保证幂等。教学项目单机部署无所谓但你要知道这个问题存在。第二个坑是任务执行时间和业务高峰的冲突。比如商家处理订单的任务如果在整点跑刚好多实例同时扫描大表数据库压力瞬时上升。建议把cron表达式错峰设置比如每秒执行的任务避开15分、30分这些整点减少和报表任务撞车。第三个坑是日志。定时任务出问题通常不是立刻暴露的而是很久之后用户投诉“我的订单怎么被取消了”。所以任务里一定要打印扫描到的订单数量和处理的订单ID方便事后追溯。我习惯在任务开始和结束时各打一条日志begin scan、end scan countxx。朴素但好用。5. WebSocket来单提醒从轮询到实时推送的升级5.1 为什么商家端要用WebSocket而不是轮询现在的需求是用户支付成功后商家收银台要立刻收到新订单提醒。最容易想到的做法是商家端页面每隔几秒调一次接口查有没有新订单这叫轮询。轮询的问题很明显如果间隔短服务器被无效请求打满如果间隔长提醒不及时。而且用户支付之后商家可能正在忙新订单要能主动冒出来提示音和弹窗。WebSocket解决的是“服务端主动推送”的问题。它是一条长连接客户端和服务端建立连接后服务端可以在任意时刻主动往客户端发数据。这和HTTP的“一问一答”模式完全不同。商家收银台页面打开时建立一个WebSocket连接服务端收到支付成功的消息后往所有商家端连接推送一条“来单了”的通知前端收到后播放提示音并刷新订单列表。苍穹外卖里WebSocket主要用在后端管理端的来单提醒。后续如果有需求比如订单状态变化实时通知用户小程序同样是这套方案。学会了这一处其他地方就是复制思路的问题。5.2 服务端WebSocket的实现要点Spring Boot集成WebSocket比较简单核心是自己写一个WebSocket服务端类。苍穹外卖里一般基于Java自带的javax.websocket实现关键点是维护一个会话集合Component ServerEndpoint(/ws/{sid}) Slf4j public class WebSocketServer { // 会话集合key可以用用户ID或客户端标识 private static final MapLong, Session SESSION_MAP new ConcurrentHashMap(); OnOpen public void onOpen(PathParam(sid) Long sid, Session session) { SESSION_MAP.put(sid, session); log.info(客户端连接sid{}, sid); } OnClose public void onClose(PathParam(sid) Long sid) { SESSION_MAP.remove(sid); log.info(客户端断开sid{}, sid); } OnError public void onError(Session session, Throwable error) { log.error(WebSocket错误, error); } public static void sendToAll(String message) { for (Session session : SESSION_MAP.values()) { try { if (session.isOpen()) { session.getBasicRemote().sendText(message); } } catch (IOException e) { log.error(推送失败, e); } } } }这里有几个细节要注意。SESSION_MAP必须用ConcurrentHashMap因为WebSocket的打开和关闭事件可能由不同线程触发推送前要判断session.isOpen()否则向已关闭的连接写数据会抛异常onClose里要移除会话不然连接断开后Map里残留一堆失效Session内存和推送效率都会受影响。5.3 触发时机和前端接收来单提醒的触发时机是什么严格来说应该是支付成功回调里订单状态从待支付变为已支付之后往商家端推送一条消息。代码上就是在支付Service更新完订单状态后调用WebSocketServer.sendToAll()。推送的内容可以是简单的JSON字符串比如{type: 1, content: 您有新的订单}前端拿到后解析再根据type决定是弹窗、播放提示音还是刷新列表。前端页面在收银台打开时创建一个WebSocket对象let ws new WebSocket(ws://localhost:8080/ws/1); ws.onmessage function (event) { let data JSON.parse(event.data); // 根据data.type做对应处理 if (data.type 1) { // 刷新订单列表并播放提示音 } };小程序的写法类似换一个API接口就行。联调时最容易出的问题是路径配置后端接口地址写成了http前端连不上或者端口写错WebSocket握手直接失败。建议先启动后端用页面访问ws://localhost:8080/ws/1看到控制台打出连接日志再继续往下联调。这里还要提醒一个业务边界WebSocket它只管实时推送通知不保证推送的数据就是用户要看到的最终数据。前端收到提醒后正确的做法是再调一次订单列表接口刷新数据而不是相信推送里带的订单详情。推送只是一个“触发信号”数据一致性还得靠查询接口保证。6. 我实测下来最容易翻车的几个点6.1 事务还没提交就推送消息先说一个我实际踩过的坑。用户支付成功后我一开始是在Service方法内部直接调用WebSocket推送而这个Service方法是带Transactional的。表面看没毛病但实际上推送消息发出去了事务还没有提交——商家端收到提醒后立刻去查订单列表查到的还是“待支付”状态因为数据库事务还没结束别人读不到未提交的修改。解决思路有两种一是把推送动作放到事务提交之后Spring里可以用事务同步的afterCommit回调或者TransactionalEventListener监听事务提交事件二是更简单粗暴的方式把推送从事务方法里拆出来由外层调用方先完成事务再调用推送。教学项目里直接拆出来做反而最易懂等你理解之后再改成监听事件的方式。6.2 状态判断写反导致已取消订单被支付成功模拟支付接口里判断订单状态的地方很容易写反。比如if (order.getStatus() ! 1) { // 这里应该是终止支付 }如果写成 1才终止那所有已支付和已取消的订单都能再支付一次状态就会被改乱。这个问题的排查往往要等数据乱了才发现。我的建议是给状态定义加枚举或者常量不要让魔法数字散落在代码各处。比如定义常数STATUS_PENDING_PAYMENT 1判断时写if (order.getStatus() ! STATUS_PENDING_PAYMENT) return error可读性高也不容易写反。6.3 时区问题导致超时订单被秒取消前面提过时区问题这里展开讲一下。有一次我本地跑定时任务刚下的订单一分钟后就被自动取消了。排查日志发现SQL条件查出来的订单时间比预期早了8小时原因是数据库会话的时区设置和Java进程的默认时区不一致。后来在数据库连接参数里显式指定了serverTimezone并统一用同一个时间格式序列化LocalDateTime问题才稳定。这个坑在本地开发很容易忽略因为本地测试数据少偶尔跑一次没感觉。但一旦上了服务器数据库和应用的时区如果不一致订单取消逻辑就会莫名失控。建议从第一天做这个项目起就在项目配置里把时区问题规范化处理。6.4 清空购物车失败导致用户重复下单下单成功后要清空购物车。如果这一步没执行成功用户看到购物车还是满的就会再下一单产生重复订单。清空购物车逻辑本身很简单按用户ID删除购物车记录。但要注意它必须在下单主事务里如果前面的订单写入成功、清空购物车失败整体就要回滚否则购物车里的商品又变成“待下单”状态。我见过一个同学把清空购物车单独做了一个事务导致下单成功了购物车还在连续点几次就建了三个一模一样的订单。6.5 WebSocket连接失效但不自知WebSocket长连接最怕静默断连。用户可能关了电脑、网络切换、服务器重启连接断开后服务端不一定立刻感知。实际项目里需要做心跳机制客户端每隔一段时间发一个ping服务端回pong或者服务端定期检查连接的空闲状态超过阈值就主动关闭。苍穹外卖项目里一般不强求心跳但你要知道这个机制的用途面试时聊到WebSocket能主动说出“生产环境要加心跳和断线重连”就是一个加分项。整套跑下来我个人的体会是day10和day11的代码量不算大核心价值在于把前面学的零散知识串联成一个完整业务闭环。如果让我重新做一遍我会先亲手画一遍订单状态流转图把每个状态变更的触发条件和涉及的表字段列出来再动笔写代码。这样做的好处是遇到并发和边界问题时你能一眼看出哪里缺少保护。建议你也试试这个方法画完之后你会发现后面所有订单相关的功能都是在给这张图补细节。