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 源码与使用 —— 回到主线,把队列的封装看透。

进入 keel 阅读