背景

Committer 服务负责消费 Kafka 消息,将网络策略通过 NSO 提交到物理网络设备。原始设计使用单线程顺序消费:每次从 Kafka 拉取一条消息后提交,等待 NSO 返回结果再处理下一条。

单线程设计的瓶颈在于 NSO 提交设备的耗时差异很大,从几秒到几分钟不等。一条消息若因设备响应慢而阻塞,整个消费循环只能等待,整体吞吐严重受限。

改造目标是将 Committer 改为多线程并发消费,让不同设备的策略可以同时提交。

设备级并发约束

多线程并发带来一个新问题:物理网络设备在同一时刻只能处理一个配置提交。若并发向同一台设备提交多条策略,NSO 会返回冲突错误,设备状态也可能不一致。

因此,并发的粒度需要控制在设备维度:不同设备的提交可以同时进行,但同一台设备上的提交必须串行。

实现方案

多线程消费

Kafka 消费线程不再直接执行提交逻辑,而是将每条消息交给 ThreadPoolExecutor(默认 10 个 worker)异步处理。消费线程本身快速返回,继续 poll 下一批消息。

设备锁设计

引入一张设备锁表,记录当前正在进行中的提交和对应的设备,以及锁变更的时间戳。

提交任务在执行前先获取设备锁:

SELECT ... FROM device_lock WHERE device_id = %s FOR UPDATE NOWAIT

加锁使用 NOWAIT:竞争失败时立即抛出异常而非阻塞等待,消费线程可以快速感知锁状态,不会因为等锁而阻塞整个提交流程。

提交完成后,在 finally 块中释放锁:

SELECT ... FROM device_lock WHERE device_id = %s FOR UPDATE

解锁不使用 NOWAIT:此时当前任务本身就是锁的持有者,不存在竞争,阻塞等待语义更安全,确保成功取到行后再执行删除,不会因为瞬时异常而意外跳过清理步骤。无论 NSO 超时、解析异常还是其他未知错误,设备锁都会被清理,不会出现因为异常路径遗漏而导致的死锁。

锁的粒度是设备,不是请求。同一台设备的多条策略竞争同一把锁,不同设备的提交互不影响。

锁竞争与 Kafka 重投

竞争失败的消息不会被丢弃。Kafka 本身保证至少一次投递,消息会在下一个消费周期重新投递,再次参与锁竞争。NSO 侧做了幂等处理,重复提交不会导致设备状态错误。

当某个 Kafka 分区内的消息持续因锁竞争失败而无法消费时,服务会将该分区记录到内存中的阻塞列表。若所有分区都进入阻塞状态,会把最旧的一条消息回投到 topic 尾部,释放分区压力,防止消费者整体卡死。

防火墙策略的下发场景对消息顺序不敏感,这个回投策略不会引入业务层面的问题。

为何选择 PostgreSQL 行锁

与 Redis 分布式锁的对比

Redis 分布式锁的核心优势是 TTL:锁可以设置过期时间,自动释放,减少了死锁风险。但 Redis 主从复制默认是异步的,主节点写入锁之后、从节点尚未同步时若主节点宕机,锁状态会丢失,可能导致两个客户端同时持锁,互斥性被破坏。

项目的 PostgreSQL 配置了 synchronous_commit = on,写入在主库和从库都落盘后才返回,锁状态具备强一致性保证。加上系统已经依赖 PostgreSQL 存储其他业务数据,不需要额外引入 Redis,也降低了运维复杂度。

PostgreSQL 和 Redis 分布式锁的详细对比见分布式锁。

为何不用 PostgreSQL Advisory Lock

Advisory Lock 是 session 级别的,session 断开时自动释放,适合短期、无需跨请求持久化的场景。

设备锁需要在提交任务的整个生命周期内持续存在,一次 NSO 提交可能持续数分钟。行锁语义更明确,锁状态在数据库中有持久化记录,异常情况下也可以通过时间戳字段识别和清理过期锁。

已知问题

finally 块覆盖了大多数异常场景,但若进程因 OOM Kill 等原因崩溃,finally 不会执行,锁记录会永久驻留。这种情况发生概率极低,通过时间戳字段可以人工识别并清理。增加定时任务扫描超时锁并自动解锁,已列入 backlog。

改造效果

改造前,所有设备的提交都在一个线程里串行进行,任意一条消息阻塞都会拖慢整个队列。改造后,从日志观察到互相不依赖的设备策略可以同时提交,整体吞吐有明显提升。