工程实践 把平台停发收进特性开关

8 阅读5分钟

停发一个渠道,听起来是最简单的运维动作:改一行配置,下次不再发。但真做的时候我踩了一次——停发指令下了四分钟,稿子还是照发上去了。问题不在执行力度,在于「开关」这个词在我们代码里其实有好几个读取点,而我改的只是其中一个。

一次没生效的停发

那天 01:41 决定停发两个渠道,01:45 把主调度脚本里的目标平台列表改掉,删掉了那两个平台。按直觉这就算生效了。结果 01:43 和 01:44 两条发布记录照样出现,收件方正是刚刚说要停掉的平台。

排除法很快定位:真正干活的补发脚本压根没看那份目标列表。它从任务文件里直接取「这个任务包含哪些平台」,然后逐个提交:

# 补发脚本的原始逻辑(有问题)
for platform in job["platforms"].keys():
    publish(platform, job["file"])   # 完全不校验 TARGET

于是同一个「发不发」的问题,有了两个答案源:主调度脚本说「不发」,任务文件说「发」。代码的实际行为由后者决定。这类问题在工程上有个不起眼但很贵的名字——配置漂移:同一份意图被复制到多处,谁也不知道哪一处才是当前有效的。

真源不唯一 开关就是假的

特性开关(feature flag)的价值从来不是「多包一个 if」,而是把决策收敛到一个可读、可审计的判据。如果同一个决策在多个地方各自判断,那它就不是开关,只是散落各处的常量。

# 有问题的写法:判断散落在消费点
TARGET = ["juejin", "zhihu"]          # 文件 A

def push_one(job):                    # 文件 B
    for p in job["platforms"]:        # ← 这里的真源是任务文件
        do_post(p)

def write_ledger(job):                # 文件 C
    for p in job["platforms"]:        # ← 又一份
        append(p)

三处各自成立,改一处不生效,还都「看起来对」。那次停发失败的本质就是:A 改了,B 和 C 没读 A。

改成单一真源的骨架很朴素——决策只算一次,结果往上带。把「发不发」的判断做成一个函数,所有消费点都调它:

# 收敛后的写法:开关只在一个地方求值
from daily_publish import TARGET      # 唯一真源

def enabled(platform: str) -> bool:
    return platform in TARGET         # 唯一判据,只此一处

def push_one(job):
    for p in job["platforms"]:
        if not enabled(p):
            continue                  # 不 POST、不写台账、不归档
        do_post(p)

关键是那个 continue 之后要做的事:什么都不能做。我们把它明确成「非目标平台直接跳过,不发请求、不写台账、不做归档」。因为半途而废的副作用比不发更麻烦——写了台账会让下一次运行以为「已经发过」,做了归档会让文件位置错乱,最后变成一笔理不清的账。

平台停发开关 单一真源与默认拒绝流程图

消费点要默认拒绝 不是默认放行

开关最容易出事的地方,是新增消费点时忘了接判据。防御办法是让「忘了」的后果落在安全侧——默认拒绝(fail-closed)。

def gate(platform: str) -> bool:
    """任何消费点都必须先过这道闸,默认拒绝。"""
    if platform not in TARGET:
        log(f"SKIP {platform} 不在 TARGET 内,本周期不处理")
        return False
    return True

配合两点工程习惯:其一,新增平台必须走同一个 gate,代码评审只认这一个入口,不允许在业务分支里另写 if platform == ...;其二,闸门日志必须留痕,跳过也要打日志。「什么都没发生」和「被闸门挡住」在日志里必须能区分开,否则事后排查只能靠猜。

开关要能回答 为什么现在是这个值

一个只能读、不能解释自己的开关,用不了多久就会被当成玄学。我们后来给停发状态加了三个固定字段:状态(停发 / 在发)、生效时间、原因。原因要写具体事件,而不是「账号有问题」四个字——「登录态长期失效」「账号二次封禁」「账号被封」各自对应完全不同的恢复条件。

这带来一个很实际的收益:恢复时不需要问人。看到「停发 + 二次封禁 + 恢复条件:换账号环境后人工试发 1 条观察 48 小时」,值班的人照着做就行,不会出现「好像解封了,先加回去试试」这种把账号再送一次的操作。

硬拒要归类 别当普通失败重试

开关之外还有一层:平台侧的硬拒。请求发出去了,平台回执明确说「未满足发布条件」「视频质量不足」「按钮不可用」,这类不是网络抖动,重试一百次也是同样的结果。

HARD_REJECT_MARKERS = ("未满足发布条件", "视频质量不足", "按钮不可用")

def classify(resp_text: str) -> str:
    if any(m in resp_text for m in HARD_REJECT_MARKERS):
        return "abandoned"   # 硬拒:记废,不再重试
    return "failed"          # 软失败:可重试

abandoned 和 failed 分开记,下个值守周期才不会去重试一批注定失败的稿子,日志也不会被无意义的失败刷满。判断「硬拒」的依据是回执文本,所以这份标记表要跟着平台话术更新——它过期的方式是静默的:不再命中就退化成软失败,重试自然又回来了。

落地清单

把上面几件事拉成一份可以照抄的清单:

1. 唯一真源:开关只在一个模块里求值,其它地方只 import,不复制
2. 单一入口:所有消费点必须过同一个 gate(),默认拒绝
3. 跳过要留痕:SKIP 日志与「无事发生」必须可区分
4. 状态可解释:状态 + 生效时间 + 原因,原因写具体事件
5. 副作用原子:判定为不发,就不 POST / 不写台账 / 不归档
6. 失败分两类:硬拒记 abandoned,软失败记 failed

六个条目里只有第 1 条算「新增功能」,其余五条都是把已有行为收口。工程实践里这类改动常常收益最高——它不新增能力,只是让系统停止说两种话。停发一个渠道,从此变成一个动作:改真源,然后看闸门日志确认它被挡住了。