摘要:今天来啃电商系统里最硬的一块骨头——订单系统。从状态机设计到下单事务,从乐观锁到悲观锁,一篇讲透。订单全流程:待支付 → 已支付 → 已发货 → 已完成,以及两条岔路——已取消和已退款。代码全部可运行。
为什么订单系统是电商的心脏
你想想,一个电商系统,商品展示可以丑一点,搜索可以慢一点,但订单要是出了问题——
- 用户付了钱,订单没创建:钱没了,货也没了。
- 库存扣了,订单没生成:商品凭空蒸发。
- 两个用户同时买最后一件,库存变成 -1:超卖了,老板哭了。
这不是 bug,这是灾难。
我在做商城的时候,订单模块改了整整三版。第一版没有事务,结果测试环境一压就出脏数据;第二版用了悲观锁,高并发直接卡死;第三版才改成现在的乐观锁 + 状态机方案,稳如老狗。
所以订单系统的设计,核心就三个词:状态机、事务、锁。
今天就把这三个词,一个一个拆明白。
原理速览
状态机:订单的"交通规则"
状态机听起来很学术,其实就是一个规则表:
订单当前是什么状态,下一步能变成什么状态。
就像交通规则——红灯只能变绿,绿可以变黄,但你不能从红灯直接变黄灯。订单也是一样,你不能从"待支付"直接跳到"已发货",必须先经过"已支付"。
事务:要么全做,要么全不做
下单这一个动作,其实包含了 5 个子操作:
- 扣减库存
- 计算优惠
- 创建订单
- 创建订单项
- 清理购物车
这 5 步必须是一个原子操作——要么全部成功,要么全部回滚。
为什么必须原子?看一个真实的回滚场景:
用户下单买 2 件衣服。第 1 步扣库存成功(仓库 -2),第 2 步计算优惠成功(打 8 折),第 3 步创建订单时数据库连接超时了——崩了。如果没有事务保护,库存扣了但订单没建,用户的 2 件衣服就这么凭空消失了。
有了事务,第 3 步失败会触发回滚,第 1 步的库存扣减也会撤销。世界恢复原样,用户可以重新下单。
锁:防止"一货多卖"
库存只剩 1 件,100 个人同时点"下单"——谁该买到?
两种策略:
| 策略 | 乐观锁 | 悲观锁 |
|---|---|---|
| 核心思路 | 先操作,提交时检查有没有冲突 | 先加锁,操作完再释放 |
| 数据库实现 | WHERE stock >= quantity条件更新 | SELECT ... FOR UPDATE行锁 |
| 优点 | 性能好,不阻塞其他事务 | 绝对不会超卖 |
| 缺点 | 冲突时需要重试 | 高并发下排队等待 |
| 适用场景 | 日常购物(冲突概率低) | 秒杀、限时抢购(冲突概率高) |
一句话总结:乐观锁是"信任制",悲观锁是"门禁制"。
动手:从状态机到下单事务
状态机设计
完整的状态转换图:
┌─────────┐ │ 待支付 │ (pending) └────┬────┘ ┌─────┴─────┐ ▼ ▼ ┌─────────┐ ┌─────────┐ │ 已支付 │ │ 已取消 │ ← 终态 │ (paid) │ └─────────┘ └────┬─────┘ ┌─┴──────────┐ ▼ ▼ ┌─────────┐ ┌─────────┐ │ 已发货 │ │ 已退款 │ ← 终态 │(shipped) │ └─────────┘ └────┬─────┘ ┌─┴──────────┐ ▼ ▼ ┌─────────┐ ┌─────────┐ │ 已完成 │ │ 已退款 │ ← 终态 └─────────┘ └─────────┘ (终态)用代码表示就是这张转换表:
| 当前状态 | 允许转换的目标状态 |
|---|---|
| 待支付 (pending) | 已支付 (paid)、已取消 (cancelled) |
| 已支付 (paid) | 已发货 (shipped)、已退款 (refunded) |
| 已发货 (shipped) | 已完成 (completed)、已退款 (refunded) |
| 已完成 (completed) | 无(终态) |
| 已取消 (cancelled) | 无(终态) |
| 已退款 (refunded) | 无(终态) |
代码实现非常简洁:
# 状态转换规则表ORDER_STATE_TRANSITIONS={'pending':['paid','cancelled'],'paid':['shipped','refunded'],'shipped':['completed','refunded'],'completed':[],# 终态'cancelled':[],# 终态'refunded':[],# 终态}defcan_transition(from_status,to_status):"""检查状态转换是否合法"""allowed=ORDER_STATE_TRANSITIONS.get(from_status,[])returnto_statusinallowed执行转换的函数,顺便记录时间节点(核心逻辑):
deftransition_order(order,target_status):"""执行订单状态转换,同时记录时间节点"""current_status=order.status# 1. 校验转换合法性ifnotcan_transition(current_status,target_status):raiseValidationError(f"非法转换:{current_status}→{target_status}")# 2. 更新状态order.status=target_status# 3. 记录时间节点(paid_at / shipped_at / completed_at)now=timezone.now()iftarget_status=='paid':order.paid_at=noweliftarget_status=='shipped':order.shipped_at=noweliftarget_status=='completed':order.completed_at=now order.save()returnorder状态机的好处:把业务规则从代码里抽出来,变成一张表。新人来了看这张表就知道订单怎么流转,改规则也只改这一张表。这就是"数据驱动"的思想。
下单事务:5 步原子操作
这是整个订单系统最关键的代码。核心框架(关键片段):
fromdjango.dbimporttransactionfromdjango.db.modelsimportFdefcreate_order(user_id,cart_items,address_info,coupon_discount=None):"""创建订单 —— 整个流程在事务中执行,任何一步失败全部回滚"""withtransaction.atomic():# ① 扣库存(乐观锁:条件更新,affected == 0 说明库存不足)foritemincart_items:affected=Goods.objects.filter(id=item['goods_id'],stock__gte=item['quantity'],# 库存 >= 购买数量).update(stock=F('stock')-item['quantity']# F表达式,数据库原子操作)ifaffected==0:raiseValidationError(f"商品{item['goods_id']}库存不足")# ② 计算总价 + 优惠(优惠后不为负)# ③ 创建订单(生成订单号、地址快照、status='pending')# ④ 批量插入订单项(bulk_create 性能更好)# ⑤ 清理购物车returnorder为什么要用transaction.atomic()?
想象一下,如果没有事务:
第 1 步:扣库存 ✅ (仓库 -2) 第 2 步:算优惠 ✅ (打 8 折) 第 3 步:建订单 ❌ (数据库连接超时了) 第 4 步:没执行 第 5 步:没执行结果:库存扣了,优惠算了,但订单没建出来。用户的库存没了,订单也没了。这叫什么?这叫事故。
有了transaction.atomic(),第 3 步失败后,第 1 步的扣库存也会自动回滚。世界恢复原样,用户可以重新下单。
注意
F('stock') - quantity的用法。这不是 Python 层面的减法,而是让数据库自己算。F表达式会生成 SQL 的SET stock = stock - N,在数据库层面完成原子操作,避免"先读后写"的竞态条件。
乐观锁 vs 悲观锁
来看两种锁的实际代码对比。
乐观锁(推荐日常使用):
# 核心:WHERE stock >= quantity,条件更新affected=Goods.objects.filter(id=goods_id,stock__gte=quantity,# 条件检查).update(stock=F('stock')-quantity# 数据库层面原子扣减)ifaffected==0:raiseValidationError("库存不足")悲观锁(秒杀场景):
# 核心:select_for_update() 加行锁goods=Goods.objects.select_for_update().get(id=goods_id)ifgoods.stock<quantity:raiseValidationError("库存不足")goods.stock-=quantity goods.save()两者区别在哪?
乐观锁像"信任制"——我先改,提交时检查有没有被别人改过。没冲突直接成功,有冲突就重试。SQL 是UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock >= 1,一句话说完。
悲观锁像"门禁制"——我先锁门,改完了再开门。别人要改同一件商品?排队等着。SQL 是SELECT ... FOR UPDATE,先锁住这一行,其他事务读取同一行会被阻塞。
怎么选?看场景:
- 日常购物:同时买同一件商品的概率不高,乐观锁够用,性能好。
- 秒杀活动:几千人同时抢 10 件商品,冲突概率极高,必须悲观锁。
商城的策略:普通商品用乐观锁,秒杀商品用悲观锁。既保证性能,又保证安全。
订单查询与接口设计
商城规范:所有订单操作统一传数字主键 id。
classOrderViewSet(viewsets.ModelViewSet):"""订单接口"""permission_classes=[IsAuthenticated]defget_queryset(self):"""只返回当前用户的订单"""returnOrder.objects.filter(user=self.request.user)# GET /api/orders/ → 订单列表# GET /api/orders/{id}/ → 订单详情(传数字主键 id)# POST /api/orders/ → 创建订单(下单)# POST /api/orders/{id}/cancel/ → 取消订单# POST /api/orders/{id}/complete/ → 确认收货# POST /api/orders/{id}/refund/ → 申请退款为什么用id而不是order_no?
order_no是给用户看的,用于客服沟通、物流查询。- 数字主键
id是数据库层面的唯一标识,查询效率最高(主键自带索引)。 - 统一用
id,前后端对接更简洁。如果有的接口传id、有的传order_no,前后端同学不得打起来?
取消订单 & 退款
取消和退款不只是改个状态——还得把库存还回去。核心思路:
@action(detail=True,methods=['post'],url_path='cancel')defcancel(self,request,pk=None):"""取消订单 + 回滚库存"""order=self.get_object()# 1. 状态机校验(只有 pending → cancelled 是合法的)transition_order(order,'cancelled')# 2. 回滚库存(遍历订单项,把扣的库存加回去,用 F('stock') + quantity)# 这一步和改状态必须在同一个事务里!returnResponse({'code':0,'message':'订单已取消'})退款逻辑类似,paid → refunded或shipped → refunded,库存同样要回滚。
注意:取消和退款的库存回滚也应该放在事务里。如果改完状态、回滚库存到一半崩了,就会出现"订单取消了但库存没还"的惨剧。
用 AI 生成状态机代码
状态机这种东西,特别适合让 AI 来写。因为模式非常固定:
- 定义状态常量
- 定义转换规则表
- 写校验函数
- 写执行函数
这四步,AI 几秒钟就能搞定,而且质量很高。
prompt 是这样的:
你是一个 Django 开发者。请为电商订单系统实现一个状态机。 状态列表:pending、paid、shipped、completed、cancelled、refunded 要求: 1. 用字典定义状态转换规则 2. 实现 can_transition() 校验函数 3. 实现 transition_order() 执行转换,同时记录时间节点 4. 所有非法转换抛出 ValidationError 5. 代码风格符合 PEP 8,带中文注释AI 给出的代码和我们的实现几乎一致,甚至还会额外加日志记录和状态标签映射。
不过有一点要注意:AI 生成的事务代码,你一定要亲自检查。因为 AI 不知道你项目的具体业务逻辑——比如"优惠券过期了要不要回滚"、“部分退款怎么算”,这些都需要人来把关。
AI 加速心得:状态机、校验逻辑、序列化器这类"规则明确、模式固定"的代码,放心交给 AI。但涉及业务判断、事务边界的代码,AI 只是助手,你才是决策者。
学完来练练手
加一个"超时自动取消":订单创建后 30 分钟未支付,自动变成已取消、库存回滚。(提示:Celery Beat 定时任务,或 Django Signals)
加一个"部分退款":订单里 3 件商品,用户只退其中 1 件。库存怎么回滚?金额怎么算?状态怎么变?(提示:可能需要新增一个
partial_refunded状态)加一个"订单状态变更通知":每次状态变更时给用户发推送通知。(提示:Django Signal 的
post_save,检测status字段变化)给乐观锁加重试机制:当条件更新返回
affected = 0时,不直接报错,等 50ms 后重试,最多 3 次。(提示:time.sleep(0.05)+for循环)
做完你会发现,一个真实的订单系统远不止今天这些内容。但核心框架——状态机 + 事务 + 锁——已经搭好了,后面都是在这个基础上长肉。
配套文件day10_order.py(状态机 + 下单事务 + 乐观锁/悲观锁双版本 + DRF 接口)可参考学习。
本系列为梅雅达编程笔记原创,首发于 CSDN。有问题?评论区见