订单模块
面试官您好,其实我之前做家政项目时,订单模块是我全程跟进的核心模块,从初期设计到后期优化都参与了,所以聊这个我还挺熟的。简单说,这个模块就像项目的 “中枢”—— 用户下单、阿姨接活、钱怎么算、优惠怎么用,都得通过它串起来,核心目标就是把订单从创建到结束的全流程管明白,别出岔子,还得让用户和阿姨用着顺。
我先跟您说说最开始的基础设计吧,这部分是底子,当时我们花了不少时间抠细节。首先是表结构,没敢搞太复杂,用了 “主表 + 明细表” 的结构。主表(order_main)就是存订单的核心信息,比如订单号、哪个用户下的(用户 ID)、选了哪个服务(服务 ID)、总金额、实付多少、用了哪张优惠券(优惠券 ID)、现在是什么状态,还有创建时间、支付时间这些关键时间点。明细表(order_item)是因为有的用户会一次选两个服务,比如保洁 + 擦玻璃,那明细表就存每个服务的细节 —— 服务名、单价、数量,这样查订单详情的时候,不用联一堆表,主表拿整体信息,明细表拿服务明细,很清楚。
然后是订单状态,这个不能乱定,得跟着业务流程来。当时我们梳理了 7 个核心状态,还规定了只能按顺序变,不能跳步:比如下单没付钱是 “待支付”,付了钱变 “已支付”,阿姨上门服务中是 “服务中”,结束了用户确认就变 “已完成”;另外,待支付的时候用户不想买了,或者超时了,就变 “已取消”;已支付后要是取消订单,就得走 “退款中”,钱退完了变 “已退款”。这么定是怕出现 “已完成的订单突然取消” 这种逻辑 bug,后期维护也省心。
订单号生成也踩过点小坑,最开始想直接用雪花算法,但是家政项目的单量没那么大,峰值也就每秒几十单,雪花算法的号太长,后面查问题的时候,光看号看不出啥信息。后来就改成 “时间戳(yyyyMMddHHmmss)+ 用户 ID 后 4 位 + 3 位随机数”,比如 20241115102030_6789_123,这样好处很明显:一是不会重复(时间戳精确到秒,再加随机数),二是查问题的时候,看后 4 位就知道是哪个用户的订单,不用再去联用户表,效率高多了。
接下来就是具体功能开发,这部分最实在,也踩了不少坑。先说说下单接口吧,整个流程是 “校验→算钱→建订单→调支付”。第一步校验很关键,得确认用户状态正常(没被拉黑)、服务能约(比如阿姨当天档期满了就不能约)、优惠券能用(没过期、没被用过)。然后算实付金额,比如服务总价 100,优惠券减 20,实付就是 80,这里得注意优惠券的规则,有的券是满减,有的是折扣,得兼容不同类型。
算完钱就该建订单了,这里必须用事务,因为要同时写主表和明细表,万一写明细表失败了,主表也得回滚,不然就会出现 “只有主表数据,没明细” 的脏数据。当时开发的时候,事务还失效过两次:第一次是把加 @Transactional 的方法设成 private 了,后来才想起来 Spring 的事务代理不支持 private 方法,改成 public 就好了;第二次是在 try-catch 里把异常吞了,没抛出来,事务没法回滚,最后在 catch 里主动抛了 RuntimeException 才解决。
建完订单就调支付模块,我们主要对接的是微信小程序支付,把订单号、金额、用户的 openid 传给支付服务,拿到 prepay_id 这些参数,再返回给前端,用户点支付就能跳微信支付了。这里还做了个优化:最开始下单后给用户发通知(短信 + 推送)是同步的,接口响应要 1.5 秒左右,用户等得着急;后来改成用 RabbitMQ 异步发,响应时间直接降到 300 毫秒以内,体验好了很多。
还有个常见问题是防重复提交,比如用户点了 “下单” 没反应,又点一次,很容易生成两个订单。我们做了双重保障:前端是用户点完下单按钮就置灰,10 秒内不让再点;后端是用 Redis 存 “用户 ID + 服务 ID” 当 key,下单前先查 Redis,要是存在就返回 “别重复下单”,不存在就存 30 分钟(和超时取消时间一致),同时给订单号加了数据库唯一索引,就算 Redis 出问题,数据库也能拦截重复订单,双保险才放心。
优惠券核销也是个重点,不能随便核销。当时定的规则是 “下单占坑,支付核销”—— 用户下单时,先把优惠券 ID 关联到订单里,但不核销,相当于先占着这张券,防止别人用;等支付成功的回调过来,再把优惠券状态改成 “已核销”,同时记录核销的订单号和时间。这里要多做几层校验:优惠券是不是这个用户的、有没有过期、有没有被其他订单核销过,少一层都可能出问题,比如之前测试的时候,没校验 “是否用户本人的券”,导致 A 用户能用 B 用户的券,后来加上校验才 fix 掉。
超时未支付的订单也得处理,总不能一直占着优惠券和服务档期。最开始想用房卡定时任务(XXL-Job),每 5 分钟扫一次待支付的订单,超过 30 分钟就取消。但测试发现有延迟,比如 30 分钟超时,可能 31-35 分钟才取消,优惠券释放不及时,影响其他用户下单。后来换成了 RabbitMQ 的延迟队列:用户下单时,往延迟队列发一条 30 分钟后到期的消息;消息到期后,消费端查订单状态,如果还是待支付,就把状态改成已取消,同时把优惠券释放(改回可用状态),这样基本能做到 30 分钟一到就处理,实时性好很多。
订单模块不是孤立的,和支付模块的协同特别重要。除了下单时调支付接口,还有支付结果回调的处理:微信支付成功后,会回调我们的通知接口,第一步必须验签,因为怕有人伪造回调数据骗钱 —— 按微信给的步骤,用 API 密钥验证签名,验签通过了才敢处理后续逻辑。处理逻辑包括:把订单状态改成 “已支付”、核销优惠券、给阿姨发通知(有新订单了)。这里还得考虑重试,万一回调的时候我们服务宕机了,微信会重试回调,所以接口必须是幂等的,比如查一下订单状态,要是已经是已支付了,就直接返回成功,不重复处理。
到了项目运营半年后,订单数据越来越多(大概有 50 多万条),原来的设计就有点吃力了,比如运营查去年的订单列表,要 2 秒多才能出来,用户查自己的历史订单也慢,所以我们做了三轮优化。
第一轮是用状态机管理订单状态。之前状态变更全靠 if-else,比如 “已支付能不能转已取消?”“退款中能不能转已完成?”,逻辑越堆越乱,改一个状态要改好几处代码。后来用了 Spring StateMachine,把状态(待支付、已支付等)和事件(支付、取消、完成)定义清楚,比如只有 “支付事件” 能触发 “待支付→已支付”,只有 “取消事件” 能触发 “已支付→退款中”,逻辑可视化了,维护起来特别方便,后面加新状态也不用改旧代码。
第二轮是分库分表。我们用 ShardingSphere-JDBC 做的:按 “用户 ID 哈希” 分库,分了 2 个库,这样写订单的时候,数据能分摊到两个库,不会让一个库压力太大;按 “创建时间” 分表,1 年内的订单存在 order_main 主表,2023 年的存在 order_main_2023,2022 年的存在 order_main_2022。查询的时候,根据时间路由到对应的表,比如查 2023 年的订单就查 order_main_2023,再加上 “用户 ID + 创建时间” 的联合索引,运营查去年的订单从 2 秒降到 200 毫秒以内,快了很多。
第三轮是冷热分离。1 年以上的订单属于 “冷数据”,用户很少查,但占存储空间,所以我们把冷数据迁移到 HBase 里,MySQL 只存 1 年内的热数据。用户查订单的时候,系统会自动判断:如果是 1 年内的,查 MySQL;如果是 1 年以上的,查 HBase。这样既减轻了 MySQL 的存储压力,又没影响用户查历史订单,成本也低(HBase 存冷数据比 MySQL 便宜)。
总结下来,这个订单模块从设计到优化,核心就是围绕 “稳定、高效、易维护” 这三个点。稳定是指数据不能错、流程不能断,比如事务、防重复、验签这些;高效是指接口响应快、查询快,比如异步通知、分库分表;易维护是指代码逻辑清晰,比如状态机、模块化设计。其实做下来最大的感受是,订单模块不仅要懂技术,还得懂业务,比如家政服务的 “阿姨档期”“优惠券规则”,这些业务细节要是没考虑到,技术做得再炫也没用。

浙公网安备 33010602011771号