去年年初,当店铺数突破二十个的时候,我每天早上打开监控面板,看到的不是系统状态,而是惊悚片。
任务调度器占用内存持续爬升,浏览器实例管理混乱,影刀流程脚本散落在各个服务器上没人知道哪个是当前版本,我不得不开始整个系统的重构。
很多团队做店群自动化,都是从一两个脚本开始的。
写好一个流程,手动触发,跑完了再手动下一个。
看起来简单直接,业务需求满足了,一切都很完美。
但当业务需要从管理几个店铺扩展到管理上百个店铺,从单平台扩展到多平台,从手动触发扩展到全天候自动运行——原来那个“脚本”思路,就变成了技术债务的温床。
本文要分享的,就是如何把一个面向脚本的自动化系统,重构成一个可扩展、可维护的工程系统。
这不是一次简单的代码重写,而是一次思维模式的转换。
一、脚本思维 vs 工程系统思维
重构之前,我们的系统架构本质上是这样的:
Python调用脚本 → 影刀执行流程 → 浏览器操作页面 → 脚本返回结果
看起来没什么问题。但实际上,所有逻辑都耦合在这个简单的调用链里。
浏览器管理、任务调度、异常处理、日志收集,全混在一起,改一个地方就得重新测试整个链。
工程系统思维要求我们换个角度:
自动化不是一个动作序列,而是一套生产系统。
就像工厂的流水线,原材料(数据)进来,经过多个工位(流程节点)加工,最终产出成品(业务结果)。
每个工位有自己的职责边界,工位之间通过传送带(消息队列)连接,整个流水线有监控、有质检、有容错机制。
当我们这样重新定义问题后,系统架构就变得清晰了:
我们需要构建的不是“更好的脚本”,而是一条数字化生产流水线。
二、系统重构的核心原则
在动手重构之前,我们定了几个硬性原则,这些原则指导了后续所有的设计决策:
-
关注点分离:浏览器管理、流程执行、数据管道、任务调度、异常处理,各自独立,互不干扰。
-
无状态执行:每个任务执行单元不保存任何业务状态,所有状态外化到 Redis 和数据库。
-
面向失败设计:假设一切都会失败——网络会断、浏览器会崩、平台会改版,系统必须能在失败时优雅降级。
-
可观测性优先:没有充分监控的系统,等于没有刹车的高速列车。
-
配置驱动:平台差异、流程版本、重试策略,全部通过配置管理,不写死在代码里。
这些原则听起来像是教科书的废话,但在实际落地过程中,每一项都曾让我们付出过代价。
三、模块化拆分:从单体到分布式
基于关注点分离的原则,我们把原来的单体脚本架构拆成了六个独立模块。
3.1 模块划分与职责边界
┌─────────────┐
│ 调度引擎 │ → 负责任务分发、节点管理、容量规划
└─────────────┘
↓
┌─────────────┐
│ 消息队列层 │ → Redis Stream,解耦调度与执行
└─────────────┘
↓
┌─────────────┐
│ 执行节点层 │ → 消费任务、管理浏览器、调用影刀
└─────────────┘
↓
┌─────────────┐
│ 数据管道 │ → 数据接入、标准化、结果回写
└─────────────┘
↓
┌─────────────┐
│ 异常处理系统 │ → 重试策略、环境恢复、熔断机制
└─────────────┘
↓
┌─────────────┐
│ 可观测性平台 │ → 日志、指标、追踪、告警
└─────────────┘
每个模块有自己的代码仓库、独立的部署单元、明确的 API 边界。
这是从“一个项目”到“一组服务”的转变。
最初我们尝试过把一些模块放在同一个进程里运行,以为可以减少网络开销。
结果发现出问题时难以定位——你根本不知道是调度逻辑出了 bug,还是执行逻辑卡住了。
物理隔离带来的排查便利性,远超那一点微秒级的网络延迟。
3.2 模块间的通信协议
模块间通信,我们选择了 Redis Stream 作为核心消息总线。
选择理由有三:
- 消费者组机制天然支持多节点竞争消费
- 消息持久化,节点宕机不会丢任务
- 支持消息回溯,方便排查问题
每个模块的输入输出都定义成标准的 JSON 消息格式,版本号内嵌在消息头里,保证向前兼容。
class MessageProtocol:
VERSION = "1.0"
@staticmethod
def create_message(task_type: str, payload: dict) -> dict:
return {
"version": MessageProtocol.VERSION,
"task_type": task_type,
"timestamp": time.time(),
"message_id": str(uuid.uuid4()),
"payload": payload,
}
@staticmethod
def validate(message: dict) -> bool:
required = ["version", "task_type", "timestamp", "message_id", "payload"]
return all(k in message for k in required)
所有的消息生产者和消费者都使用这个协议。
一个模块升级消息格式时,只增不减字段,保证旧消费者仍能解析。
四、配置驱动:让平台差异变成数据,而不是代码
跨平台自动化最棘手的地方,是每个平台的后台结构完全不同。
拼多多的商品上架流程,和 TEMU 的完全不同,TikTok Shop 更是另一套逻辑。
我们早期把平台差异硬编码在影刀流程里,每个平台一个独立的流程包。
这本身没问题,但问题出在调度层——很多关于平台的判断逻辑散落在 Python 代码里。
重构后,所有平台差异都被抽象为配置项,统一管理:
platforms:
pdd:
display_name: "拼多多"
queue_key: "rpa:queue:pdd"
default_timeout: 300
retry_config:
max_retries: 3
backoff_base: 30
rate_limit:
upload_product: { per_hour: 20 }
update_price: { per_hour: 50 }
browser_config:
viewport: [1920, 1080]
language: "zh-CN"
temu:
display_name: "TEMU"
queue_key: "rpa:queue:temu"
default_timeout: 360
retry_config:
max_retries: 3
backoff_base: 45
rate_limit:
upload_product: { per_hour: 15 }
inventory_sync: { per_hour: 30 }
browser_config:
viewport: [1920, 1080]
language: "en-US"
tiktok:
display_name: "TikTok Shop"
queue_key: "rpa:queue:tiktok"
default_timeout: 420
retry_config:
max_retries: 2
backoff_base: 60
rate_limit:
upload_product: { per_hour: 10 }
message_reply: { per_hour: 30 }
browser_config:
viewport: [1920, 1080]
language: "en-US"
新增一个平台,只需要加一份配置。
不需要改任何 Python 调度代码,不需要重新部署执行节点(节点会自动拉取新配置)。
这是我们从“代码驱动”转向“配置驱动”的关键一步。
很多人会担心配置文件越来越大、难以维护。
我们的经验是:只要配置结构设计合理,分层清晰,配置文件比代码里散落的 if-else 好维护得多。
因为配置是声明式的,一眼就能看出差异,而代码里的分支逻辑需要在脑子里运行时才能还原全貌。
五、插件化执行节点的设计
当平台从 1 个变成 3 个,我们面临一个选择:
是一个节点能跑所有平台的流程,还是每个节点只跑一个平台?
我们选择了后者——一个节点一个平台。
不是技术上做不到混合运行,而是混合运行带来的收益远小于风险。
一个节点如果同时加载三个平台的流程包,内存占用更大,浏览器配置更复杂。
更关键的是,一个平台的流程出了问题(比如内存泄漏),会影响同节点上其他平台的任务。
而独立节点意味着,TEMU 出了问题,拼多多和 TikTok 完全不受影响。
为了让节点管理更灵活,我们把执行节点设计成了“插件化”的。
节点启动时根据配置决定加载哪个平台的流程包、采用什么样的浏览器配置、连接哪个队列。
class ExecutorNode:
def __init__(self, config_path: str):
self.config = self._load_config(config_path)
self.platform = self.config["platform"]
async def bootstrap(self):
# 根据平台加载对应的流程注册信息
flow_config = self._load_flow_config(self.platform)
self.flow_executor = FlowExecutor(flow_config)
# 根据平台创建对应的消费者
queue_key = f"rpa:queue:{self.platform}"
self.consumer = StreamConsumer(queue_key, self.flow_executor)
# 注册节点信息
await self._register_node()
这个设计让节点变成了“平台执行器”的载体,而不是一个什么都干的庞然大物。
扩缩容时,可以根据各平台的实时负载,分别增减对应的节点。
凌晨拼多多任务量大,就多跑几个拼多多节点;TEMU 没任务,就把 TEMU 节点缩掉。
六、系统演进中的技术债务管理
重构不是一蹴而就的。
我们采取了渐进式重构的策略,而不是推倒重来。
具体做法是:绞杀者模式。
老的脚本系统继续运行,新的模块逐个上线。
新老系统通过消息队列并行运行一段时间,对比结果,确保新系统无误后再切流量。
比如我们重构异常处理系统时,先让新系统以“旁路模式”运行——接收同样的错误信息,给出处理建议,但不实际执行。
跑了半个月,对比新系统的建议和人工处理的结果,匹配率达到 95% 后,才把异常处理的实际控制权交给新系统。
这种保守的切换策略,让我们在整个重构过程中没有造成一次线上事故。
很多团队追求“一次性重构完成”,但我们更信任“小步快跑,持续验证”。
七、工程化带来的真实收益
重构完成后,我们对比了一些关键指标:
- 新平台接入时间:从 10 天缩短到 2 天。加一份配置,写一个影刀流程包,即可上线。
- 线上故障 MTTR(平均修复时间):从 45 分钟降到 12 分钟。模块化让排查范围大幅缩小。
- 系统可用性:从 96% 提升到 99.7%。异常自愈和熔断机制消化了大量瞬时故障。
- 单节点任务吞吐量:提升了约 2.3 倍。异步化和批量操作减少了大量等待时间。
这些数字不是一蹴而就的,而是每次迭代积累下来的。
更重要的是,团队不再害怕变更了——加店铺、加平台、改流程,都有了标准化的路径。
八、架构演进的几个关键决策回顾
回头来看,有几个决策在关键时刻起了决定性作用:
决策一:选择 Redis Stream 而不是 Kafka 或 RabbitMQ。
理由是部署简单、运维成本低,而且我们的任务量级远没到 Kafka 的必要阈值。
事实证明这个选择是对的——Redis 在我们的场景下足够可靠,运维几乎零成本。
决策二:坚持“一店一浏览器”的物理隔离,不做任何形式的复用。
这牺牲了一些资源利用率,但换来了绝对的环境安全。
在经历过一次平台批量风控之后,我们更加确信这个决策的价值。
决策三:把监控和告警放在系统设计的早期,而不是上线后的“补课”。
这使得我们在系统规模增长时,始终能看清它的运行状态。
很多团队的系统崩溃,不是因为代码不行,而是因为不知道它不行。
决策四:渐进式重构,不推倒重来。
这让我们在重构过程中保持了业务的连续性。
运营侧完全无感,但系统已经悄然完成了架构升级。
九、写在最后
从脚本到工程系统,这不是一条容易走的路。
它要求你不断地问自己:当下的设计,能支撑三个月后的业务量吗?
如果能,那就继续。
如果不能,那就该动手重构了。
但重构不是推翻。
真正的工程能力,是在不影响业务运行的前提下,让系统悄无声息地变强。
这篇复盘,记录了我们在店群自动化这条路上踩过的一些关键的坑,以及从坑里爬出来之后沉淀下来的架构思路。
如果你正在做或准备做类似的事情,希望能帮你少走一段弯路,能把这些经验用在自己的项目上。
作者:林焱
