术核 AI · 后端工程课件
为 notes-service 建立导出任务资源,先固定状态转换与查询协议,再接入执行器。
202 只说明服务受理了请求,不能表示文件已经产生。提交响应应提供 job_id、当前状态和查询地址;最终结果通过查询接口获取。
本模块使用 queued、running、succeeded、failed、cancelled。running 才能进入成功或失败;终态不再被旧执行者改写。取消请求与已取消会在后续拆开处理。
本课只建立协议,不启动后台执行。记录存在当前进程内,重启会丢失。第 11 课才把状态迁入 PostgreSQL;不能把本课 202 作为可靠交付承诺。
在 notes-service 下创建 tasks_lab 并进入,使用 Python 3.11+ 虚拟环境。第 01–07 课每个文件独立运行。Windows 可用 py -3 -m venv .venv 和 ..venv\Scripts\Activate.ps1;下面是 Linux/WSL2 命令。没有启动任何外部生产服务。
文件:终端 / 当前课时
mkdir tasks_lab
cd tasks_lab
python3 -m venv .venv
source .venv/bin/activate
python -m pip install "fastapi>=0.115,<1" "uvicorn[standard]>=0.30,<1" "httpx>=0.27,<1"
python --version
创建 api_states.py。transition 只用于同进程实验,先检查再修改;错误时不能留下半更新记录。保留原始 note_ids,后续任务执行只接收业务标识或可序列化快照。
文件:api_states.py
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, Field
app = FastAPI()
jobs: dict[str, dict] = {}
allowed = {
"queued": {"running", "cancelled"},
"running": {"succeeded", "failed", "cancelled"},
"succeeded": set(), "failed": set(), "cancelled": set(),
}
class ExportIn(BaseModel):
note_ids: list[int] = Field(min_length=1, max_length=100)
def transition(job_id: str, target: str):
row = jobs[job_id]
if target not in allowed[row["status"]]:
raise ValueError(f'{row["status"]} -> {target} is not allowed')
row["status"] = target
把这段追加到同一文件。返回 202 和 Location,查询不存在的编号返回 404。当前所有任务保持 queued,不能通过等待假定它会完成。运行 python -m uvicorn api_states:app --reload 后在 /docs 操作。
文件:api_states.py
@app.post("/exports", status_code=202)
async def submit_export(body: ExportIn, response: Response):
job_id = str(uuid4())
jobs[job_id] = {
"id": job_id, "status": "queued",
"note_ids": body.note_ids, "result": None, "error": None,
}
response.headers["Location"] = f"/exports/{job_id}"
return jobs[job_id]
@app.get("/exports/{job_id}")
async def get_export(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "导出任务不存在")
return jobs[job_id]
单独创建 verify_states.py,执行 python verify_states.py。TestClient 与 transition 在同一进程共享字典,这不是修改另一个已启动服务的状态。记录 202、Location、queued→running→succeeded,以及终态回退失败。
文件:verify_states.py
from fastapi.testclient import TestClient
from api_states import app, jobs, transition
with TestClient(app) as client:
response = client.post("/exports", json={"note_ids": [1, 2]})
assert response.status_code == 202
job_id = response.json()["id"]
assert response.headers["location"] == f"/exports/{job_id}"
transition(job_id, "running")
jobs[job_id]["result"] = {"rows": 2}
transition(job_id, "succeeded")
assert client.get(f"/exports/{job_id}").json()["status"] == "succeeded"
try:
transition(job_id, "running")
except ValueError:
print("terminal transition rejected")
用户导出 100 条笔记,希望关闭页面后仍能查询结果。
只在本地提交一条 queued 任务后重启服务,记录查询变成404,说明进程内存的丢失边界。
原因:受理与执行完成混在一个判断中。
处理:仅在查询得到 succeeded 且存在结果引用后开放下载。
原因:状态只存在内存,尚未连接可靠执行系统。
处理:将本课限定为协议实验,后续使用持久状态和 outbox。
增加 GET 查询中的 terminal 布尔字段,验证 queued 为 false、succeeded 为 true。
提示:根据状态计算字段,不另存一份可能不同步的布尔值。
参考答案:
返回记录时增加 terminal=row['status'] in {'succeeded','failed','cancelled'};不要让客户端传入 terminal。
批量读取笔记时同时限制执行中的 I/O 和等待处理的数据,取消时回收全部子任务。
Semaphore 控制同时进入临界区的协程数量,却不会阻止一次创建十万个 Task。有限 Queue 加固定数量消费者,同时限制等待项和任务对象数量。
退出 TaskGroup 时等待子任务;子任务出现非取消异常时会取消其余任务,并以 ExceptionGroup 传播。它不会把任务保存到磁盘,也不负责服务重启后恢复。
asyncio.timeout() 超时后在上下文外捕获 TimeoutError。协程在 await 等位置响应取消,finally 适合清理;捕获 CancelledError 后应重新抛出。
新建 bounded_async.py,使用 sleep 代替笔记 API 的 I/O。把输出保存在字典,以 note_id 建立对应关系;实际外部调用还必须设置连接和读取超时。
文件:bounded_async.py
import asyncio
async def read_note(note_id: int):
await asyncio.sleep(0.1)
return {"id": note_id, "title": f"note-{note_id}"}
追加函数。四个消费者最多有两个同时读取;队列最多保存四项。None 是本实验的结束哨兵,真实输入不使用 None。task_done 必须放在 finally,失败也要结清已取出的项。
文件:bounded_async.py
async def collect_notes(note_ids):
queue = asyncio.Queue(maxsize=4)
slots = asyncio.Semaphore(2)
results = {}
async def consume():
try:
while True:
note_id = await queue.get()
try:
if note_id is None:
return
async with slots:
results[note_id] = await read_note(note_id)
finally:
queue.task_done()
finally:
print("consumer closed")
async with asyncio.TaskGroup() as group:
for _ in range(4):
group.create_task(consume())
for note_id in note_ids:
await queue.put(note_id)
for _ in range(4):
await queue.put(None)
await queue.join()
return results
追加入口,执行 python bounded_async.py。正常预算 3 秒足够处理 20 条;把预算改成 0.15 秒复现取消。TimeoutError 在 timeout 上下文外处理,不能返回一个看似完整的部分结果。
文件:bounded_async.py
async def main():
try:
async with asyncio.timeout(3):
rows = await collect_notes(range(20))
print("completed", len(rows))
except TimeoutError:
print("batch timed out")
if __name__ == "__main__":
asyncio.run(main())
临时在 read_note 中加入下面两行,再次执行。TaskGroup 失败应停止整批,其余消费者均输出清理日志。随后恢复函数;如果产品允许部分成功,应另行定义逐条错误结果,不能默默吞掉异常。
文件:bounded_async.py
# 临时插入 read_note 的 await 之前:
# if note_id == 3:
# raise ValueError("invalid note for drill")
# 如果明确需要观察组合异常,可在入口外围写:
# try:
# asyncio.run(main())
# except* ValueError as group:
# print("batch failed", len(group.exceptions))
一次导出需要读取大量笔记,不能耗尽连接池和内存。
在本地第3条输入抛出异常,再用0.15秒预算运行,记录两种情况下消费者关闭与结果行为。
原因:先为全部笔记创建任务,再限制内部I/O。
处理:改为固定消费者和有界Queue,生产端await put自然等待。
原因:子协程吞掉CancelledError或长期没有await。
处理:清理后重新抛出取消,阻塞I/O移到下一课的合适执行器。
给 read_note 加运行中计数与峰值,验证最大同时读取数不超过2。
提示:计数增加、减少包围真实I/O,减少放finally。
参考答案:
在Semaphore内部增加计数并更新峰值;try/finally中减少。正常与超时路径峰值均≤2,结束后运行中计数为0。
在响应后执行一个短任务,观察进程退出、异常回收和重启丢失。
任务在响应发出后运行,不是持久队列。执行异常无法把已发送的202改成500;重要结果必须通过任务状态和日志记录。
保存强引用,读取完成异常,退出时决定等待或取消。只调用create_task不会自动得到重试、限流或跨进程恢复。
正常关闭可以给任务清理时间;强制终止、崩溃和机器断电不会执行finally。会影响用户资产的任务应进入后续持久队列方案。
新建 api_background.py,复用第01课的app和状态。这里新增独立路径,避免覆盖原来只受理的/exports。用 python -m uvicorn api_background:app 启动;此实验不要开reload以免编辑时无意重启。
文件:api_background.py
import asyncio
from uuid import uuid4
from fastapi import BackgroundTasks, Response
from api_states import app, jobs, transition, ExportIn
async def local_export(job_id: str):
transition(job_id, "running")
try:
await asyncio.sleep(1)
jobs[job_id]["result"] = {"rows": len(jobs[job_id]["note_ids"])}
transition(job_id, "succeeded")
except asyncio.CancelledError:
transition(job_id, "cancelled")
raise
except Exception as exc:
jobs[job_id]["error"] = type(exc).__name__
transition(job_id, "failed")
@app.post("/exports/background", status_code=202)
async def submit_local(body: ExportIn, background: BackgroundTasks, response: Response):
job_id = str(uuid4())
jobs[job_id] = {"id": job_id, "status": "queued",
"note_ids": body.note_ids, "result": None, "error": None}
background.add_task(local_export, job_id)
response.headers["Location"] = f"/exports/{job_id}"
return jobs[job_id]
新建 task_registry.py。done回调既移除强引用,也获取异常;不要在回调里再次抛出未处理异常。这个组件尚未暴露提交路由,下一步只在独立生命周期实验里使用。
文件:task_registry.py
import asyncio
import logging
running: set[asyncio.Task] = set()
def observe(task):
running.discard(task)
if task.cancelled():
return
error = task.exception()
if error is not None:
logging.error("local task failed: %r", error)
def start(coro):
task = asyncio.create_task(coro)
running.add(task)
task.add_done_callback(observe)
return task
新建 registry_demo.py独立运行。先给3秒自然完成窗口,剩余任务发取消并等待清理。此例子仅含遵守取消的sleep;不能对任意阻塞库宣称3秒后必定退出。
文件:registry_demo.py
import asyncio
from task_registry import start, running
async def work():
try:
await asyncio.sleep(30)
finally:
print("cleanup")
async def main():
start(work())
await asyncio.sleep(0.1)
pending = set(running)
if pending:
_, pending = await asyncio.wait(pending, timeout=3)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
print("remaining", len(running))
if __name__ == "__main__":
asyncio.run(main())
提交 /exports/background 并轮询第01课查询路径,观察running到succeeded。再将sleep临时改为30秒,仅在本地终止该实验进程并重启;原job_id查询404。比较正常关闭可能等待与强制退出丢失,随后恢复1秒。
文件:终端 / 当前课时
POST /exports/background
Content-Type: application/json
{"note_ids":[1,2]}
# 保存202响应中的id,随后查询:
GET /exports/{job_id}
接口响应后写一条可丢失的诊断信息,与必须完成的用户导出同时存在。
本地延长任务后终止应用,重启查询旧id,验证不存在自动恢复机制。
原因:响应已发送,后台工作不再控制这次HTTP状态。
处理:将异常转为任务failed并记录关联job_id。
原因:create_task任务失败但无人await或读取异常。
处理:保存引用并获取完成异常,或对相关工作使用TaskGroup。
让work在0.1秒后抛出ValueError,确认回调记录异常且running恢复为空。
提示:不要用空except隐藏错误。
参考答案:
将sleep后加raise ValueError('drill');observe记录错误,回调移除任务引用,程序结束时无未获取异常提示。
两个线程处理笔记导出片段,保证每条任务结清并限制等待队列。
字典读改写涉及多个步骤,不能用GIL代替业务锁。锁只保护同一进程中的共享状态,不能协调多个Celery进程或多台机器。
queue.Queue提供线程安全的put/get,有界容量用于限制待处理项。取出后必须对应task_done,join等待的是计数清零,不是线程自然退出。
耗时读取、网络等待、文件写入放到锁外。不要在持锁期间等待另一个需要相同锁的任务,避免死锁和无意义串行化。
创建 threaded_queue.py。每个输入只是一条笔记编号,结果计数属于这个脚本;不要把该锁当成PostgreSQL记录锁。
文件:threaded_queue.py
from queue import Queue
from threading import Lock, Thread
import time
pending = Queue(maxsize=4)
mutex = Lock()
counts = {"done": 0, "failed": 0}
errors = []
追加consumer。异常时记录失败,finally中task_done;即使收到结束哨兵也要结清那一次get。模拟耗时发生在锁外,锁中只修改计数。
文件:threaded_queue.py
def consume():
while True:
note_id = pending.get()
try:
if note_id is None:
return
time.sleep(0.05)
if note_id == 7:
raise ValueError("bad note for drill")
with mutex:
counts["done"] += 1
except Exception as exc:
with mutex:
counts["failed"] += 1
errors.append((note_id, type(exc).__name__))
finally:
pending.task_done()
追加并执行脚本。非daemon线程让退出路径可见。先发送全部编号,再发送两个哨兵;队列满时put等待消费者释放容量。
文件:threaded_queue.py
if __name__ == "__main__":
threads = [Thread(target=consume) for _ in range(2)]
for thread in threads:
thread.start()
for note_id in range(20):
pending.put(note_id)
for _ in threads:
pending.put(None)
pending.join()
for thread in threads:
thread.join()
print(counts, errors)
预期done=19、failed=1、错误编号7。再把失败条件改成两个编号,确认总数仍20。不要用empty()判断所有任务完成:队列已空时消费者仍可能在处理。
文件:threaded_queue.py
# 验收断言可放在所有thread.join之后:
assert counts["done"] + counts["failed"] == 20
assert all(not thread.is_alive() for thread in threads)
assert len(errors) == counts["failed"]
同步笔记读取库只能在线程中调用,希望稳定处理小批量导出。
让第7条输入抛异常并把Queue容量缩小到1,验证错误收集和任务结清。
原因:异常或哨兵分支漏了task_done。
处理:让每次成功get都在finally中task_done一次。
原因:持有锁时执行了sleep或阻塞I/O。
处理:只把共享计数更新放在锁里,I/O放在锁外。
把队列容量改成1,并让消费者每条等待0.2秒,观察生产者入队会等待。
提示:在put前后记录perf_counter,不能只记录消费者耗时。
参考答案:
生产者前几次put后会因队列满等待;消费者释放位置后继续。最终仍完成20项且线程正常退出。
限制提交数量,为等待设置超时,并验证取消 Future 不会强制终止已经运行的线程。
max_workers不会限制任意多次submit累积的待执行Future。调用方仍需限批提交、有界队列或接入限流。
future.result(timeout=...)只限制调用方等待时间。cancel只能取消尚未运行的工作,无法安全强杀运行中的线程。
给同步库设置超时,长循环定期检查Event。with退出线程池默认等待工作完成,不能靠离开with逃避仍在运行的阻塞函数。
创建 pool_io.py。函数每小段等待后检查停止事件;本例0.1秒是可控制的模拟I/O,真实库须使用其自身timeout参数。
文件:pool_io.py
import time
from threading import Event
from concurrent.futures import ThreadPoolExecutor, TimeoutError
def read_sync(note_id, stop):
for _ in range(20):
if stop.is_set():
return {"id": note_id, "cancelled": True}
time.sleep(0.1)
return {"id": note_id, "cancelled": False}
追加并执行。只有一个任务提交到一个线程,确保实验没有无限排队。0.2秒时通常已经开始执行,cancel返回False;设置stop后等待实际退出。
文件:pool_io.py
if __name__ == "__main__":
stop = Event()
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(read_sync, 1, stop)
try:
print(future.result(timeout=0.2))
except TimeoutError:
print("wait timed out; cancel:", future.cancel())
stop.set()
print("worker result:", future.result(timeout=1))
print("pool closed")
另建 batch_io.py,导入同一同步函数。每次只提交四项,完成后才提交下一批;这会牺牲少量流水线利用率,但不会无限占用内存。不要把本例的循环直接放进async路由阻塞事件循环。
文件:batch_io.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Event
from pool_io import read_sync
def export_batches(note_ids):
stop = Event()
results = []
with ThreadPoolExecutor(max_workers=2) as pool:
for start in range(0, len(note_ids), 4):
batch = note_ids[start:start + 4]
futures = [pool.submit(read_sync, item, stop) for item in batch]
for future in as_completed(futures):
results.append(future.result())
return results
if __name__ == "__main__":
print(len(export_batches(list(range(8)))))
在独立脚本中用asyncio.to_thread调用read_sync,并在超时后设置Event。to_thread使用的底层线程不会因为await被取消就自动消失;业务确认停止必须来自工作函数结束或持久任务状态。
文件:async_thread.py
import asyncio
from threading import Event
from pool_io import read_sync
async def demo():
stop = Event()
try:
async with asyncio.timeout(0.2):
return await asyncio.to_thread(read_sync, 1, stop)
except TimeoutError:
stop.set()
print("stop requested, thread exits cooperatively")
if __name__ == "__main__":
asyncio.run(demo())
导出调用只有同步接口的存储SDK,接口请求可能先超时。
本地让read_sync运行2秒,0.2秒停止等待,记录cancel返回值和线程实际结束时间。
原因:调用方取消了等待,没有停止底层线程。
处理:外部操作需要幂等键和明确状态,给库设置超时并使用协作取消。
原因:线程池正在等待没有超时的阻塞调用完成。
处理:先修正I/O本身的时间上限,不把shutdown当强制终止接口。
增加一个尚未运行的Future,验证排队任务cancel可能为True,而正在运行的任务为False。
提示:max_workers=1,先提交一个长任务,再立即提交第二个。
参考答案:
第二个尚未运行时cancel返回True,不能再对它当作成功结果调用;首个仍需stop.set并等待实际退出。
把纯 Python 文本统计移入独立进程,明确序列化、spawn 与进程入口要求。
在通常带GIL的CPython构建中,多个线程不会让纯PythonCPU循环稳定获得多核并行;某些C扩展会释放GIL,需实际测量。进程池提供独立解释器,代价是启动与数据复制。
传可序列化的文本、编号和结构,不传Request、锁、数据库连接或打开的文件。每个进程需要自己的连接生命周期。
Python不同版本与平台的默认启动方式不同。本模块明确使用spawn,目标函数放模块顶层,并用if name == 'main'保护启动代码。
新建 cpu_work.py。使用纯Python循环产生可比较的校验结果,不借助网络或数据库。规模可以调小,不能为了展示加速耗尽机器资源。
文件:cpu_work.py
def score_text(text: str, rounds: int) -> int:
total = 0
for _ in range(rounds):
for character in text:
total = (total * 33 + ord(character)) % 1000000007
return total
创建 process_export.py。在终端运行文件,避免直接在交互式Console定义worker函数。先用两个进程、小输入测量;小任务可能更慢,这是有效结果。
文件:process_export.py
import multiprocessing as mp
from concurrent.futures import ProcessPoolExecutor
from time import perf_counter
from cpu_work import score_text
def main():
texts = ["note export " * 100 for _ in range(4)]
started = perf_counter()
serial = [score_text(text, 2000) for text in texts]
print("serial", perf_counter() - started)
started = perf_counter()
with ProcessPoolExecutor(max_workers=2, mp_context=mp.get_context("spawn")) as pool:
parallel = list(pool.map(score_text, texts, [2000] * len(texts)))
print("process", perf_counter() - started)
assert serial == parallel
if __name__ == "__main__":
main()
把目标函数临时改为main内部的局部函数,观察序列化失败,然后恢复模块顶层定义。不要删除入口保护后在繁忙机器试验递归启动;用代码审查确认创建池只发生在受保护入口。
文件:process_notes_review.py
# 错误结构,只用于辨认,勿替换为实际入口:
# def main():
# def local_score(text):
# return len(text)
# pool.submit(local_score, "note")
# 正确结构:
from cpu_work import score_text
assert callable(score_text)
在独立调用侧捕获BrokenProcessPool并把整批视为失败。Future等待超时也不会自动结束已运行CPU函数;大工作应分块,控制单块长度,避免在API每次请求里创建一整池进程。
文件:verify_process.py
from concurrent.futures.process import BrokenProcessPool
from process_export import main
if __name__ == "__main__":
try:
main()
except BrokenProcessPool:
print("batch failed: worker process lost")
raise
笔记导出需要大量纯Python清洗或统计,阻塞API事件循环。
仅在隔离脚本把目标函数换为局部定义,确认序列化失败可见且不会产出成功结果。
原因:任务函数是闭包、lambda或局部定义。
处理:把函数移入可导入模块顶层,仅传可序列化参数。
原因:并发请求各自放大worker数量。
处理:使用受控的应用级池或后续独立Celery进程,限制整体并发。
把rounds分别设为10和2000,比较小任务与大任务的进程启动占比。
提示:两次保持输入、进程数与计时范围一致。
参考答案:
两个版本均先验证结果相同,再记录耗时。小任务可能明显更慢;只有在任务足够大时,多核收益才可能覆盖序列化与启动成本。
用同一导出需求比较执行位置,并确认何时需要 RQ、Dramatiq 或 Celery。
asyncio适合可等待I/O,线程承接阻塞I/O,进程承接需要隔离或多核的工作。它们本身不提供机器重启后的任务恢复。
RQ围绕Redis/Valkey队列和Python函数,Dramatiq围绕actor与中间件,Celery提供任务路由、重试、调度等能力。不同产品都需要明确版本、broker与故障交付语义。
主线采用Celery5.x、Redis7作为练习broker与结果缓存,PostgreSQL16保存任务真源。选择工具不能消除重复执行、外部副作用和数据库一致性问题。
建立decision.md,逐项填写现有notes-service的工作量,而不是只写“异步”。本例行数是练习限制,不是普遍容量建议。
文件:decision.md
| 工作 | 主要约束 | 起点 |
|---|---|---|
| 同一请求并发读20条笔记 | 可等待I/O、请求内完成 | asyncio + 有界队列 |
| 同步SDK上传一个导出文件 | 阻塞I/O、有超时 | 受限线程池 |
| 大批纯Python文本统计 | CPU、数据可分块 | 受限进程池 |
| 关闭API后仍需完成导出 | 持久状态、独立消费者 | 任务队列 + 状态表 |
先用json.dumps验证任务参数。工作函数接收job_id和小型快照;生产阶段只传job_id,从状态表读取不可变输入。不要把密码、请求对象或数据库连接放入消息。
文件:message_contract.py
import json
from uuid import uuid4
message = {
"job_id": str(uuid4()),
"rows": [{"id": 1, "title": "note", "content": "", "pinned": False}],
}
encoded = json.dumps(message, ensure_ascii=False).encode("utf-8")
assert len(encoded) < 64 * 1024
print(len(encoded))
阅读下方官方资料,记录所选版本的broker、worker启动方式、超时、重试和结果保留方式。只安装下一课Celery,不同时引入三套消费者。Celery5.x不把原生Windows作为支持平台;本模块worker统一在Linux或WSL2运行。
文件:decision.md
# 记录到decision.md:
# broker: Redis 7,仅本机隔离练习
# worker: Celery 5.x / Linux 或 WSL2
# durable status: PostgreSQL 16 export_jobs
# delivery: 允许重复,业务处理必须幂等
# result backend: 临时查询缓存,不能替代export_jobs
运行前几课的小脚本并记录:进程退出是否丢任务、CPU是否饱和、待处理数量是否有界。若要求跨重启恢复,不以继续增加线程数作为解决方案;从下一课分离producer与worker。
文件:终端 / 当前课时
python bounded_async.py
python pool_io.py
python process_export.py
python message_contract.py
从单机小导出演进到API与导出worker独立运行。
对比结束本地线程脚本与停止独立worker后的任务存留,用观察结果修订选型表。
原因:忽略ack、重投和外部副作用窗口。
处理:按允许重复设计,后续用幂等输入与状态CAS约束结果。
原因:缓存过期被误读为任务不存在。
处理:查询任务真源使用PostgreSQL,结果缓存只用于有限期辅助诊断。
给“同步SDK耗时3秒且必须跨API重启完成”选择方案。
提示:执行方式和交付方式可组合,并非只能选一个。
参考答案:
独立队列承诺持久受理与恢复;worker内部按SDK性质使用同步调用或受限线程。仍须设置SDK超时、幂等键和持久状态。
创建带认证的独立 Redis broker,把小型笔记快照发给 worker,再查询导出结果。
producer提交消息,broker暂存与转交消息,worker执行函数。result backend保存短期执行结果;它的可用性与保留时间不等于业务记录的完整性。
worker必须能导入celery_app和export_tasks。装饰器指定稳定任务名,producer与worker使用相同代码和兼容参数。
本课发送一条笔记快照并生成本地 JSON 文件,暂不读取 PostgreSQL。第 11 课改为从数据库读取任务快照。命令都在同一 Linux/WSL2 环境运行;宿主连接 broker 6384,Docker Compose 内连接 broker:6379,不能混用两种网络地址。
在 notes-service/tasks_lab 的 Python 3.11+ 虚拟环境执行。Celery worker 使用 Linux 或 WSL2,不以原生 Windows 为支持环境。先通过 docker version 确认当前环境能访问 Docker Engine;Windows 使用 Docker Desktop 的 WSL 集成。本课另建 Redis 7 broker,宿主端口 6384、容器端口 6379,与缓存练习的 6380 分开。端口或 notes-task-broker 容器名已被其他练习占用时,先核对所属用途,不删除未知容器。
文件:终端 / 当前课时
python -m pip install "celery[redis]>=5.5,<6" "psycopg[binary]>=3.2,<4" "python-dotenv>=1,<2"
docker version
docker ps --format 'table {{.Names}}\t{{.Ports}}'
python -m pip freeze > requirements-tasks-lock.txt
保存 prepare_broker.py,下一步运行。它生成 TASK_BROKER_PASSWORD、带认证的 Celery URL 和 Redis 配置,不打印凭据,也不会覆盖现有文件。broker-local 目录只允许当前宿主用户进入;配置文件本身可由容器内 Redis 用户读取。请在 WSL 的 Linux 文件系统中运行,避免 Windows 挂载盘权限语义差异。把 .env.tasks 与 broker-local/ 加入当前项目 .gitignore;不要提交、分享配置或把 URL 复制进日志。两个 Redis DB 只分离键空间,仍共享容量与可用性。
文件:prepare_broker.py
from pathlib import Path
import secrets
from urllib.parse import quote
folder = Path("broker-local")
env_file = Path(".env.tasks")
if env_file.exists() or folder.exists():
raise SystemExit("local broker configuration exists; reuse it")
folder.mkdir(mode=0o700)
password = secrets.token_urlsafe(24)
encoded = quote(password, safe="")
env_file.write_text(
f"TASK_BROKER_PASSWORD={password}\n"
f"CELERY_BROKER_URL=redis://:{encoded}@127.0.0.1:6384/0\n"
f"CELERY_RESULT_BACKEND=redis://:{encoded}@127.0.0.1:6384/1\n",
encoding="utf-8",
)
env_file.chmod(0o600)
conf = folder / "redis.conf"
conf.write_text(
"bind 0.0.0.0\nprotected-mode yes\nport 6379\n"
f"requirepass {password}\n"
"dir /data\nappendonly yes\nappendfsync everysec\n"
"maxmemory 256mb\nmaxmemory-policy noeviction\n",
encoding="utf-8",
)
conf.chmod(0o644)
ignore = Path(".gitignore")
existing = ignore.read_text(encoding="utf-8") if ignore.exists() else ""
with ignore.open("a", encoding="utf-8") as stream:
for rule in (".env.tasks", "broker-local/"):
if rule not in existing.splitlines():
stream.write("\n" + rule + "\n")
print("local broker files created; credentials were not printed")
先运行 python preparebroker.py,再执行以下命令。数据使用独立命名卷;Redis 配置只读挂载,本课不执行 CONFIG REWRITE。宿主映射限制为 127.0.0.1:6384。启动后用 Python 客户端读取 .env.tasks 并 PING,只输出 PONG。若连接仍指向旧端口,打开未设置旧 CELERY* 变量的新终端;load_dotenv 默认保留已注入环境变量。停止后保留容器可用 docker start notes-task-broker 恢复,不重复 docker run。
文件:终端 / 当前课时
python prepare_broker.py
docker run -d --name notes-task-broker \
-p 127.0.0.1:6384:6379 \
--mount type=bind,src="$PWD/broker-local/redis.conf",dst=/usr/local/etc/redis/redis.conf,readonly \
--mount type=volume,src=notes-task-broker-data,dst=/data \
redis:7 redis-server /usr/local/etc/redis/redis.conf
python - <<'PY'
import os
from dotenv import load_dotenv
from redis import Redis
load_dotenv(".env.tasks")
client = Redis.from_url(
os.environ["CELERY_BROKER_URL"],
socket_connect_timeout=2, socket_timeout=2,
)
try:
print("PONG" if client.ping() else "unexpected reply")
finally:
client.close()
PY
新建 celery_app.py,每次从 tasks_lab 目录运行时先加载本地 .env.tasks。部署环境已注入的变量优先,不会被文件覆盖。结果保留 1 小时;worker 只接受 JSON,消息内不传凭据。此处尚未启用 late ack,下一课演示交付窗口。
文件:celery_app.py
import os
from celery import Celery
from dotenv import load_dotenv
load_dotenv(".env.tasks")
app = Celery(
"notes_service",
broker=os.environ["CELERY_BROKER_URL"],
backend=os.environ["CELERY_RESULT_BACKEND"],
include=["export_tasks"],
)
app.conf.update(
task_serializer="json", result_serializer="json",
accept_content=["json"], result_expires=3600,
task_default_queue="exports", task_track_started=True,
broker_connection_timeout=3,
)
新建export_tasks.py。校验job_id,文件只写入当前练习目录的artifacts。另一个相同环境终端启动 python -m celery -A celery_app:app worker -l INFO -Q exports --concurrency=2。确认启动列表出现notes.export。
文件:export_tasks.py
import json
from pathlib import Path
from uuid import UUID
from celery_app import app
@app.task(name="notes.export")
def export_notes(job_id: str, rows: list[dict]):
safe_id = str(UUID(job_id))
folder = Path("artifacts")
folder.mkdir(exist_ok=True)
target = folder / f"{safe_id}.json"
target.write_text(
json.dumps(rows, ensure_ascii=False, sort_keys=True),
encoding="utf-8",
)
return {"job_id": safe_id, "path": str(target), "rows": len(rows)}
创建produce_export.py,在有相同环境变量的另一终端运行。get仅用于本地命令行验收,不放进FastAPI请求或Celery任务中同步等待。SUCCESS表示此任务执行结果已写缓存,文件仍位于worker本地路径。
文件:produce_export.py
from uuid import uuid4
from export_tasks import export_notes
job_id = str(uuid4())
rows = [{"id": 1, "title": "first", "content": "note body", "pinned": False}]
result = export_notes.apply_async(args=[job_id, rows], task_id=job_id)
print("submitted", result.id)
print(result.get(timeout=15))
print("state", result.state)
API提交导出后立即响应,独立worker完成文件生成。
仅停止本机练习worker,提交一条后恢复,观察受理、等待超时与最终完成的区别。
原因:worker未导入模块或producer任务名不一致。
处理:检查include、当前目录与notes.export注册名,更新代码后重启worker。
原因:结果backend对未知或已过期ID可能没有记录。
处理:PENDING不等于确定在排队;第11课使用状态表区分未知与已受理。
原因:宿主与容器网络地址混用、没有加载认证 URL,或旧环境变量覆盖 .env.tasks。
处理:宿主使用 127.0.0.1:6384;同一 Compose 网络内使用 broker:6379。确认当前目录、已注入环境和凭据来源,不把完整 URL 打印到日志。
先停止worker再提交一条,记录producer拿到ID但不能立刻完成,恢复worker后再查询。
提示:使用同一broker与队列,不把get的等待超时当作任务执行失败。
参考答案:
暂停期间消息可留在broker;producer等待可能超时,但恢复worker后任务仍可能成功。随后用相同ID核对结果与文件。
故意重复投递同一导出任务,验证稳定输入与输出标识如何限制重复副作用。
early ack可能在执行前确认;late ack在工作完成后确认,更便于恢复未确认消息,但完成副作用后、确认前崩溃仍可能重复。不要把任一配置称为exactly once。
可用且按要求配置的broker、未确认消息恢复和消费者重试共同支持至少一次交付目标。Celery即使late ack,部分worker子进程丢失场景默认仍确认;reject_on_worker_lost改变该行为,也可能制造毒消息循环。
同一job_id使用不可变快照和稳定输出名称,重复执行产生相同内容。本地原子替换只避免读到半文件,不解决多个版本竞争、断电持久性或跨机器文件一致性。
在celery_app.py追加配置并重启练习worker。此处最长任务应小于30秒,设置60秒仅便于本地恢复观察;真实配置需覆盖执行上界且考虑恢复等待。Redis的三处visibility_timeout统一。
文件:celery_app.py
app.conf.update(
task_acks_late=True,
task_reject_on_worker_lost=True,
worker_prefetch_multiplier=1,
broker_transport_options={"visibility_timeout": 60, "socket_timeout": 3, "socket_connect_timeout": 3},
result_backend_transport_options={"visibility_timeout": 60},
visibility_timeout=60,
)
用下面整体替换第08课export_tasks.py。每次执行使用自己的临时文件,最终名字只由job_id决定;输入仍是已固定的小快照。异常时清理本次临时文件,不删除其他任务的输出。
文件:export_tasks.py
import hashlib
import json
import os
from pathlib import Path
from uuid import UUID, uuid4
from celery_app import app
@app.task(name="notes.export")
def export_notes(job_id: str, rows: list[dict]):
safe_id = str(UUID(job_id))
payload = json.dumps(rows, ensure_ascii=False, sort_keys=True).encode("utf-8")
folder = Path("artifacts")
folder.mkdir(exist_ok=True)
target = folder / f"{safe_id}.json"
temporary = folder / f"{safe_id}.{uuid4()}.tmp"
try:
temporary.write_bytes(payload)
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
return {"job_id": safe_id, "path": str(target),
"sha256": hashlib.sha256(payload).hexdigest()}
在独立producer提交两次相同job_id和快照,但使用不同Celery消息ID,避免把共享结果缓存覆盖当作去重。应比较逻辑输出路径与摘要;Celery task_id相同本身也不是业务去重保证。
文件:duplicate_export.py
from uuid import uuid4
from export_tasks import export_notes
job_id = str(uuid4())
rows = [{"id": 1, "title": "fixed snapshot", "content": "", "pinned": False}]
first = export_notes.apply_async(args=[job_id, rows], task_id=str(uuid4()))
second = export_notes.apply_async(args=[job_id, rows], task_id=str(uuid4()))
a, b = first.get(timeout=15), second.get(timeout=15)
assert a["path"] == b["path"]
assert a["sha256"] == b["sha256"]
print("same logical output", a)
在本地任务里临时于os.replace后增加5秒sleep,记录执行worker子进程PID,只终止该隔离进程以模拟完成后未确认。重投时再次进入任务;观察日志、最终文件和摘要,恢复后删除故障代码。不要给永久崩溃消息无限恢复机会,后续用持久attempts和死信限制。
文件:终端 / 当前课时
python -m celery -A celery_app:app inspect active
python -m celery -A celery_app:app inspect reserved
# 只对已确认属于本次隔离任务的worker子进程做故障注入。
# 使用日志与inspect中的任务ID核对,不按模糊进程名批量终止。
导出文件已生成,worker在发送确认前崩溃。
重复消息确定性验收后,再在隔离worker完成输出到ack之间终止执行进程,观察重复进入而非假定只执行一次。
原因:消息ID或结果缓存键不提供业务级只执行一次保证。
处理:用不可变输入、稳定业务键和后续数据库CAS保护最终结果。
原因:reject_on_worker_lost重排同一毒消息,没有持久尝试预算。
处理:限制持久attempts,把不可恢复输入转失败或死信,修复后受控重放。
给第二次投递同一job_id却不同title,观察原子替换无法保证业务版本正确。
提示:本实验故意违反输入不可变约束,只使用新建隔离job_id。
参考答案:
最终文件取决于最后完成者,不能靠os.replace解决。第11课由状态表固定快照,消息仅传job_id,并用租约token防旧执行者覆盖结果引用。
只重试暂时性错误,在每次执行和下一次等待前检查截止时间。
连接暂不可用、受控限流等可以重试;输入校验失败、无权访问通常直接失败。重试范围应围绕幂等的最小操作,不应重复整个含不可逆副作用的业务流程。
max_retries只限制retry次数,不限制首次排队、每次执行和等待的总耗时。deadline_at随业务任务持久保存;跨worker用明确时间基准,本地耗时测量使用monotonic。
等待上限随尝试次数增长,再在区间内随机选择等待时间。长远调度不宜大量使用Redis broker的ETA/countdown,避免消息占用worker内存及可见性时间导致重投。
新建retry_tasks.py,并把该模块加入celery_app.py的include列表。任务只模拟读取,不写文件,便于观察重试而不重复副作用。fail_until只用于本地演练,不对外开放。
文件:retry_tasks.py
import random
import time
from celery_app import app
class TemporaryReadError(Exception):
pass
def fetch_snapshot(attempt: int, fail_until: int):
if attempt < fail_until:
raise TemporaryReadError("temporary source failure")
return [{"id": 1, "title": "snapshot"}]
追加任务。deadline_epoch由producer确定并原样传递。进入执行前和计划下次重试前都检查;每次真实I/O还须设置不超过remaining的超时。手动retry会抛出Retry用于通知Celery,使用raise保持控制流。
文件:retry_tasks.py
@app.task(bind=True, name="notes.retry_demo", max_retries=3)
def retry_export(self, job_id: str, deadline_epoch: float, fail_until: int = 2):
remaining = deadline_epoch - time.time()
if remaining <= 0:
raise RuntimeError("export deadline exceeded")
try:
rows = fetch_snapshot(self.request.retries, fail_until)
return {"job_id": job_id, "rows": len(rows)}
except TemporaryReadError as exc:
if self.request.retries >= self.max_retries:
raise
cap = min(8.0, 2.0 ** self.request.retries)
delay = random.uniform(0, cap)
if time.time() + delay + 0.1 >= deadline_epoch:
raise RuntimeError("insufficient retry budget") from exc
raise self.retry(exc=exc, countdown=delay)
重启worker加载模块;在独立producer运行两次。第一次前两次失败后成功;第二次预算已经过期,应在调用外部读取前失败。get只用于本地命令行,异常时记录类型。
文件:verify_retry.py
import time
from uuid import uuid4
from retry_tasks import retry_export
ok = retry_export.delay(str(uuid4()), time.time() + 30, 2)
print(ok.get(timeout=35))
expired = retry_export.delay(str(uuid4()), time.time() - 1, 0)
try:
expired.get(timeout=10)
except Exception as exc:
print(type(exc).__name__, str(exc))
临时把fetch_snapshot改成raise ValueError,确认不会进入TemporaryReadError重试分支。再将fail_until设置10,观察最多初次加3次重试。记录开始时间、重试次数、等待区间和终态;恢复实验函数。
文件:retry_tasks.py
# 永久错误实验:
# raise ValueError("invalid export request")
# 次数预算实验:
# result = retry_export.delay(str(uuid4()), time.time() + 60, 10)
# 最多4次执行:首次 + 3次重试。
导出依赖短暂不可用的笔记存储,用户只接受10分钟内完成。
对本地函数分别注入可恢复错误、永久错误、过期deadline,记录三种重试轨迹。
原因:总预算实际永远不会耗尽。
处理:deadline随原始任务保存,重试传递原值,不在worker重新计算。
原因:语法、校验或权限错误变成无用流量。
处理:明确可重试异常集合,永久故障直接进入失败处理。
把预算设为0.05秒,说明为什么次数预算尚余也可能直接失败。
提示:执行机会必须同时满足次数和时间条件。
参考答案:
remaining已不足下一次等待加最小操作时间,因此应结束;剩余retry次数不是必须消耗的额度。
固定导出快照,用持久领取时间与租约控制执行,阻止过期执行者发布结果。
PostgreSQL保存输入快照、状态、尝试数、截止时间和结果引用。Redis结果过期不改变业务任务是否存在。此后Celery消息只传job_id,不再传第08课rows参数。
领取只允许queued且next_attempt_at已到的任务,一次UPDATE原子生成lease_token并增加attempts。完成同时检查running、token、租约、deadline和取消标志。过期running不能直接被消息抢走,必须经过第15课恢复扫描重新排队并设置退避。
读取快照和领取状态分别在短事务中完成,文件生成放在事务外。不要持有数据库行锁等待长await、网络上传或CPU计算。租约是有时限的权属,不是自动杀死旧worker。
在已有本机PostgreSQL16的notes_lab库执行SQL,账户app_user,端口5433。DATABASE_URL由终端环境设置,密码使用本地练习凭据。notes表需已有id/title/content/pinned;本课不覆盖原笔记表。 next_attempt_at决定最早可领取时间,last_dispatched_at记录最新一次被publisher确认的派发时间;这不等于worker已经领取或消息已经永久持久化。
文件:schema_jobs.sql
CREATE TABLE IF NOT EXISTS export_jobs (
id uuid PRIMARY KEY,
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
payload jsonb NOT NULL,
attempts integer NOT NULL DEFAULT 0,
lease_token uuid,
lease_until timestamptz,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_dispatched_at timestamptz,
cancel_requested boolean NOT NULL DEFAULT false,
deadline_at timestamptz NOT NULL DEFAULT now() + interval '10 minutes',
result_ref text,
error_code text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS export_jobs_status_created
ON export_jobs(status, created_at);
-- 兼容已按旧版课件建立的本机练习表:
ALTER TABLE export_jobs ADD COLUMN IF NOT EXISTS next_attempt_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE export_jobs ADD COLUMN IF NOT EXISTS last_dispatched_at timestamptz;
创建state_store.py。设置DATABASE_URL指向postgresql://app_user:本地密码@127.0.0.1:5433/notes_lab;WSL2必须能访问该地址。限制100条,读取后保存JSON快照,后续修改笔记不会改变已受理的导出输入。 connect设置连接超时3秒、单条SQL超时3秒、锁等待1秒,用于本机小数据实验;连接建立、语句与事务总时间不是同一预算。生产根据服务SLO和批次大小调整,维护进程每批之间检查停机信号,不能依赖无限等待的数据库调用。
文件:state_store.py
import os
from uuid import uuid4
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
def connect():
return psycopg.connect(
os.environ["DATABASE_URL"],row_factory=dict_row,connect_timeout=3,
options="-c statement_timeout=3000 -c lock_timeout=1000",
)
def submit_snapshot(note_ids: list[int]) -> str:
ids = sorted(set(note_ids))
if not ids or len(ids) > 100:
raise ValueError("select 1..100 notes")
job_id = str(uuid4())
with connect() as conn:
rows = conn.execute(
"SELECT id,title,content,pinned FROM notes WHERE id=ANY(%s) ORDER BY id",
(ids,),
).fetchall()
if len(rows) != len(ids):
raise ValueError("one or more notes do not exist")
conn.execute(
"INSERT INTO export_jobs(id,payload) VALUES (%s,%s)",
(job_id, Jsonb(rows)),
)
return job_id
追加两个函数。领取事务提交后才开始工作,返回的token用于完成校验。30秒是本地小文件租约;真实大导出需分块并续租,续租失败立即停止后续发布。 running租约过期后claim仍返回None;第15课扫描器负责queued转换,避免broker重复消息绕过持久退避时间。成功领取和完成清除当前error_code,历史错误由阶段日志保留。
文件:state_store.py
def claim(job_id: str):
token = str(uuid4())
with connect() as conn:
return conn.execute("""
UPDATE export_jobs SET status='running', attempts=attempts+1,
lease_token=%s, lease_until=now()+interval '30 seconds',
error_code=NULL, updated_at=now()
WHERE id=%s AND cancel_requested=false AND attempts<3
AND deadline_at>now()
AND next_attempt_at<=now() AND status='queued'
RETURNING id,payload,lease_token,attempts
""", (token, job_id)).fetchone()
def finish(job_id: str, token: str, result_ref: str) -> bool:
with connect() as conn:
row = conn.execute("""
UPDATE export_jobs SET status='succeeded', result_ref=%s, error_code=NULL, updated_at=now()
WHERE id=%s AND status='running' AND lease_token=%s
AND lease_until>now() AND deadline_at>now() AND cancel_requested=false
RETURNING id
""", (result_ref, job_id, token)).fetchone()
return row is not None
整体替换export_tasks.py并重启worker,停止使用第08–09课携带rows的producer。每次领取写独立token文件,最终有效文件由数据库result_ref决定;旧worker最多留下未引用文件,不覆盖新结果。异常由Celery记录,业务状态暂留租约,恢复扫描在第15课补齐。不要把第10课retry直接套在未定义状态转换上。
文件:export_tasks.py
import json
from pathlib import Path
from celery_app import app
from state_store import claim, finish
@app.task(name="notes.export")
def export_notes(job_id: str):
job = claim(job_id)
if job is None:
return {"job_id": job_id, "claimed": False}
token = str(job["lease_token"])
folder = Path("artifacts")
folder.mkdir(exist_ok=True)
target = folder / f"{job['id']}.{token}.json"
target.write_text(json.dumps(job["payload"], ensure_ascii=False), encoding="utf-8")
published = finish(job_id, token, str(target))
return {"job_id": job_id, "published": published}
使用真实已存在的笔记ID执行submit_snapshot,再export_notes.delay(job_id)。查询只读状态表。当前提交后发送仍有双写窗口,下一课用outbox解决。接下来的专用租约实验不发送Celery消息,通过明确的一条记录模拟恢复转换。
文件:submit_db_export.py
from state_store import submit_snapshot, connect
from export_tasks import export_notes
job_id = submit_snapshot([1])
export_notes.delay(job_id)
with connect() as conn:
print(conn.execute(
"SELECT id,status,attempts,result_ref FROM export_jobs WHERE id=%s",
(job_id,),
).fetchone())
# HTTP查询接口应读取同一记录;无记录返回404。
# 本地故障注入只对刚创建的job_id修改租约,勿扫描修改其他任务。
使用已有笔记编号作为输入;下面通过最小编号选取样本,不假定id=1存在。只修改刚创建的job_id。过期running无法自动领取,实验显式模拟第15课的恢复转换;attempts不清零,随后旧token仍不能完成,新token生成真实文件后才发布。
文件:verify_lease.py
import json
from pathlib import Path
from state_store import submit_snapshot, connect, claim, finish
with connect() as conn:
note_id = conn.execute("SELECT min(id) AS id FROM notes").fetchone()["id"]
assert note_id is not None, "create a note first"
job_id = submit_snapshot([note_id])
old = claim(job_id)
assert old is not None and claim(job_id) is None
with connect() as conn:
conn.execute("UPDATE export_jobs SET lease_until=now()-interval '1 second' WHERE id=%s", (job_id,))
assert claim(job_id) is None
assert finish(job_id, str(old["lease_token"]), "unused") is False
with connect() as conn:
conn.execute("""
UPDATE export_jobs SET status='queued',lease_token=NULL,lease_until=NULL,
next_attempt_at=now(),updated_at=now()
WHERE id=%s AND status='running' AND lease_until<now()
""", (job_id,))
new = claim(job_id)
assert new is not None and new["attempts"] == 2
assert finish(job_id, str(old["lease_token"]), "unused") is False
folder = Path("artifacts")
folder.mkdir(exist_ok=True)
target = folder / f"{job_id}.{new['lease_token']}.json"
target.write_text(json.dumps(new["payload"],ensure_ascii=False),encoding="utf-8")
assert finish(job_id,str(new["lease_token"]),str(target)) is True
print("lease ownership verified",job_id)
多个worker可能收到同一个导出job,旧worker在暂停后重新运行。
对专用任务令租约过期,先确认claim和旧token完成均失败,再模拟恢复为queued并重新领取,比较两次token与result_ref。
原因:完成时只按job_id更新,没有检查token或输出路径复用。
处理:文件名带token,结果发布使用状态、token和租约条件CAS。
原因:消息可能没有领取权,或者完成CAS失败;执行结果不等于业务终态。
处理:用户查询只认export_jobs,记录claimed/published用于诊断。
实现续租函数:仅当token匹配、status=running、当前租约未过期且未取消时延长30秒。
提示:过期租约不能自己复活,需要重新领取获取新token。
参考答案:
UPDATE export_jobs SET lease_until=now()+interval '30 seconds' WHERE id=%s AND lease_token=%s AND status='running' AND lease_until>now() AND deadline_at>now() AND cancel_requested=false RETURNING id;无返回记录即失去权属。
把导出记录与待发布事件写入同一数据库事务,并处理发布成功但确认写回失败的重复。
先写数据库后发送消息,可能留下永不入队的queued任务;先发送后提交数据库,worker可能读不到记录。outbox把业务记录和发送意图放进同一数据库事务。
发布成功后进程可能在写sent_at之前崩溃,事件会被再次发布。consumer继续依赖第11课的领取和结果CAS,不能因为用了outbox就移除幂等逻辑。
先短事务领取outbox事件,提交后访问broker,最后短事务按claim_token标记sent。租约过期允许其他publisher接手;SKIP LOCKED用于队列式领取,不用于普通业务一致性查询。
在同一notes_lab数据库执行。外键保证job存在;同一个job可以有后续重放事件,因此不对job_id单独加唯一约束。事件ID与业务job_id分开记录。
文件:schema_outbox.sql
CREATE TABLE IF NOT EXISTS export_outbox (
id uuid PRIMARY KEY,
job_id uuid NOT NULL REFERENCES export_jobs(id),
created_at timestamptz NOT NULL DEFAULT now(),
available_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz,
claim_token uuid,
claim_until timestamptz,
attempts integer NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS export_outbox_pending
ON export_outbox(available_at, created_at) WHERE sent_at IS NULL;
在state_store.py的submit_snapshot里,把下面INSERT放到原有with connect()事务中、export_jobs INSERT之后。删除producer中直接export_notes.delay的调用。HTTP202只能在数据库事务提交后返回,暂时broker不可用仍可保留待发布事件。
文件:submit_outbox_export.py
# 放在submit_snapshot的同一个with connect() as conn内:
# conn.execute(
# "INSERT INTO export_outbox(id,job_id) VALUES (%s,%s)",
# (str(uuid4()), job_id),
# )
# 提交侧现在只调用:
from state_store import submit_snapshot
job_id = submit_snapshot([1])
print("accepted", job_id)
创建publisher.py。CTE锁定一行并更新领取token,连接上下文退出后才发消息。该函数一次处理一条,便于观察;循环调用时需停机信号、空队列等待与有限批量。
文件:publisher.py
from uuid import uuid4
from state_store import connect
from celery_app import app
def claim_event():
token = str(uuid4())
with connect() as conn:
return conn.execute("""
WITH candidate AS (
SELECT id FROM export_outbox
WHERE sent_at IS NULL AND available_at<=now()
AND (claim_until IS NULL OR claim_until<now())
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1
)
UPDATE export_outbox o SET claim_token=%s,
claim_until=now()+interval '30 seconds', attempts=attempts+1
FROM candidate c WHERE o.id=c.id
RETURNING o.id,o.job_id,o.claim_token
""", (token,)).fetchone()
追加并执行python publisher.py。消息只带job_id。broker调用不包在数据库事务内;失败时不标sent,等待领取租约到期后可再次尝试。生产还应限制发布重试总量并报警,不能无日志地永久积压。
文件:publisher.py
def publish_one():
event = claim_event()
if event is None:
return False
app.send_task(
"notes.export", args=[str(event["job_id"])],
task_id=str(event["id"]), queue="exports",
retry=False,
)
with connect() as conn:
conn.execute("""
UPDATE export_outbox SET sent_at=now(), claim_until=NULL
WHERE id=%s AND claim_token=%s AND sent_at IS NULL
""", (event["id"], event["claim_token"]))
return True
if __name__ == "__main__":
print("published", publish_one())
只在本地publisher临时把发送之后的数据库更新跳过一次。等待该事件租约到期再执行publisher,它会再次发送;检查export_jobs只有一个有效结果引用。恢复更新代码;另试停止broker后提交,确认任务和outbox同时存在。
文件:终端 / 当前课时
SELECT j.id, j.status, j.result_ref, o.id AS event_id, o.attempts, o.sent_at
FROM export_jobs j JOIN export_outbox o ON o.job_id=j.id
ORDER BY o.created_at DESC LIMIT 10;
数据库正常、Redis暂不可用,API不能受理后永久漏发任务。
分别模拟事务回滚、broker不可用、发送成功但sent_at未写回,检查三种窗口的恢复结果。
原因:先标记再发送,或把异常忽略。
处理:发送成功后再条件更新sent_at,失败保留待处理事件。
原因:把broker发布放在FOR UPDATE事务内部。
处理:分成领取、网络发送、确认三个阶段,用领取token防旧publisher误确认。
在导出记录INSERT后、outbox INSERT前抛异常,验证两个表都没有这次提交的记录。
提示:使用同一个连接事务,让异常退出with触发回滚。
参考答案:
保存本次job_id进行查询;两表计数都应为0。若只回滚一张表,说明事务边界没有统一。
在数据库事务中控制待处理容量,用独立worker减少大导出对小任务的影响。
系统吞吐还受数据库连接、存储I/O和CPU限制。到达速度长期高于完成速度时,队列持续增长,必须在入口拒绝、等待或降级。
concurrency控制同时执行,rate limit控制时间窗口速率;Celery任务rate_limit通常按worker实例生效,不等于全局额度。全局限制应在共享状态或独立入口实现。
给fast和bulk命名不会自动隔离资源;分别启动受限worker,并配置CPU、连接等预算。prefetch降低预取囤积,但不能代替入队容量限制。
在state_store.py添加admit_export,并在submit_snapshot的with connect()内、INSERT之前调用。所有提交路径必须使用同一检查,包括之后定时任务和重放。该本地全局锁只持有短事务;多租户系统可设计分租户额度。
文件:state_store.py
class CapacityExceeded(Exception):
pass
def admit_export(conn, limit: int = 100):
conn.execute("SELECT pg_advisory_xact_lock(831701)")
row = conn.execute("""
SELECT count(*) AS total FROM export_jobs
WHERE status IN ('queued','running')
""").fetchone()
if row["total"] >= limit:
raise CapacityExceeded("export capacity reached")
# submit_snapshot内部:
# with connect() as conn:
# admit_export(conn)
# ...读取快照、INSERT job、INSERT outbox...
执行SQL并在提交outbox时根据快照条数选择队列。本例≤30条走fast,其余走bulk。修改publisher的RETURNING包含queue_name,send_task的queue改为event['queue_name'];不要仅修改任务装饰器却保留显式旧queue。
文件:schema_capacity.sql
ALTER TABLE export_outbox
ADD COLUMN IF NOT EXISTS queue_name text NOT NULL DEFAULT 'exports.bulk'
CHECK (queue_name IN ('exports.fast','exports.bulk'));
-- 在提交事务中使用参数化INSERT:
-- INSERT INTO export_outbox(id,job_id,queue_name) VALUES (%s,%s,%s);
-- Python: queue_name = "exports.fast" if len(rows) <= 30 else "exports.bulk"
先完成第12课旧exports队列中的实验任务,再停旧worker。两个命令分别放在终端运行,各一个执行进程;worker_prefetch_multiplier=1继续保留。正常发布的事件必须进入其中一条被消费的队列。
文件:终端 / 当前课时
# Linux / WSL2,终端A
python -m celery -A celery_app:app worker -l INFO -Q exports.fast --concurrency=1 -n fast@%h
# Linux / WSL2,终端B
python -m celery -A celery_app:app worker -l INFO -Q exports.bulk --concurrency=1 -n bulk@%h
暂停本地消费者后少量提交,逐渐达到本课100条容量;CapacityExceeded应由API转换为503并附Retry-After,而不是继续受理。先统计之前练习遗留的queued/running,不能假定数据库为空。恢复worker和publisher后观察最老等待下降。
文件:终端 / 当前课时
SELECT status, count(*) AS jobs,
extract(epoch FROM now()-min(created_at)) AS oldest_age_seconds
FROM export_jobs
WHERE status IN ('queued','running')
GROUP BY status;
SELECT queue_name, count(*) AS unpublished,
extract(epoch FROM now()-min(created_at)) AS oldest_unpublished_seconds
FROM export_outbox WHERE sent_at IS NULL GROUP BY queue_name;
将CapacityExceeded映射放在现有提交接口,不创建第二个状态真源。不同用户配额用429或业务约定,整个服务容量不足本课使用503。数据库事务回滚后不得返回202和虚假job_id。
文件:admission_adapter.py
from fastapi import HTTPException
from state_store import CapacityExceeded, submit_snapshot
def accept_export(note_ids: list[int]):
try:
return {"job_id": submit_snapshot(note_ids)}
except CapacityExceeded as exc:
raise HTTPException(
503, "导出容量暂满,请稍后重试",
headers={"Retry-After": "5"},
) from exc
大批量导出占满worker,小型用户导出等待过久。
仅暂停本地bulk消费者,提交大小两类任务,观察fast仍处理;随后压到小容量上限确认拒绝。
原因:所有队列仍由同一批已被长任务占满的worker处理。
处理:为关键小任务保留独立消费者和资源预算。
原因:多个producer并发读取相同count,检查与INSERT不在同一串行化范围。
处理:同一事务获取约定锁,检查后写job与outbox,所有入口遵守。
将本地受理上限改为5,用两个producer同时提交,核对queued/running总数没有超过5。
提示:先处理或隔离此前活动任务,再做小容量实验。
参考答案:
超过容量的事务抛CapacityExceeded并回滚;只有实际写入状态表和outbox的请求能返回202。
Celery beat负责唤醒,数据库唯一键合并同一执行分钟;跨分钟重投和停机补跑明确作为不同问题验收。
本课按worker实际开始处理时的UTC分钟生成slot_key,数据库唯一约束保证同一槽只创建一个job。这是按执行分钟合并,不是按原始beat信号去重:同一消息如果跨分钟重投,会落到不同槽并可能新建任务。
本地例子用worker处理信号时的UTC分钟作为slot_key,同一分钟最多创建一份快照。它不补齐停机错过的分钟;需要严格补跑时,应由持久调度表保存due_at与补跑规则。
同一时间槽不重复创建,并不阻止不同时间槽的大导出重叠。复用任务领取租约,并按业务决定跳过、合并或限制同一导出类型的并发。
若业务要求每个计划分钟最多一次且停机后补跑,应在数据库保存schedule_id、next_due_at和唯一(schedule_id,scheduled_at)槽。调度事务锁定配置、按有限批次推进游标并写job/outbox;worker消费持久scheduled_at,不能再用执行时now猜测原计划。本课保留较小的执行分钟模型,下面用实际两次调用证明边界。
执行SQL。外键延迟到事务提交时检查,允许先争取slot_key,再插入对应job。冲突意味着该时间槽已有任务,不能再创建第二条。
文件:schema_schedule.sql
CREATE TABLE IF NOT EXISTS export_schedule_slots (
slot_key text PRIMARY KEY,
job_id uuid NOT NULL UNIQUE REFERENCES export_jobs(id)
DEFERRABLE INITIALLY DEFERRED,
created_at timestamptz NOT NULL DEFAULT now()
);
新建schedule_store.py。复用connect、admit_export;生产参数应包含固定租户和授权范围,本地只取给定笔记。没有长任务或broker调用进入这个事务。
文件:schedule_store.py
from uuid import uuid4
from psycopg.types.json import Jsonb
from state_store import connect, admit_export
def schedule_once(slot_key: str, note_ids: list[int]):
ids = sorted(set(note_ids))
if not ids or len(ids) > 100:
raise ValueError("select 1..100 notes")
job_id = str(uuid4())
with connect() as conn:
won = conn.execute("""
INSERT INTO export_schedule_slots(slot_key,job_id) VALUES (%s,%s)
ON CONFLICT(slot_key) DO NOTHING RETURNING slot_key
""", (slot_key, job_id)).fetchone()
if won is None:
return None
admit_export(conn)
rows = conn.execute(
"SELECT id,title,content,pinned FROM notes WHERE id=ANY(%s) ORDER BY id",
(ids,),
).fetchall()
if len(rows) != len(ids):
raise ValueError("one or more scheduled notes do not exist")
conn.execute("INSERT INTO export_jobs(id,payload) VALUES (%s,%s)",
(job_id, Jsonb(rows)))
conn.execute("""
INSERT INTO export_outbox(id,job_id,queue_name) VALUES (%s,%s,%s)
""", (str(uuid4()), job_id, "exports.bulk"))
return job_id
新建schedule_tasks.py;将模块加入Celery include并重启worker。把下方app.conf部分放入celery_app.py。此信号使用fast队列,实际导出由outbox进入bulk;继续运行publisher才能把事件变成消息。 execution_slot接受显式时间,便于下一个实验固定跨分钟时序;正式tick仍使用开始执行时的now,因此不能宣称保留原beat计划时间。
文件:schedule_tasks.py
from datetime import datetime, timezone
from celery_app import app
from state_store import connect
from schedule_store import schedule_once
def execution_slot(when: datetime) -> str:
if when.tzinfo is None:
raise ValueError("timezone-aware timestamp required")
return when.astimezone(timezone.utc).strftime("notes-export:%Y%m%dT%H%M")
@app.task(name="notes.schedule_tick")
def schedule_tick():
slot = execution_slot(datetime.now(timezone.utc))
with connect() as conn:
rows = conn.execute("SELECT id FROM notes ORDER BY id LIMIT 100").fetchall()
ids = [row["id"] for row in rows]
return schedule_once(slot, ids) if ids else None
# 以下配置写入celery_app.py:
# app.conf.timezone = "UTC"
# app.conf.beat_schedule = {
# "export-each-minute": {
# "task": "notes.schedule_tick", "schedule": 60.0,
# "options": {"queue": "exports.fast"},
# }
# }
Linux/WSL2单独终端执行命令,启动文件只给本次beat使用。不要为了高可用复制同一个本地schedule文件。用schedule_once对同一个测试slot连续调用两次,第一次返回job_id,第二次None。
文件:终端 / 当前课时
python -m celery -A celery_app:app beat -l INFO --schedule=./beat-lab.db
# 另一个终端在Python里测试同槽去重:
# from schedule_store import schedule_once
# print(schedule_once("drill:unique-slot-01", [1]))
# print(schedule_once("drill:unique-slot-01", [1]))
本实验直接调用同一slot生成逻辑,并注入两个明确的UTC时间,不必等待真实分钟切换。每次生成新的drill前缀避免与真实调度或此前实验混用。相同delivery_label表示模拟同一信号;两次不同分钟仍会创建两个job,结果是当前模型的边界,不应标成原消息exactly-once。
文件:verify_schedule_slots.py
from datetime import datetime, timezone
from uuid import uuid4
from state_store import connect
from schedule_store import schedule_once
from schedule_tasks import execution_slot
with connect() as conn:
note_id = conn.execute("SELECT min(id) AS id FROM notes").fetchone()["id"]
assert note_id is not None
prefix = f"drill:{uuid4()}:"
delivery_label = "same-redelivered-signal"
t1 = datetime(2026,1,1,10,0,59,tzinfo=timezone.utc)
t2 = datetime(2026,1,1,10,1,1,tzinfo=timezone.utc)
first = schedule_once(prefix+execution_slot(t1),[note_id])
same_slot = schedule_once(prefix+execution_slot(t1),[note_id])
later_slot = schedule_once(prefix+execution_slot(t2),[note_id])
assert first is not None and same_slot is None
assert later_slot is not None and later_slot != first
print(delivery_label,{"first":first,"same_slot":same_slot,"later_slot":later_slot})
# 此处会创建两条真实本地job与outbox事件;按正常publisher/worker流程处理。
停止本地beat两分钟再启动,查询时间槽表。本例不会自动补发停机期间每一分钟的导出;明确记录这一行为。若需要补跑,先设计持久due_at游标和补跑容量,再修改任务。
文件:终端 / 当前课时
SELECT slot_key,job_id,created_at
FROM export_schedule_slots ORDER BY created_at DESC LIMIT 10;
每分钟为笔记生成一份小快照,调度进程偶尔重启。
依次验证同槽重复调用、同一模拟信号跨分钟调用、beat停机两分钟三种情形;当前模型分别得到合并、新槽、无自动补跑。
原因:多个调度器同时发送同一计划。
处理:常态运行单beat,数据库slot唯一约束兜住重复业务创建。
原因:上一分钟任务未完成,下一分钟仍会生成新任务。
处理:按业务增加导出类型并发限制或跳过策略,保留任务执行租约。
用两个producer并发调用同一全新slot_key,确认只有一个返回job_id。
提示:唯一约束需要在数据库事务中争取,不能先SELECT判断再INSERT。
参考答案:
一方成功创建job和outbox,另一方ON CONFLICT返回None。最终一个slot、一条业务任务;数据库事务保证外键成立。
按持久退避恢复过期租约,重新派发久未领取的queued任务,并保留取消、失败与人工重放记录。
取消标志表示用户请求停止,不能证明线程、子进程或外部写入已经停止。执行者在分块边界检查权属与取消,发布结果前再次CAS;不要把terminate当普通业务取消。
Celery worker收到TERM进行warm shutdown,等待正在运行工作;强制终止不能保证finally执行。退出宽限期要覆盖有界单次任务,恢复依赖状态租约和未确认消息机制。
Redis broker没有自动替本模块建立业务死信流程。本课用export_dead_letters保存尝试耗尽或过期任务,原任务保持failed;修复后创建新job重放,保留原始失败证据。
publisher收到发送成功后,消息仍可能在broker故障中丢失;worker也可能在claim之前因数据库连接错误退出。两者都可能留下queued和已sent事件。恢复扫描还要查久未领取、没有未发送事件的queued任务,原子新建outbox。重复派发允许存在,数据库claim决定谁能执行。
请求取消只修改活跃状态记录,终态不回退。取消路由仍应复用notes-service身份与任务归属校验。运行中的文件操作可能继续片刻,但第11课finish必须检查取消和deadline。
文件:schema_recovery.sql
CREATE TABLE IF NOT EXISTS export_dead_letters (
job_id uuid PRIMARY KEY REFERENCES export_jobs(id),
reason text NOT NULL,
quarantined_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE export_jobs ADD COLUMN IF NOT EXISTS replay_of uuid REFERENCES export_jobs(id);
-- 用参数化语句处理一个已授权的job_id:
-- UPDATE export_jobs SET cancel_requested=true, updated_at=now()
-- WHERE id=%s AND status IN ('queued','running') RETURNING id,status;
把can_continue追加到state_store.py,在生成每个数据块前调用,返回False就停止后续工作。小文件实验可在write_text前检查;完成CAS仍不可省略。有效租约也必须处于业务deadline以内。
文件:state_store.py
def can_continue(job_id: str, token: str) -> bool:
with connect() as conn:
row = conn.execute("""
SELECT id FROM export_jobs
WHERE id=%s AND lease_token=%s AND status='running'
AND lease_until>now() AND deadline_at>now()
AND cancel_requested=false
""", (job_id, token)).fetchone()
return row is not None
# export_tasks.py生成文件前:
# if not can_continue(job_id, token):
# return {"job_id": job_id, "published": False}
替换publisher.py中的publish_one,保留原claim_event,并确认第13课已让RETURNING包含queue_name。事件确认与last_dispatched_at在同一短事务中写入,网络发送仍在事务外。UPDATE未匹配token时不写job时间,旧publisher不能延迟新一轮恢复判断。last_dispatched_at只表示发送响应已确认,不保证消息永不丢失。
文件:publisher.py
def publish_one():
event = claim_event()
if event is None:
return False
app.send_task(
"notes.export",args=[str(event["job_id"])],
task_id=str(event["id"]),queue=event["queue_name"],retry=False,
)
with connect() as conn:
confirmed = conn.execute("""
UPDATE export_outbox SET sent_at=now(),claim_until=NULL
WHERE id=%s AND claim_token=%s AND sent_at IS NULL
RETURNING job_id
""",(event["id"],event["claim_token"])).fetchone()
if confirmed is not None:
conn.execute("UPDATE export_jobs SET last_dispatched_at=now() WHERE id=%s",
(confirmed["job_id"],))
return confirmed is not None
新建recovery.py。每次最多50条;锁内只做数据库操作。running租约过期后设next_attempt_at=10秒后,outbox同时使用该时间;所有claim都检查此字段。queued超过最近一次确认派发60秒仍未领取、且不存在待发送事件时重新派发,解决消息丢失或claim前失败。旧queued缺last_dispatched_at时兼容读取已有sent_at。60秒是本机观察阈值,生产应结合队列正常等待SLO设置,过短会制造重复消息。 无消费能力时最多按阈值重新派发直到业务deadline,派发不增加执行attempts;attempts只在claim成功时增加。仍有未发送outbox时由publisher租约重试,扫描器不重复堆新事件。课件一次性命令只处理一批;部署时应以监督进程或调度器每5秒调用扫描器并持续运行publisher,空批次等待、异常告警、关闭时停止取新工作。
文件:recovery.py
from uuid import uuid4
from state_store import connect
def recover_batch():
with connect() as conn:
rows = conn.execute("""
SELECT j.*,j.deadline_at<=now() AS expired
FROM export_jobs j
WHERE (j.status='running' AND j.lease_until<now())
OR (j.status='queued' AND
(j.cancel_requested OR j.deadline_at<=now() OR j.attempts>=3))
OR (j.status='queued' AND j.next_attempt_at<=now()
AND j.cancel_requested=false AND j.deadline_at>now() AND j.attempts<3
AND NOT EXISTS (
SELECT 1 FROM export_outbox pending
WHERE pending.job_id=j.id AND pending.sent_at IS NULL)
AND GREATEST(j.updated_at,j.next_attempt_at,
COALESCE(j.last_dispatched_at,
(SELECT max(o.sent_at) FROM export_outbox o WHERE o.job_id=j.id),
j.created_at)) < now()-interval '60 seconds')
ORDER BY j.updated_at FOR UPDATE OF j SKIP LOCKED LIMIT 50
""").fetchall()
for row in rows:
if row["cancel_requested"]:
status,reason,delay = "cancelled","user_cancelled",0
elif row["expired"] or row["attempts"] >= 3:
status = "failed"
reason = "deadline_exceeded" if row["expired"] else "attempts_exhausted"
delay = 0
elif row["status"] == "running":
status,reason,delay = "queued","lease_expired",10
else:
status,reason,delay = "queued","dispatch_overdue",0
updated = conn.execute("""
UPDATE export_jobs SET status=%s,error_code=%s,
lease_token=NULL,lease_until=NULL,
next_attempt_at=now()+(%s * interval '1 second'),updated_at=now()
WHERE id=%s RETURNING next_attempt_at
""",(status,reason,delay,row["id"])).fetchone()
if status == "failed":
conn.execute("""
INSERT INTO export_dead_letters(job_id,reason) VALUES (%s,%s)
ON CONFLICT(job_id) DO NOTHING
""",(row["id"],reason))
if status == "queued":
conn.execute("""
INSERT INTO export_outbox(id,job_id,queue_name,available_at)
VALUES (%s,%s,'exports.bulk',%s)
""",(str(uuid4()),row["id"],updated["next_attempt_at"]))
return len(rows)
if __name__ == "__main__":
print("recovered",recover_batch())
先暂停本机publisher与consumer,防止实验记录被正常流程抢先处理。只针对新建实验job设置next_attempt_at为未来10秒;两次直接claim模拟已有重复消息。两次均为None,attempts保持0。到期后claim成功且attempts变1。演练需要10秒等待,只在本地执行;任务后续通过正常worker输出或恢复扫描到终态。
文件:verify_retry_gate.py
import time
from state_store import connect,submit_snapshot,claim
with connect() as conn:
note_id=conn.execute("SELECT min(id) AS id FROM notes").fetchone()["id"]
assert note_id is not None
job_id=submit_snapshot([note_id])
with connect() as conn:
conn.execute("UPDATE export_jobs SET next_attempt_at=now()+interval '10 seconds' WHERE id=%s",(job_id,))
assert claim(job_id) is None
assert claim(job_id) is None
with connect() as conn:
assert conn.execute("SELECT attempts FROM export_jobs WHERE id=%s",(job_id,)).fetchone()["attempts"] == 0
time.sleep(10.1)
job=claim(job_id)
assert job is not None and job["attempts"] == 1
print("persistent retry gate verified",job_id)
# 此处人为持有新租约;不伪造成功结果,等待租约到期后由恢复流程重新排队。
暂停本机publisher和consumer后执行本实验,避免正常进程抢先处理。使用新建job的outbox行模拟发送已确认,但不真的发送Celery消息;只修改该job时间。扫描后必须出现新的pending outbox,第二次扫描不能再给它新增事件。恢复publisher和consumer后应正常执行。这覆盖排队消息丢失,也覆盖worker在claim前数据库连接失败的相同持久状态。
文件:verify_queued_recovery.py
from state_store import connect,submit_snapshot
from recovery import recover_batch
with connect() as conn:
note_id=conn.execute("SELECT min(id) AS id FROM notes").fetchone()["id"]
assert note_id is not None
job_id=submit_snapshot([note_id])
with connect() as conn:
conn.execute("""
UPDATE export_outbox SET sent_at=now()-interval '61 seconds',claim_until=NULL
WHERE job_id=%s
""",(job_id,))
conn.execute("""
UPDATE export_jobs SET last_dispatched_at=now()-interval '61 seconds',
updated_at=now()-interval '61 seconds',next_attempt_at=now()-interval '61 seconds'
WHERE id=%s
""",(job_id,))
recover_batch()
with connect() as conn:
pending=conn.execute("SELECT count(*) AS n FROM export_outbox WHERE job_id=%s AND sent_at IS NULL",(job_id,)).fetchone()["n"]
assert pending == 1
recover_batch()
with connect() as conn:
assert conn.execute("SELECT count(*) AS n FROM export_outbox WHERE job_id=%s AND sent_at IS NULL",(job_id,)).fetchone()["n"] == 1
assert conn.execute("SELECT attempts FROM export_jobs WHERE id=%s",(job_id,)).fetchone()["attempts"] == 0
print("queued delivery recovery verified",job_id)
# 恢复正常publisher和consumer,再核对该job终态与输出。
对本次隔离worker先停止取新任务或直接发送TERM,观察warm shutdown等待当前有界任务。只在故障演练时终止已核对的执行进程;30秒租约过期后运行recovery.py,再让publisher发布到期事件。不要使用模糊进程名批量终止。
文件:终端 / 当前课时
python -m celery -A celery_app:app inspect active
python -m celery -A celery_app:app inspect reserved
# 正常关闭:对已核对的本次worker PID发送TERM,或首次Ctrl+C。
python recovery.py
python publisher.py
新建replay_job.py,在命令行传一个明确的failed任务ID。事务中保留payload、记录replay_of、建立新outbox;原dead letter不删除。自动批量重放前必须有故障已修复的证据,本模块只逐条本地重放。
文件:replay_job.py
import sys
from uuid import UUID, uuid4
from state_store import connect, admit_export
def replay(old_id: str):
old_id = str(UUID(old_id))
new_id = str(uuid4())
with connect() as conn:
admit_export(conn)
row = conn.execute("""
INSERT INTO export_jobs(id,payload,replay_of)
SELECT %s,payload,id FROM export_jobs WHERE id=%s AND status='failed'
RETURNING id
""", (new_id, old_id)).fetchone()
if row is None:
raise ValueError("failed source job not found")
conn.execute("""
INSERT INTO export_outbox(id,job_id,queue_name)
VALUES (%s,%s,'exports.bulk')
""", (str(uuid4()), new_id))
return new_id
if __name__ == "__main__":
print(replay(sys.argv[1]))
发布新版本需要停worker,用户同时请求取消导出,部分任务已多次崩溃。
依次验证过期租约、未来next_attempt_at收到重复消息、已sent却queued、已有pending事件抑制重复恢复、3次耗尽与单条重放;每次仅操作记录的本地job_id。
原因:revoke或发送取消信号不等于所有执行者与外部操作已停止。
处理:先显示取消申请,待持久状态确认后显示cancelled。
原因:仍有有效租约的执行者会与新worker竞争。
处理:只恢复过期租约,旧执行者发布必须受token、时间和状态CAS约束。
原因:消息可能丢失,或worker在claim之前因数据库错误退出;只恢复running无法覆盖。
处理:按持久派发时间扫描久未领取且无pending事件的queued任务,原子重建发送意图;仍用claim防重复执行。
连续让同一隔离任务领取后租约过期,验证第3次后进入failed,不产生第4次领取。
提示:只操作本次新建任务,并通过正常claim增加attempts。
参考答案:
前两次过期产生恢复事件;第三次过期被扫描器隔离,attempts保持3,后续claim返回None;原错误和重放链可查询。
用同一组任务验证受理、发布、领取、执行、取消、恢复与结果查询的完整链路。
排队数量之外,还要测受理到开始、运行时长、总完成时间、最老待发布年龄、失败率和重试次数。只看worker存活无法知道任务是否积压。
日志同时记录job_id、outbox_id或Celery task_id、lease_token和attempt,才能区分同一个任务的重复消息与重新执行。日志不打印完整笔记正文、数据库密码或访问密钥。
任务成功需要有效结果引用和可解析文件;取消或失败不能被旧worker覆盖。单元测试能检查状态转换,真实broker、worker和数据库故障窗口仍需隔离集成演练。
在worker领取后和完成后记录下面格式,使用真实变量。错误日志保留异常类型与关联ID,正文快照不进入日志。要记录Celery消息ID可将任务改为bind=True,并使用self.request.id;同时保持对外任务名notes.export。
文件:task_logging.py
import json
import logging
logger = logging.getLogger("notes.exports")
def log_phase(job_id, phase, message_id=None, lease_token=None, attempt=None, **fields):
logger.info(json.dumps({
"event": "export_phase", "job_id": str(job_id),
"phase": phase, "message_id": message_id,
"lease_token": str(lease_token) if lease_token else None,
"attempt": attempt, **fields,
}, ensure_ascii=False))
# log_phase(job_id, "claimed", self.request.id, token, job["attempts"])
# log_phase(job_id, "published", self.request.id, token, job["attempts"],
# success=published)
保存SQL为monitor_exports.sql,在本地演练前后各执行一次。age和数量一起看;注意取消等待清理、outbox未发布和running租约过期是不同阶段。当前updated_at不能直接当精确执行耗时,需另记started_at/finished_at或使用结构化阶段时间戳。
文件:monitor_exports.sql
SELECT status,count(*) AS total
FROM export_jobs GROUP BY status;
SELECT count(*) AS unpublished,
extract(epoch FROM now()-min(created_at)) AS oldest_unpublished_seconds
FROM export_outbox WHERE sent_at IS NULL;
SELECT count(*) AS expired_leases
FROM export_jobs WHERE status='running' AND lease_until<now();
SELECT reason,count(*) AS failed
FROM export_dead_letters GROUP BY reason;
SELECT count(*) AS queued_without_pending,
extract(epoch FROM now()-min(COALESCE(j.last_dispatched_at,j.created_at))) AS oldest_dispatch_age
FROM export_jobs j
WHERE j.status='queued' AND NOT EXISTS (
SELECT 1 FROM export_outbox o WHERE o.job_id=j.id AND o.sent_at IS NULL
);
SELECT id,status,attempts,next_attempt_at,last_dispatched_at
FROM export_jobs WHERE status IN ('queued','running') ORDER BY created_at LIMIT 20;
新建verify_terminal.py,运行时传本次job_id。成功时只读取artifacts目录内的受控结果,核对文件内容与固定payload。查询不是SUCCESS时明确显示实际状态,不用RedisPENDING代替数据库结果。
文件:verify_terminal.py
import json
import sys
from pathlib import Path
from uuid import UUID
from state_store import connect
job_id = str(UUID(sys.argv[1]))
with connect() as conn:
job = conn.execute("SELECT * FROM export_jobs WHERE id=%s", (job_id,)).fetchone()
assert job is not None, "job missing"
print("state", job["status"], "attempts", job["attempts"])
if job["status"] == "succeeded":
root = Path("artifacts").resolve()
target = Path(job["result_ref"]).resolve()
assert target.is_relative_to(root), "unexpected artifact location"
assert json.loads(target.read_text(encoding="utf-8")) == job["payload"]
assert not job["cancel_requested"]
print("artifact verified")
elif job["status"] in {"failed", "cancelled"}:
assert job["result_ref"] is None
print("terminal without published artifact")
每一行使用新建job_id,先记基线,再暂停或修改本次隔离组件,最后恢复并运行publisher/recovery。不要同时注入多种故障,否则无法确定恢复由哪个机制保证。下面是待执行清单,不代表已经跑过。
文件:failure-matrix.md
| 故障点 | 操作 | 应核对的结果 |
|---|---|---|
| DB事务提交前 | 本次提交主动抛异常 | job与outbox同时回滚 |
| broker不可用 | 暂停本地Redis后提交 | job与outbox保留,sent_at为空 |
| 发布后未标sent | 跳过一次确认写回 | 可重复发布,业务结果仍受CAS保护 |
| worker持租约退出 | 终止本次隔离执行进程 | 到期恢复,attempts增加 |
| 旧token晚到 | 新领取后用旧token完成 | finish=False,不改result_ref |
| 取消 | 设置本次cancel_requested | 最终cancelled,不发布成功结果 |
| 次数耗尽 | 3次领取均失败/过期 | failed及dead letter,无第4次领取 |
| 结果缓存过期 | 缩短本地result_expires | PostgreSQL状态仍可查询 |
| 队列满 | 临时小容量并发提交 | 503,无虚假202 |
| 同槽周期重复 | 相同slot并发调度 | 只创建一个周期job |
| 周期跨分钟重投 | 同一模拟信号用两个执行分钟 | 当前模型创建两个槽,不宣称原信号exactly-once |
| 已派发但未领取 | 专用job模拟sent且queued、时间超过阈值 | 扫描生成新outbox,恢复后完成 |
| claim之前失败 | worker进入claim前抛连接错误 | queued路径能重新派发 |
| 退避期间重复消息 | next_attempt_at未来时直接重复claim | 均返回None,attempts不增加 |
| pending事件仍在 | 同一个queued重复运行扫描器 | 不为已有pending事件继续新增恢复事件 |
为每次演练填写实际值:操作时间、job_id、消息ID、租约、状态转换、输出摘要、恢复耗时。只有实际运行后才能标pass。设置本地验收阈值,例如过期租约路径在60秒内重新进入处理、queued丢消息路径在90秒内重新进入处理;若超出,检查可见性时间、outbox租约、扫描周期与队列等待之和。 queued恢复包含60秒未领取阈值,不能拿租约恢复阈值直接套用;两条路径都要加入扫描周期、10秒退避(仅过期运行任务)和实际队列等待。
文件:verification-record.json
{
"case": "publish_after_send_before_mark",
"job_id": "填写本次实际ID",
"observed_states": [],
"duplicate_deliveries": null,
"recovery_seconds": null,
"artifact_sha256": null,
"result": "not_run",
"notes": "实际演练后填写;不要用预计行为替代观测"
}
准备把导出服务交给他人维护,需要证明故障可恢复且结果可追踪。
逐项执行本课矩阵,恢复所有本地组件与临时配置后,再跑一次无故障导出作为回归。
原因:worker可能活着但队列积压、outbox不发布或任务卡住。
处理:同时观察最老等待、过期租约、失败和用户完成时间。
原因:eager在当前进程执行,绕过真实交付链路。
处理:eager只做函数级测试,消息重投与worker故障使用隔离集成环境。
选择“发布成功但sent_at未写回”案例,写出日志应包含的三个ID,并说明如何判断重复消息没有改变最终结果。
提示:区分业务job_id、outbox消息ID和租约token。
参考答案:
同一job_id关联重复投递日志,消息ID可能相同或不同;新领取有新token,只有有效token完成CAS。最终唯一result_ref指向与payload一致的文件,旧token完成返回False。