KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 业务锁封装:把基础设施翻译成业务语言 — keel 龙骨
第 4 章的分布式锁是通用的——key 你随便传、异常是通用的 Redis 异常。但业务代码想要的是:「锁住某次工作流运行(可选精确到节点)」「抢不到时抛的是业务认识的异常」。这一章讲清楚基础能力 → 业务能力的封装套路,套路本身和你用什么业务无关:key 规范化 + 异常翻译 + 组合而非继承。
第 4 章的分布式锁是通用的——key 你随便传、异常是通用的 Redis 异常。但业务代码想要的是:「锁住某次工作流运行(可选精确到节点)」「抢不到时抛的是业务认识的异常」。这一章讲清楚基础能力 → 业务能力的封装套路,套路本身和你用什么业务无关:key 规范化 + 异常翻译 + 组合而非继承。
一、为什么需要这一层
裸锁直接进业务代码会显得「很陌生」:
async def advance_node(task_run_id, step_id):
key = f"lock:task:{task_run_id}:{step_id}" # 和业务无关的命名散落各处
token = str(uuid.uuid4())
ok = r.set(key, token, nx=True, ex=30) is not None
if not ok:
raise RuntimeError("lock failed") # 业务不认识的异常类型
try:
...
finally:
r.eval(RELEASE, 1, key, token)
问题:① 锁 key 的拼法(task: / : 分隔 / 是否带 node)散落在每个调用点,哪天要改规则得全局搜;② 抢不到抛的是 RuntimeError,上层没法区分「锁冲突」和「真·运行时错误」。
封装层把这两件事收敛到一处。
二、封装结构:组合而非继承
业务代码
│ async with ResourceLock(task_run_id="task_1", step_id="step_7"):
▼
ResourceLock(业务层)
├─ _generate_lock_key() → "lock:task:task_1:step_7"(key 规范收敛到一处)
├─ 创建基础锁并调它的进入逻辑
└─ 捕获基础层异常 → 转成 ResourceLockAcquisitionError(业务认识的类型)
▼
RedisDistributedLock(基础层,第 4 章拆过的那个)
└─ SET NX EX + UUID + Lua,抛基础层异常
关键点:业务层持有基础层实例(组合),而不是继承它。组合让你能自由地改 key 规则、改异常类型,而不碰基础锁的实现。
三、三个封装动作
① key 规范化(收敛一处)
def _generate_lock_key(self) -> str:
if self.step_id:
return f"lock:task:{self.task_run_id}:{self.step_id}"
return f"lock:task:{self.task_run_id}"
task: 前缀、: 分隔、是否带 node 段——规则只在这一行。所有调用方只传业务 id,不关心 Redis key 长什么样。
② 异常翻译(让上层能区分)
try:
await base_lock.acquire()
except RedisLockAcquisitionError as exc:
# 翻译成业务认识的异常,附带业务上下文
raise ResourceLockAcquisitionError(
f"工作流 {self.task_run_id} 正在被另一实例处理"
) from exc
上层 except ResourceLockAcquisitionError 就能精确处理「锁冲突」——比如返回「请稍后再试」,而不是和别的 RuntimeError 混在一起。
③ 释放异常吞掉(别让清理炸了主流程)
async def __aexit__(self, exc_type, exc, tb):
try:
await base_lock.release() # 释放失败(如已过期)不应该影响业务结果
except RedisLockReleaseError:
pass
锁释放失败(常见原因:已经过期被别人拿走了)不应该让已经成功的业务事务回滚。吞掉 + 记日志足够。
四、用 async with 把「进入/退出」藏起来
业务侧最终长这样,干干净净:
async def advance_node(task_run_id, step_id):
async with ResourceLock(task_run_id=task_run_id, step_id=step_id):
# 这里就是临界区,保证同一时刻只有一个实例进入
...
进入时抢锁、退出时释放、异常时也能释放(__aexit__ 保证),业务完全不用管 Redis 细节。
这一章的套路适用于任何「通用基础设施 → 业务 API」的封装:规范命名、翻译异常、组合而非继承、
with托管生命周期。
↓ 下一步:06 章 · ARQ 源码与使用 —— 回到主线,把队列的封装看透。