与数据库对比
查看 Markdown大多数后端把状态存在数据库里,再围绕数据库搭建业务流程。下面先用这种方式一步步搭一个订单流程,再用 Dex Flow 实现同一个流程。
完整的订单流程先向买家扣款,再发货。发货前,它要等卖家审批,等待期间提醒卖家。发货失败持续一小时后,它给买家退款。两种设计为此需要的部件如下:
数据库加队列
14个部件要你自己搭建和运行
- 带 status 的订单表
- 审批表
- charge-requests
- ship-requests
- refund-requests
- retry topic 或 visibility timeout
- 死信队列(DLQ)
- 扣款消费者
- 发货消费者
- 退款消费者
- 提醒 cron job
- 审批接口
- 状态轮询接口
- DLQ 查看和修复工具
运行在数据库和队列 broker 上(不计入)
Dex
5个部件要你自己编写和运行
- Charge Step
- Ship Step
- Refund Step
- approve RPC
- 运行这个 Flow 的 Worker
运行在 Dex Server 上,它提供(不计入)
- 每个 Step 自己的 retry
- Durable Timer
- 等待 Channel
- Client 等待 API
- Step 历史和重新执行
数据库做不到什么
PostgreSQL、MySQL、S3 这类数据库和存储服务,负责保存数据,并在你查询时返回数据。它们是被动的:只有你的代码调用它们时,它们才会做事。
可靠的后端需要的不只是存储。它要跑在多台机器上,这样一台出故障不会让服务停下,也能承接更多流量。一个 API 常常要调用好几个别的 API 和服务,一次数据库事务覆盖不了。
一个典型的后端 API 还要:
- 串行或并行地调用多个 API。
- 安排之后要做的事,比如提醒和业务超时。
- 等待其他系统,比如人工审批或外部事件。
- 处理失败:retry、回滚,以及用于人工修复的死信队列(DLQ)。每一步的策略可能都不一样。
- 能看到每一步的输入、错误,以及还没做完的工作。
- 流量增长时,依然可用、够快、成本可控。
这些数据库都不提供。每个服务都要把这些代码重新写一遍。
例子:分两步处理订单
订单流程只有两步,每一步是一次 API 调用:
- 向买家扣款。
- 发货。
做原型时,会直接依次调用这两个 API:
def process_order(order):
call_charge_api(order)
call_ship_api(order)
只要不出错,这样就能用。但如果扣款成功后进程崩溃,或者发货调用超时,买家已经付了钱,货却永远不会发出。
两次调用都完成后才返回——或者半路失败。
同一个请求线程里 charge(); ship();
- 扣款后崩溃或超时会留下损坏的状态
- 做 POC 够用,上生产不行。
方案 1:数据库行加轮询 worker
下一个想法是:插入一行 status = pending 的订单,立即返回给客户端,再让后台 worker 去取这一行来处理。
插入一行,返回 request id。
status = pending | charging | shipping | done
SELECT … FOR UPDATE
扫同一张表
订单保存下来了,但新问题也来了:
- 多个 worker 怎么取行,才不会抢到同一行?
- worker 取到一行之后、处理完之前崩溃了,怎么办?
方案 2:步骤之间用消息队列
有经验的团队通常会改用消息队列,每一步一个队列:charge-requests 和 ship-requests。订单先作为一条消息进入 charge-requests。扣款消费者处理完这条消息,再往 ship-requests 发一条消息。
写入扣款队列后立刻返回。
调扣款 API,再写入发货队列。
调发货 API,也许再写一行状态。
- 发货消息已发出,扣款消息的确认却超时:扣款和发货都会执行两次。
- 第三步就要第三套队列和另一批消费者。
- Backoff、DLQ、审批等待,往往又是更多队列和 cron。
队列解决了方案 1 的问题,但带来了更难的问题:
- 大多数队列无法把「扣款完成」和「发出发货消息」一起提交。如果发货消息已经发出,但扣款消息的确认超时了,队列会再投递一次扣款消息。于是同一笔订单在发货的同时,又在重复扣款。
- 团队通常再加一行数据库记录作为唯一可信来源,用来去重。
- 带退避的 retry 和 DLQ 往往要自己补。
- 比如 Kafka 要额外建 retry topic,SQS 要靠 visibility timeout 绕过去。
- 查看和恢复 DLQ 里消息的工具,也得自己做。
- 前端可能要在扣款成功后马上返回(「已付款,订单处理中」)。这时它只能轮询数据库,既别扭又浪费。
业务还会不断变化。订单流程常常还要:
- 发货 API 重试后仍然失败时给买家退款,比如连续失败一小时后退款。
- 发货前等卖家审批,或者让卖家手动填写运单号。
- 提醒卖家去审批或填写运单号。
在队列方案里,每一项都要加队列、消费者、表或 cron job。
用 Dex Flow 实现同一个流程
在 Dex 里,你只写业务逻辑。retry、timer、等待、崩溃后恢复,以及每个 Step 的记录,都由 Dex 提供。
启动 Flow,再用 Client API 等 Charge Step 完成。
把每个 Step 的结果和到下一个 Step 的推进一起记录。
Charge、Ship、Refund Step
同一份 Flow 代码
扣款、审批、发货的流程写成:
- Charge Step:向买家扣款,有自己的 retry 策略。扣款成功后,Dex 把订单推进到 Ship Step。Dex 把结果和这次推进一起记录下来,所以崩溃不会让订单卡在两个 Step 之间。
- Ship Step:
- 等待卖家审批,或者等一个 durable timer。
- timer 先到,就发提醒并继续等;审批到了,就发货。
- 发货调用会 retry。retry 用尽后,进入 Refund Step。
- Refund Step:给买家退款。
- 前端调用一个内置的 Client API,等 Charge Step 完成后显示「已付款」。
Dex 记录每个 Step 的输入、输出和错误。运维人员可以查看每一笔订单,并在修复后从之前的某个 Step 重新执行(time travel)。
| 问题 | 数据库加队列 | Dex Flow |
|---|---|---|
| 两步之间崩溃 | 买家可能已扣款,货却没发 | Dex 把每个 Step 的结果和到下一个 Step 的推进一起记录 |
| 多个 worker | 自己实现抢行,或依赖队列投递 | Dex 把每个任务派给你的某个 Worker |
需求变化时
每个新需求都会给队列方案加部件。在 Dex 里,它最多加一个 Step 或 RPC,再改一下 Ship Step。
发货失败一小时后给买家退款
- refund-requests
- 退款消费者
发货消费者记下第一次失败的时间,一小时后放弃。
- Refund Step
Ship Step:retry 一小时,然后进入 Refund Step。
发货前等卖家审批
- 审批表
- 审批接口
改由审批接口写入 ship-requests,扣款消费者不再写。
- approve RPC
Ship Step 等待卖家 Channel 上的消息。
提醒卖家
- 提醒 cron job
这个 job 扫描审批表,找出还在等的订单。
Ship Step 同时等一个 Timer。Timer 到了,就发提醒,然后继续等。
为什么是 Dex? 给出这条 Flow 的 Dex 代码,Quick Start 会把它跑起来。