如果 API Gateway 解决的是“请求应该去哪里”,那么分布式任务系统解决的就是另一个更难的问题:一件事情,到底应该由谁来做?
当任务只有几个时,一个 Goroutine 就够了。当任务变成几百万个,真正困难的就不再是“怎么执行”,而是:任务怎么存?谁来执行?一个 Worker 挂了怎么办?任务执行了一半进程崩溃怎么办?任务到底执行了一次,还是执行了两次?如何让几千台机器共同处理一堆任务,却又不会重复消费? 这就是分布式任务系统真正要解决的问题。
一、问题:为什么一个 go func() 最终解决不了任务系统?
假设我们有一个电商系统。用户下单之后,需要执行很多事情:
创建订单
↓
扣减库存
↓
生成支付单
↓
发送短信
↓
发送邮件
↓
生成发票
↓
更新推荐系统
↓
统计销售数据
最开始,开发者可能会这样写:
go sendEmail(order)
go sendSMS(order)
go updateRecommend(order)
看起来非常优雅。主请求快速返回,后台 Goroutine 慢慢执行任务。但是生产环境很快会告诉你:事情没有这么简单。
如果进程突然崩溃:
HTTP Request
|
v
go func()
|
v
sendEmail()
|
X
Process Crash
邮件没有发送,而系统甚至不知道任务丢失了。
再考虑另外一个问题。如果服务器有:
Gateway
|
v
Order Service
Order Service 只有一台机器。任务量突然增加:
100 task/s
↓
1000 task/s
↓
10000 task/s
一台机器上的 Goroutine 数量开始不断增加。最终:
Task
↓
Goroutine
↓
Memory
↓
GC Pressure
↓
CPU
↓
Latency
↓
Crash
这时候问题已经不是“Go 能不能并发执行任务?”,而是:我们应该如何把任务从“请求”中分离出来?
于是:
Client
|
v
Business Service
|
v
Task Queue
|
+----------+----------+
| | |
v v v
Worker A Worker B Worker C
这就是分布式任务系统最基本的模型。
二、从“调用函数”到“提交任务”
传统程序:
A()
↓
B()
↓
C()
调用者必须等待。而任务系统把它变成:
Producer
|
| Submit Task
v
Queue
|
+------> Worker A
|
+------> Worker B
|
+------> Worker C
Producer 不负责执行。它只负责:告诉系统:“这里有一件事情需要完成。” Worker 才负责:把这件事情真正做完。
这个变化非常重要。因为一旦“任务提交”和“任务执行”被分离,Producer 和 Worker 就可以独立扩展。
任务增加:
Worker 3
↓
Worker 30
↓
Worker 300
Producer 不需要改变。这就是分布式任务系统最核心的架构价值:> 把任务生产速度与任务处理能力解耦
三、历史背景:任务为什么会从进程内部走向消息队列?
这个问题其实经历了很长的发展过程。
1. 单机函数调用
最简单:
Program
|
+--> Function A
|
+--> Function B
任务和程序生命周期绑定。程序死,Task 就 Lost。
2. 后台线程
于是开始使用线程:
Main Thread
|
+------> Worker Thread
任务可以异步执行。但是线程数量是有限的。而且:
Thread
↓
Stack
↓
Memory
任务一多,资源管理就会越来越复杂。
3. Goroutine
Go 把问题进一步简化:
Task
↓
Goroutine
大量任务可以由大量 Goroutine 执行。但这里存在一个非常关键的边界:> Goroutine 解决的是并发执行问题,不是任务可靠性问题
如果进程崩溃:
Goroutine
↓
Process Crash
↓
Task Lost
所以:Goroutine 不是任务队列。
四、真正的任务系统需要“持久化”
假设我们有 Task A、Task B、Task C。如果这些任务只存在内存:
Memory
├── Task A
├── Task B
└── Task C
服务器重启:
Process Restart
↓
Memory Lost
↓
Task A/B/C Lost
所以可靠任务系统通常需要:
Producer
|
v
Persistent Queue
|
v
Worker
任务首先进入可靠存储。例如:
Task
↓
Disk / Database / Message Broker
↓
Worker
这样即使 Worker 崩溃:
Worker Crash
↓
Task still exists
系统仍然可以继续处理。
五、任务系统真正困难的地方:任务到底算不算完成?
这是整个分布式任务系统最重要的问题之一。假设 Worker:
Worker
|
v
Task A
|
v
Process
|
v
执行任务
任务执行成功了。但是就在 Worker 准备告诉 Queue:ACK 的时候:
Worker
|
v
Task completed
|
X
Process Crash
于是:
Queue:
“我没有收到 ACK。”
Worker:
“我已经执行成功了。”
下一次:
Task A
↓
Worker B
↓
Execute again
于是任务执行了两次。这就是分布式系统非常经典的问题:> Exactly Once 到底是否真的存在?
六、At-Most-Once、At-Least-Once、Exactly-Once
任务系统必须先定义自己的消息语义。
At-Most-Once
最多执行一次。
Task
↓
Worker
↓
Execute
失败以后,Task Lost。优点是不重复,缺点是可能丢任务。适合非关键日志、统计事件、允许少量丢失的数据。
At-Least-Once
至少执行一次。
Task
↓
Worker
↓
Execute
↓
ACK
如果 Worker 没有 ACK:
Task
↓
Retry
↓
Worker
优点:尽量不丢任务。缺点:可能重复执行。这是现实世界任务系统中非常常见的模型。
Exactly-Once
理论上:
Task
↓
Execute exactly once
听起来最完美。但问题是:
执行成功
↓
ACK
这两个动作本身不是一个原子操作。如果:
Execute Success
↓
Process Crash
↓
ACK Lost
系统无法判断:到底执行成功了?还是根本没有执行?所以真正成熟的工程系统往往不会简单追求“Exactly Once Delivery”,而是更多采用:At-Least-Once + Idempotency。也就是:允许重复,但重复不会造成错误。这是任务系统设计中非常重要的思想。
七、幂等性:分布式任务系统的生命线
假设任务是“支付 100 元”。如果执行两次:
第一次:-100
第二次:-100
那就出大问题了。所以不能直接 execute(task),而应该引入任务 ID:
TaskID = 100001
Worker 执行之前检查 TaskID 100001 是否已经成功。如果已经执行,直接 return success;如果没有,执行并标记完成:
Task 100001
|
v
Idempotency Check
|
+---- Already Done ----> Return
|
+---- Not Done --------> Execute
这就是幂等设计。
八、一个真正的任务模型应该包含什么?
可以定义:
type Task struct {
ID string
Type string
Payload []byte
Status string
Retry int
CreatedAt time.Time
}
例如:
ID:
task-20261006-001
Type:
send_email
Payload:
{"user_id":1001}
Status:
pending
Retry:
0
任务的生命周期可以设计为:
Pending
|
v
Running
|
+------> Success
|
+------> Failed
|
v
Retry
|
v
Pending
这比简单的 queue <- task 要复杂得多。因为你已经开始设计一个真正的状态机。
九、为什么任务状态机非常重要?
因为分布式系统中:> 状态本身就是事实。
例如:
PENDING
RUNNING
SUCCESS
FAILED
RETRYING
系统发生故障以后,可以根据状态恢复。假设 Task A -> RUNNING,Worker 突然挂掉。系统启动后发现:
Task A
RUNNING
10分钟前
那么它可以推断:这个任务可能已经失去 Worker。于是:
RUNNING
↓
Timeout
↓
RETRYING
然后重新进入队列。所以任务状态并不是简单的数据库字段,它实际上是:分布式系统恢复机制的一部分。
十、Worker:真正执行任务的机器
一个最简单的 Worker:
func worker(ctx context.Context, tasks <-chan Task) {
for {
select {
case <-ctx.Done():
return
case task := <-tasks:
execute(task)
}
}
}
这里出现了 Go 的几个核心能力:
Goroutine
Channel
Context
这几个组件非常适合构建 Worker。
十一、Worker Pool:为什么不能一个任务一个无限 Goroutine?
假设有 100000 个任务。如果直接:
for _, task := range tasks {
go execute(task)
}
理论上 Go 可以创建大量 Goroutine。但:能创建,不代表应该创建。 如果每个任务都打开数据库连接、发送 HTTP 请求、访问磁盘、访问 Redis,那么最终真正限制系统的不是 Goroutine,而是:
CPU
Memory
Database Connection
Network Connection
File Descriptor
Remote Service
所以更合理的模型:
Task Queue
|
+---------+---------+
| | |
v v v
Worker 1 Worker 2 Worker 3
| | |
+---------+---------+
|
Backend
例如:
for i := 0; i < 100; i++ {
go worker(ctx, tasks)
}
这就是 Worker Pool。
十二、Worker Pool 背后的本质:控制并发度
假设任务生产速度为 10000 task/s,而后端只能处理 1000 task/s。如果没有控制:
Queue
↓
无限 Worker
↓
Backend
↓
Overload
有 Worker Pool:
Queue
↓
100 Workers
↓
Backend
系统的最大并发量被限制住。这实际上是:背压(Backpressure)。
十三、背压:系统为什么必须学会“慢下来”?
这是分布式系统非常重要的概念。假设 Producer 为 10000/s,而 Consumer 为 1000/s,那么队列增长速度:
Queue
10000 - 1000
= 9000/s
队列越来越长。如果无限增长:
Queue
↓
Memory
↓
Disk
↓
Storage Exhausted
最终系统还是会崩。所以系统必须有一个边界:
Producer
|
v
Queue
|
v
Worker Pool
|
v
Backend
当队列达到一定长度,Producer 必须阻塞、降速、丢弃低优先级任务、返回错误或延迟提交。这就是背压。
十四、Go Channel 为什么非常适合做本地 Worker Pool?
Go Channel 可以表达:
Producer
|
v
Channel
|
+--> Worker
|
+--> Worker
|
+--> Worker
例如:
tasks := make(chan Task, 1000)
Producer:
tasks <- task
Worker:
task := <-tasks
当 Channel Capacity 为 1000 被填满,Producer 就会 Block。这就是非常自然的背压机制。
十五、但 Channel 不是分布式队列
这是一个非常容易犯的错误。Channel:
Process A
|
Channel
|
Process A
它存在于单个进程内存。如果 Worker Process Crash,Channel 中的数据也会消失。所以 Channel 适合本地任务分发、Worker Pool、Goroutine 通信,但不适合作为跨机器的可靠任务存储。这就是 Go Channel 和 Message Queue 之间最重要的区别之一。
十六、分布式任务系统的完整架构
一个更加真实的系统:
Producer
|
v
+---------------+
| Task API |
+---------------+
|
v
+---------------+
| Task Storage |
+---------------+
|
v
+---------------+
| Message Queue |
+---------------+
|
+--------------+--------------+
| | |
v v v
Worker A Worker B Worker C
| | |
+--------------+--------------+
|
v
External Services
旁边还有:
Task Scheduler
Retry Manager
Dead Letter Queue
Metrics
Tracing
Dashboard
这时候它已经是一个完整的平台。
十七、Scheduler:任务不是永远立即执行
很多任务并不是“现在执行”,而是“五分钟以后执行”。例如:
2026-10-06 13:00
创建订单
2026-10-06 13:05
检查支付状态
2026-10-07 13:00
发送提醒
于是需要 Scheduler。结构:
Scheduled Task
|
v
Scheduler
|
| Time reached
v
Message Queue
|
v
Worker
这时候任务系统已经从 Queue 升级成 Task Scheduling System。
十八、延迟任务怎么设计?
最简单的设计:
tasks
----------------------
id
execute_at
status
payload
Scheduler 定期查询:
SELECT *
FROM tasks
WHERE execute_at <= NOW()
AND status = 'pending';
然后:
Task
↓
Scheduler
↓
Queue
↓
Worker
但是这里有一个新的并发问题:如果有 10 个 Scheduler,它们可能同时发现 Task A ready,于是 A、B、C 都领取 Task A,任务又重复了。因此需要任务领取(Claim)机制。
十九、任务领取:谁有资格执行?
可以把任务状态从 PENDING 原子地变成 RUNNING:
PENDING
|
| Atomic Claim
v
RUNNING
只有成功 Claim 的 Worker 才能执行。数据库可以使用类似:
UPDATE tasks
SET status = 'running'
WHERE id = ?
AND status = 'pending'
然后检查 affected rows == 1。只有修改成功的 Worker 获得执行权。其他 Worker affected rows == 0,说明已经被别人领取。这就是一种非常经典的分布式并发控制。
二十、Worker 崩溃以后怎么办?
假设:
Task A
↓
Worker A
↓
RUNNING
然后 Worker A 崩溃,Task 永远停留 RUNNING。怎么办?系统需要一个 Lease(租约)。例如:
lease_until = now + 30s
Worker 领取任务:
Task
status = RUNNING
lease_until = 13:30:30
如果 Worker 正常,通过 Heartbeat 不断延长租约;如果 Worker 挂了,Heartbeat 停止,Lease 到期:
RUNNING
↓
Lease Expired
↓
PENDING
然后任务重新进入队列。
二十一、这就是分布式系统里的 Lease 思想
Lease 本质上是:一种带过期时间的临时所有权。 它非常有用。因为分布式系统很难知道“Worker 到底是真的死了,还是只是网络断了?”系统只能使用 Timeout 做近似判断。因此 Worker 拥有任务不是永久所有权,而是在某个时间窗口内拥有任务。时间到了,Ownership Expired,任务重新可用。这比试图判断“机器到底死没死”更加现实。
二十二、重试:失败不是异常,而是系统的一部分
假设:
Task
↓
Worker
↓
HTTP Request
↓
Backend
↓
500
应该怎么办?直接失败还是重试?通常需要区分错误类型。
瞬时错误
例如 Network Timeout、Connection Reset、Temporary Unavailable,可以重试。
永久错误
例如 Invalid Payload、Permission Denied、Invalid User,继续重试没有意义。所以任务系统需要判断 Retryable?
二十三、指数退避
如果失败以后立即重试:
Request
↓
Fail
↓
Retry
↓
Fail
↓
Retry
↓
Fail
大量任务可能同时重试。最终:
Backend Failure
↓
Mass Retry
↓
Traffic Spike
↓
Backend More Failure
形成:Retry Storm。因此通常需要指数退避:
1s
2s
4s
8s
16s
32s
再加一点随机抖动 Jitter,避免大量任务同时恢复。
二十四、Dead Letter Queue:失败任务最终去哪里?
如果 Retry = 10 还是失败:
Task
↓
Retry
↓
Retry
↓
Retry
↓
...
↓
Failed
不能无限重试。否则坏任务会永久占用系统资源。所以可以进入 Dead Letter Queue:
正常任务
|
v
Worker
|
+---- success
|
+---- retry
|
v
retry limit
|
v
Dead Letter Queue
然后人工或者专门的恢复程序进行处理。这是一种非常重要的生产机制。
二十五、任务系统其实是一个状态机
把所有东西放在一起:
+-------------+
| PENDING |
+-------------+
|
v
+-------------+
| RUNNING |
+-------------+
| |
success fail
| |
v v
+---------+ +---------+
| SUCCESS | | RETRY |
+---------+ +---------+
|
|
retry limit
|
v
+---------+
| DLQ |
+---------+
这张图比任何 API 都重要。因为它告诉我们:一个任务从创建到结束,本质上是一组状态转换。
二十六、Go Runtime 在任务系统中扮演什么角色?
现在再回到 Go。一个 Worker:
for {
task := <-tasks
process(task)
}
如果有 100 个 Worker,就是 100 个 Goroutine。如果有 10000 个 Worker,理论上也可以拥有大量 Goroutine。但是 Go Runtime 并不会为每个 Goroutine 创建一个独立 OS Thread。核心仍然是 GMP:
G = Goroutine
M = OS Thread
P = Processor
大致可以理解:
Go Runtime
|
+-----------+-----------+
| | |
v v v
G G G
| | |
+-----------+-----------+
|
v
P
|
v
M
|
v
OS Thread
当任务主要进行 HTTP IO、Database IO、RPC、Redis、File IO 时,大量 Goroutine 可以高效地等待 IO。
二十七、但 Worker Pool 不是越大越好
假设 Worker = 100,系统性能 1000 task/s。于是有人想 Worker = 10000,结果可能:
CPU ↑
Memory ↑
Context Switching ↑
Connection ↑
Backend Pressure ↑
最终吞吐量反而下降。因为 Worker Pool 的核心不是“越多越快”,而是:让并发度与系统真正能够承受的资源相匹配。 例如 CPU-bound Task 通常应该接近 CPU 能力,而 IO-bound Task 可以拥有更高并发。所以 Worker 数量应该根据 CPU、IO、Database、Network、External API、Memory 共同决定。
二十八、CPU 密集型任务和 IO 密集型任务完全不同
假设任务是图片压缩、视频编码、加密计算、机器学习推理,这是 CPU 密集型。如果 Worker = 10000,很可能没有意义,因为 CPU 只有 16 cores,大量 Worker 最终只是竞争 CPU。
而如果任务是 HTTP Request、Database Query、Redis、RPC,那么大量 Worker 可能处于 Waiting 状态。这时候 Go 的并发模型非常有优势。所以:设计任务系统之前,必须先理解任务本身的资源模型。
二十九、任务系统的性能不能只看 Worker 速度
假设 Worker 能处理 10000 task/s,但是 Queue 只有 5000 task/s,那么系统吞吐量仍然只有 5000 task/s。完整系统的瓶颈可能来自 Producer、Queue、Scheduler、Worker、Database、External Service 中的任何一环。真正的吞吐量:由最慢的关键环节决定。 这就是典型的系统瓶颈问题。
三十、数据库会不会成为任务系统的瓶颈?
非常容易。假设 1000 个 Worker,每次执行任务都:
SELECT task
UPDATE task
INSERT result
UPDATE task
那么:
1000 Workers
↓
Database
↓
Thousands of QPS
任务系统看起来是高并发,真正的瓶颈却可能是数据库。所以成熟系统通常需要 Queue、Batch、Connection Pool、Partition、Cache,甚至最终使用专门的消息系统承担任务流量。
三十一、为什么任务队列通常需要分区?
假设有 10 million tasks 全部放在一个队列,随着规模扩大:
Queue
↓
Lock
↓
Contention
↓
Throughput Limit
于是可以分区:
Queue 0
Queue 1
Queue 2
Queue 3
...
Queue N
任务根据 hash(taskID) 选择分区。例如:
Task A -> Partition 0
Task B -> Partition 3
Task C -> Partition 1
Task D -> Partition 7
这样可以水平扩展。
三十二、分区带来了新的问题:顺序
假设 Task A 和 Task B,要求 A 必须先执行,B 才能执行。如果 A 进入 Partition 1,B 进入 Partition 7,就很难保证顺序。所以分布式任务系统经常必须回答:哪些任务需要顺序? 如果同一个业务实体必须顺序执行,可以 hash(userID) 让同一个用户进入同一个分区:
user-1001 -> Partition 3
user-1001 -> Partition 3
user-1001 -> Partition 3
于是 Task A、Task B、Task C 可以在同一分区内保持顺序。
三十三、分布式任务系统最难的不是并发,而是故障
正常情况下:
Producer
↓
Queue
↓
Worker
↓
Success
非常简单。真正困难的是:
Producer Crash
Queue Crash
Worker Crash
Network Partition
Database Timeout
Duplicate Delivery
Slow Consumer
Message Loss
Retry Storm
也就是说:分布式系统的复杂度,主要来自失败,而不是成功。 这也是为什么真正的任务系统设计必须围绕 Failure、Recovery、Retry、Idempotency、Timeout 展开。
三十四、一个生产级任务系统应该观察什么?
至少需要:
Task Throughput
Queue Depth
Processing Latency
Success Rate
Failure Rate
Retry Count
DLQ Count
Worker Count
Worker Utilization
尤其重要的是 Queue Depth。如果它持续增长,说明:生产速度已经超过消费速度。 这比单纯看 CPU 更能说明问题。
Processing Latency 也很关键。例如:
P50 = 20ms
P95 = 100ms
P99 = 2s
意味着大部分任务很快,但尾部任务已经出现严重问题。
三十五、任务系统也需要 Tracing
一个任务可能经过 API、Queue、Worker、Database、RPC、External Service。如果最终失败,日志只能告诉你“失败了”,但 Trace 可以告诉你:
Task ID
|
+-- API 5ms
|
+-- Queue 100ms
|
+-- Worker 30ms
|
+-- DB 20ms
|
+-- RPC 5s
这时候很快就知道:真正的瓶颈不是 Worker,而是 RPC。所以:分布式任务系统必须把任务 ID 作为可观测性的核心关联键。
三十六、Go 中的 Context 为什么特别重要?
任务系统中,一个任务可能执行 HTTP、Database、RPC。如果任务已经超时,后面的操作没有必要继续。因此:
ctx, cancel := context.WithTimeout(
context.Background(),
30*time.Second,
)
defer cancel()
然后:
process(ctx, task)
继续向下传:
Context
↓
HTTP
↓
DB
↓
RPC
一旦 ctx.Done(),整个任务链路都可以停止。这实际上形成了一种非常重要的系统能力:取消传播。
三十七、优雅退出:Worker 不能粗暴 Kill
假设服务器收到 SIGTERM。如果直接退出,正在执行的任务可能丢失。正确方式应该是:
SIGTERM
|
v
Stop Accepting New Tasks
|
v
Wait Current Tasks
|
v
Commit / ACK
|
v
Exit
Go 非常适合这种生命周期管理。例如:
ctx, cancel := signal.NotifyContext(
context.Background(),
syscall.SIGTERM,
syscall.SIGINT,
)
defer cancel()
然后:
<-ctx.Done()
收到退出信号后:
Stop
↓
Drain
↓
Shutdown
这就是生产系统真正需要的 Worker 生命周期。
三十八、任务系统为什么不能只依赖内存?
因为 Memory 属于 Process Local State。而任务可靠性要求 Process Crash 之后数据仍然存在。所以通常需要 Memory 加 Persistent Storage。可以把两者分工:
Persistent Queue
|
v
Reliable State
Memory Channel
|
v
Fast Local Dispatch
也就是说:持久化系统保证可靠性,内存系统保证速度。 这也是很多高性能系统的经典设计。
三十九、一个简化版 Go 任务系统
如果我们自己实现一个最小版本,可以从 Task Queue、Worker Pool、Retry、Context 开始。
例如任务:
type Task struct {
ID string
Type string
Payload []byte
}
Worker:
func worker(ctx context.Context, tasks <-chan Task) {
for {
select {
case <-ctx.Done():
return
case task, ok := <-tasks:
if !ok {
return
}
if err := execute(ctx, task); err != nil {
handleFailure(task, err)
}
}
}
}
启动:
tasks := make(chan Task, 1000)
for i := 0; i < 100; i++ {
go worker(ctx, tasks)
}
这个系统可以工作,但它距离生产环境还非常远。因为缺少:
Persistence
Retry
Claim
Lease
Idempotency
DLQ
Scheduler
Observability
Distributed Coordination
而这些才是真正困难的部分。
四十、源码层面真正值得理解的是什么?
如果深入 Go Runtime,任务系统最值得关注的并不是某一个 API,而是:
Goroutine
Scheduler
Channel
Netpoll
Context
Memory
GC
例如一个 Worker:
Goroutine
|
v
等待任务
|
v
Channel Receive
|
v
Task Processing
|
v
Network IO
|
v
等待
当 Worker 等待网络 IO 时,Goroutine 处于 Waiting 状态。Runtime 可以把执行机会交给 Other Goroutine。因此大量任务并不意味着大量 OS Threads。这就是 Go 在 IO 型任务系统中非常有价值的地方。
四十一、但 Go 的 GC 也会影响任务系统
任务系统可能产生大量 Task Object、Payload、JSON、HTTP Request、Response、Error、Retry Metadata。如果任务吞吐量很高,Allocation Rate 上升,就会带来 GC Work、CPU 和 Latency 的上升。因此设计任务系统时必须关注 Allocation、Object Lifetime、Payload Size、Buffer Reuse、Serialization。例如非常大的任务 Payload:
Task
|
+-- 10MB JSON
就可能造成大量内存压力。更合理的设计可能是:
Task
|
+-- Object ID
真正的数据放在 Object Storage,任务只传递 Reference,而不是 Huge Payload。
四十二、Cache Friendly 也影响任务吞吐量
Worker 经常访问 Task Metadata、Worker State、Retry Counter、Queue State。如果大量 Worker 竞争共享状态:
Worker 1
Worker 2
Worker 3
|
v
Shared Counter
就容易形成 Lock Contention 和 Cache Line Contention。所以高性能任务系统经常采用 Sharding、Local State、Atomic、Per-Worker Counter、Batch Update 来减少共享数据。这里的核心思想依然是:共享状态越少,并发系统通常越容易扩展。
四十三、任务系统中的“恰好一次”为什么如此困难?
这是整个主题最值得思考的问题之一。假设:
Worker
|
v
Execute Task
|
v
Success
|
v
ACK
如果 Success 已经发生,但 ACK 丢失,系统下一次只能看到 No ACK,于是 Retry。所以真正的问题不是“如何让消息永远不重复”,而是:如何让重复发生时,系统依然正确? 这就是为什么 At-Least-Once 加 Idempotency 往往比试图构造一个完美的 Exactly-Once 更现实。
四十四、任务系统的最终架构
把今天讨论的内容全部组合:
Producer
|
v
+---------------+
| Task API |
+---------------+
|
v
+----------------+
| Persistent DB |
+----------------+
|
v
+----------------+
| Task Scheduler |
+----------------+
|
v
+----------------+
| Message Queue |
+----------------+
|
+-----------------+-----------------+
| | |
v v v
Worker A Worker B Worker C
| | |
+-----------------+-----------------+
|
v
+----------------+
| External APIs |
+----------------+
+-------------+ +-------------+
| Retry | | Dead Letter |
| Manager | | Queue |
+-------------+ +-------------+
+-------------+
| Monitoring |
| Metrics |
| Tracing |
+-------------+
如果进一步扩大:
Control Plane
|
+-------------+-------------+
| | |
v v v
Scheduler Config Service Discovery
|
v
Data Plane
|
+----+----+----+
| | | |
Worker Worker Worker
这已经不是一个简单的 Go 项目,它是一个完整的分布式计算基础设施。
四十五、一个任务系统真正应该保证什么?
成熟的系统不应该只说“我们的任务执行很快”,而应该回答:
- 会不会丢?→ Durability
- 会不会重复?→ Delivery Semantics
- 重复了怎么办?→ Idempotency
- Worker 挂了怎么办?→ Lease / Recovery
- Backend 挂了怎么办?→ Retry / Backoff / Circuit Breaker
- 任务永远失败怎么办?→ Dead Letter Queue
- 任务太多怎么办?→ Backpressure / Horizontal Scaling
- 一个任务执行太久怎么办?→ Timeout / Cancellation
- 系统重启怎么办?→ Persistent State
- 出问题之后怎么知道哪里坏了?→ Metrics / Logs / Tracing
这些问题才构成一个真正可靠的任务系统。
四十六、从任务系统看 Go 的边界
Go 很适合构建 Worker、Scheduler、Queue Consumer、RPC Service、Network Service、Controller、Operator、Cloud Native Infrastructure。因为这些系统通常具有高并发、大量 IO、网络通信、长时间运行、大量服务实例,而这些恰好与 Go 的设计非常匹配。
但是,如果任务本身是极重 CPU 计算、GPU 计算、极端低延迟计算、内核级数据处理,Go 就未必是最好的选择。所以:语言应该服从任务模型,而不是任务模型服从语言。 这也是工程师与语言布道者之间非常重要的区别。
四十七、行业影响:任务系统其实正在成为云原生基础设施
今天我们看到的很多系统:
Kubernetes Controller
CI/CD Pipeline
Data Processing
Email Service
Notification Service
Image Processing
Video Processing
Billing
Recommendation
Machine Learning Pipeline
背后都存在某种形式的 Task、Queue、Worker、Scheduler、Retry。甚至 Kubernetes 本身也可以理解成一个不断观察系统状态,然后执行任务让现实状态逼近目标状态的巨大控制系统。因此,理解分布式任务系统,不只是为了写一个 Job Queue,而是在理解:现代基础设施如何把大量工作分配给大量机器。
四十八、未来:任务系统会走向哪里?
未来的任务系统很可能越来越动态。今天:
Scheduler
↓
Queue
↓
Worker
未来可能变成:
Event
↓
Policy
↓
Scheduler
↓
Dynamic Resource
↓
Worker
任务不只是“执行一个函数”,而可能是“根据当前系统资源、优先级、成本、延迟和可靠性要求,决定在哪里、什么时候、以什么规模执行”。这意味着任务调度最终会越来越接近 Distributed Scheduler、Resource Management、Policy Engine 的组合,而 Go 仍然会处在这个生态的重要位置。
四十九、从 Day 97 到 Day 98:一个重要的变化
Day 97,我们设计 API Gateway,它解决“请求应该去哪里?”;Day 98,我们设计 Distributed Task System,它解决“工作应该由谁完成?” 两个系统看起来完全不同,实际上它们共享大量底层思想:
Load Balance
Timeout
Retry
Failure
Concurrency
Backpressure
Observability
Horizontal Scaling
Gateway 负责 Request,任务系统负责 Work。而现代分布式系统,本质上就是不断处理 Request、Work、State 和 Failure。
五十、真正值得带走的,是“失败模型”
如果只记住今天几个概念:Worker Pool、Message Queue、Retry、Scheduler、Dead Letter Queue,还不够。真正应该记住的是:任何分布式系统设计,都应该先问:失败以后怎么办?
任务执行成功当然简单。真正重要的是:
Worker死了怎么办?
Queue死了怎么办?
网络断了怎么办?
ACK丢了怎么办?
任务重复怎么办?
任务永远失败怎么办?
Consumer跟不上怎么办?
Scheduler重复调度怎么办?
数据库不可用怎么办?
如果这些问题没有答案,那么这个系统还不能称为可靠的分布式任务系统。
五十一、写在 Day 98
从 Day 46 到 Day 60,我们学习 Go 并发:Goroutine、GMP、Channel、Select、Mutex、Atomic、Context、Worker Pool。而到了 Day 98,这些知识终于开始组合起来:
Goroutine
↓
Worker
Channel
↓
Local Queue
Context
↓
Cancellation
Atomic
↓
Concurrent State
Scheduler
↓
Distributed Task
Persistence
↓
Reliability
Retry
↓
Failure Recovery
这就是从“学习 Go 的并发 API”走向“使用 Go 设计一个分布式系统”。真正成熟的 Go 工程师,不应该只会写 go worker(),而应该开始思考:
这个任务会不会丢?
这个任务会不会重复?
如果重复,是否幂等?
Worker死了,谁接管?
任务卡住了,谁超时?
系统过载了,谁施加背压?
任务失败了,什么时候重试?
重试多少次?
为什么失败?
失败任务最终在哪里?
系统扩容之后,任务如何重新分配?
如果整个集群重启,任务还能不能恢复?
这些问题没有一个可以通过“多开几个 Goroutine”解决。因为:并发解决的是同时做很多事情。 而:分布式系统解决的是,当很多机器同时做很多事情,而且其中一些机器随时可能失败时,整个系统依然能够保持正确。 这两者之间,有一道非常大的鸿沟。Go 可以帮助我们跨过其中一部分,但真正跨过这道鸿沟的,是状态、消息、并发、故障、恢复与一致性。
一个任务系统真正的价值,不是让任务跑得更快。而是当机器突然死掉、网络突然断掉、消息突然重复、服务突然变慢时,它依然知道下一步应该做什么。
正常运行时,一切都很简单。往往是故障发生后的那几秒——当一台 Worker 消失,当一个 ACK 丢失,当一个任务执行两次,当队列突然积压一百万条任务——系统依然能够继续向前,这才是“可靠性”的真正含义。
而 Go 的价值,也正在这里开始从一门编程语言,变成构建系统基础设施的一种工程工具。