我打开编辑器准备写今天的日报时,突然想明白了一个道理:所谓坚持,不是靠鸡血硬撑,而是靠每天的记录来对抗遗忘和惰性。DAY39,这个数字本身没有任何魔法,但它意味着我已经连续一个多月稳定地产出、调试、复盘,这件事本身就比很多灵光一现的点子更有说服力。今天想记录的,不是又完成了多少行代码,而是在最近这个自动化小工具的开发过程中,我踩进去又爬出来的几个坑,以及最终找到的靠谱方案。
如果你也像我一样,正在做类似的半自动/半手动的数据处理工具,或者是想用脚本把自己从重复劳动里解放出来,那么这篇记录大概率对你有用。我会尽量把今天遇到的细节写清楚,包括当时的思路、为什么那样选型、走弯路的过程,以及最后稳定运行的结果。这篇不是教程,更像是朋友之间的技术闲聊,但干货密度不会低。
1. 为什么连续记录39天后,我选择了这个方向
其实这个项目的起点很朴素。大概在两周前,我发现每天有大量固定的信息整理工作——从几个不同来源收集数据、清洗、去重、格式化,再输出成统一的报表。一开始靠手工,复制粘贴倒也能完成,但每天重复超过一个小时后,我就知道这事儿必须交给脚本。
1.1 从手工整理到自动化脚本的需求拆解
先说说我具体面对的问题。每天需要处理的数据大概有两类:一类是文本形式的记录,另一类是从接口拉取的结构化数据。手工处理的话,大致是打开来源页面、筛选有效条目、复制到表格、再统一处理格式。听起来不难,但一旦条目数超过一两百条,特别容易眼花,而且偶尔还会漏掉重要字段。
所以在 DAY30 左右,我决定写一个脚本,把这些操作串成一个流程。当时给自己定的目标是:把整个处理时间从每天 60 分钟压缩到 10 分钟以内。到今天 DAY39,这个目标已经达成,稳定运行了一周。但这个过程绝对没有想象中那么顺利,中间遇到过编码问题、字段匹配问题、超时问题,甚至还有一个小概率的数据错位 bug,差点让我放弃了用脚本全自动处理的想法。
1.2 为什么没用现成的低代码工具
在做这个决定之前,我也考虑过要不要用现成的低代码自动化工具。坦白讲,如果只是处理轻量数据,那些图形化流程工具确实上手快,拖拽几下就能跑通基本流程。但我需要面对的场景有两个特别麻烦的地方:
第一,处理的其中一个数据源,返回的格式比较特殊,不是规范的 JSON 或 CSV,而是一种带嵌套结构的文本。低代码工具解析这种不规则格式的能力普遍偏弱,稍微变一变格式就得改配置,维护成本反而高。
第二,我需要做不少自定义的清洗逻辑,包括同义词替换、正则提取、内容打分排序。这些逻辑在代码里写是很直观的,而在低代码工具的积木式组件里表达,反而又别扭又难调试。
所以最终我决定自己写脚本。对有一定代码基础的朋友来说,如果需求属于“复杂判断+自定义清洗+多源整合”,自己写代码往往比拖组件更省心。这也是我这三十多天来最大的体会之一:工具不是越傻瓜越好,而是越贴合自己的需求越好。
2. 今天完成的核心功能:一个自带缓冲队列的数据清洗管道
今天的进展,是给整个自动化流程加了一个轻量级的缓冲队列,解决了之前偶发性的数据丢失问题。这个问题前面几天一直困扰我,今天总算找到了一个既不引入重型中间件、又能稳定扛住波动的方案。
2.1 原始方案的问题:单线程顺序处理
先介绍下之前的处理流程。整个脚本的结构大致是这样的:从数据源 A 和 B 拉取原始内容,经过清洗函数 normalize(),再通过 deduplicate() 去重,最后格式化并写入结果文件。最开始的实现是单线程顺序处理,也就是:拉取一条,清洗一条,写入一条。
在数据量小的时候,这套流程完全没问题。但前几天我测试了一批 2000 条左右的数据,发现偶尔会出现界面看起来是处理完了,结果文件里却少了几十条的情况。一开始我怀疑是数据源返回本身不完整,后来逐步排查才发现,问题出在其中一个接口的响应时间不稳定上。有时候一次请求会特别慢,如果我在代码里设置了固定超时时间(比如 10 秒),某些慢请求就会直接被截断,对应的条目自然就丢了。
2.2 引入缓冲队列的改造过程
今天的核心改动,就是在拉取数据和清洗之间增加一个队列,让数据拉取不再同步等待单条清洗完成。具体结构变成了这样:
数据源 → 获取原始条目 → 放入缓冲队列 → 清洗 worker 从队列取数据 → 格式化 → 批量写入
这样做的好处是,即便某个数据源响应较慢,也不会阻塞后面已经到位的条目处理。同时,我用一批 worker 来消费队列里的数据,能更均衡地处理突发的大批量数据。为了不让队列无限增长占用内存,我还设置了一个最大长度,超过阈值时,拉取端会暂时休息一小段时间再继续。
用到的核心库是 Python 标准库里的queue.Queue配合threading,没有引入额外的依赖。生产者和消费者的模型差不多是这个结构:
import queue import threading import time raw_queue = queue.Queue(maxsize=500) def producer(): for item in fetch_data(): raw_queue.put(item) def worker(): while True: item = raw_queue.get() if item is None: break cleaned = normalize(item) write_result(cleaned) raw_queue.task_done() threads = [threading.Thread(target=worker) for _ in range(3)] for t in threads: t.start() producer() raw_queue.join()整个过程并不复杂,但有三个细节值得强调。
第一,队列设置了maxsize,这能防止数据源一次性吐过来大量数据导致内存飙升。实测下来,maxsize=500这个值在这个场景下足够用。
第二,消费者的退出条件,我选择了放None哨兵的方式,而不是直接粗暴中断线程。这样可以确保队列里已有的数据都被处理完再退出,不会留下“半截工程”。
第三,写入结果的逻辑也做了调整。原来是一条条写,改成攒够 100 条后批量写入一次。文件 IO 的次数减少了,整个流程的耗时也下降了不少。
2.3 为什么队列长度是 500 而不是 100 或无穷大
这里展开说下队列长度的选择逻辑。如果队列不设上限,生产者拉取的速度突然飙升,消费者来不及处理,内存占用就会线性增长。我做过一次模拟测试,某数据源在特定时间段返回了约 6000 条原始记录,如果不设上限,内存最高能涨到 900MB 以上,对于一个常驻脚本来说实在太浪费。
设置上限之后,生产者在队列满时进入短暂的等待,相当于给消费者争取了喘息时间,实现了一种简单的背压效果。而长度选 500,是因为我的消费者处理能力大约是每秒 60 条左右,队列里最多积压 500 条也就意味着最多延迟几秒的等待,完全在接受范围内。
如果长度设得太小,比如 50,数据源稍微波动一下就会让生产者频繁等待,反而拉低了整体吞吐。所以这个 500 不是一个拍脑袋的数字,而是基于处理速度、数据源特点和内存承受能力综合平衡下来的结果。
3. 最隐蔽的一个Bug:字符编码不统一导致的脏数据错位
今天下午花了很长时间排查一个非常隐蔽的问题。数据本身来源是 A 和 B 两个渠道,A 渠道返回的是 UTF-8 编码,B 渠道返回的是 GB18030 编码。这个情况在第一天对接时我就发现了,也专门做了编码转换。但今天测试时发现,B 渠道有一部分记录里的个别字符在转换后变成了问号,而这些问号一旦进入后续的正则匹配逻辑,就会导致整条记录的字段错位。
3.1 排查链路:从表现到根因
最早出现的表象是,结果文件里有一小部分记录的第二个字段和第三个字段对调了位置。当时的第一反应是“是不是我在拼接字段的时候写了 bug”,但翻了好几遍代码,拼接逻辑完全没有问题。
随后我开始单独定位这些错位记录的特征,发现它们都来自 B 渠道,而且都包含某些生僻字。这时我意识到可能是编码转换不是无损的,生僻字可能在某个环节被替换成了占位符。于是我把原始数据重新拉出来,在脚本里加了一行打印每个字符编码的调试输出,终于看到元凶——B 渠道返回的个别字符,原始编码其实是 GB18030 里的扩展字符,但是我在读取时用了 GBK 去解码,导致这些扩展字符无法被识别,变成了替换字符。
3.2 修复的方式与验证
修复方法说穿了很简单,把读取时的编码参数从 GBK 换成 GB18030,GB18030 是 GBK 的超集,几乎覆盖了所有中文字符和少数民族文字。重新跑一遍后,字符转换正常了,字段错位的问题也随之消失。
但这里我想提醒的是,编码问题有时候不是表面那么简单。如果这次我只是简单地在转换后加一个 try/except 跳过异常字符,虽然不会报错,但错位的结果其实还是错的,而且隐蔽性更强。处理多源编码数据,最稳妥的思路是:统一在数据入口做编码识别和转换,并且转换之后立刻做一行断言,检查是否出现了替换字符。
def safe_gb_to_utf8(line): raw = line.encode("latin-1", errors="ignore") converted = raw.decode("gb18030", errors="strict") if "\ufffd" in converted: raise ValueError("检测到替换字符,原始编码可能识别错误") return converted.encode("utf-8")类似这样的断言逻辑,可以把“静默错误”变成“显式报错”,虽然第一次跑的时候可能会多爆几个异常,但从长期维护的角度讲,这是绝对值得的。
3.3 一个更隐蔽的“异地复现陷阱”
另外要记录一个有意思的现象:我在自己的开发环境里跑测试,从来没有复现过这个字符错位问题,但在另一台 Windows 机器上跑同一段脚本,就稳定复现。后来比对了两台机器的 Python 版本和 Locale 设置,发现差异出在默认编码上。Python 2 时代这种问题特别明显,Python 3 虽然默认 UTF-8,但某些库在 Windows 下仍可能受系统区域设置影响。
所以如果你也遇到“我这跑得好好的,别人机器上就出问题”的情况,不要急着甩锅,先检查三件事:Python 版本是否一致、依赖库版本是否有细微差异、数据源返回内容在两种环境下的字节流是否完全一致。有时候问题就藏在环境差异里。
4. 数据去重逻辑的设计:从精确去重到模糊去重
除了上面提到的缓冲队列和编码修复,今天还顺手优化了一个之前一直没做好的模块——数据去重。原计划里这块只做精确去重,也就是判断两个条目的文本内容完全一致才删除其中一个。但真实数据哪会那么规整,同样一条信息,来源 A 和来源 B 可能表达方式略有不同,比如多了个空格、符号用了全角和半角之差,甚至同一个意思换了顺序。
4.1 精确去重的局限
我之前第一版去重用的是哈希判断,把整条文本做 MD5,然后比对哈希值。原理上没错,但有个很尴尬的情况:两条内容只差一个全角逗号和半角逗号,人眼一看就是同一条,哈希值却完全不同,于是去重失败,结果文件里反复出现相近条目。对人工阅读造成很大干扰。
4.2 归一化+相似度打分方案
今天改成先做归一化,再做相似度判断。归一化这一步把全角符号转为半角、去掉多余空白、统一大小写、去掉肉眼不可见的零宽字符。做完这一层预处理,再计算文本相似度。
相似度计算我没有上维度很高的向量模型,毕竟本地跑脚本,不想为此引入太重的东西。我用了 Python 标准库里的difflib.SequenceMatcher,对于短文本来说,性能和准确率都够用。设定一个阈值,比如相似度高于 0.92 就认为是重复条目。
from difflib import SequenceMatcher def is_duplicate(a, b, threshold=0.92): a_norm = normalize_text(a) b_norm = normalize_text(b) ratio = SequenceMatcher(None, a_norm, b_norm).ratio() return ratio >= threshold这个方案处理了绝大多数重复情况。唯一的问题是计算复杂度是 O(n^2),如果一次性来 5000 条数据,两两比较的规模就到千万级别,虽然 Python 也能扛,但明显有些吃力。所以我又在比较前加了一个“分组桶”的逻辑,按照数据来源和时间戳先分桶,只在桶内两两比较,既提升了速度又不牺牲准确率。
4.3 这类去重方案适合什么场景
如果你要处理的文本比较长,比如一篇文章或一段描述性的内容,用SequenceMatcher也能跑,但建议先抽取关键片段再比较,比如标题部分加正文前几个字符。如果文本很短又特别在意语义等价,那最好上更高级的向量模型,但那就超出今天这个轻量脚本的范畴了。
对普通的结构化记录去重,我的建议是:归一化是性价比最高的环节,它能解决 80% 的“看起来不一样其实一样”的问题,不需要任何额外依赖,效果却立竿见影。
5. 运行结果与稳定性观察:连续 7 天没出乱子
改造完成之后,我没有立刻把所有任务切到新脚本上,而是先进行了三天的灰度验证。也就是手动处理一份、脚本处理一份,两份结果对比一致后,才正式切换成脚本自动运行。目前新脚本已经连续稳定运行 7 天,每天大概处理 800 到 1200 条数据,耗时从原来的 60 分钟降到了 7 分钟左右,同时也验证了缓冲队列的背压机制确实有效。
5.1 耗时对比数据
这里贴一组今天记录的实际耗时段对比,区别比较直观:
| 处理方式 | 平均耗时 | 失败次数 | 人工介入次数 |
|---|---|---|---|
| 纯手工复制粘贴 | 约 60 分钟 | 频繁 | 每次都需要 |
| 旧版单线程脚本 | 约 15 分钟 | 偶发丢失 | 偶尔需要 |
| 新版队列+批量处理 | 约 7 分钟 | 0 | 几乎不需要 |
当然,耗时的降低有一部分原因是我把文件写入从逐条改成批量写入,IO 次数大幅减少。另一个原因是加了解析缓存,同一个来源的相同请求结果不会重复解析,这在高频运行场景下能省下不少时间。
5.2 错误日志与告警机制
自动化脚本最怕的不是报错,而是不报错却静默产出错误结果。这次改造中我还加了一个简单的日志模块:每次运行都会生成一份运行日志,记录拉取条数、清洗丢弃条数、去重剩余条数、最终写文件条数。这四个数字只要有任何异常波动,我就能快速定位问题所在。
并且,对于清洗过程中被丢弃的原始条目,我不会直接扔掉,而是把原文单独存入一个discarded.log文件。这样即使某一天出了一批完全陌生的数据,清洗规则失效,也不会在不知情的情况下丢数据,至少能事后翻出来看看。
这个习惯我强烈建议做数据处理类脚本的朋友们采纳。因为清洗规则本质上是基于历史数据样本写的,没人能保证未来的数据还长这样。给所有被丢弃的条目留一条活路,其实也是给自己留一条退路。
5.3 当前仍存在的边界情况
虽然脚本已经稳定运行一周,但我知道还有几个边界情况没有完全处理完美。比如某个数据源如果连续返回重复内容怎么办,目前只做了去重,没有做“连续重复检测”并触发告警;再比如输入数据的字段顺序如果发生变化,由于我并不是动态解析表头而是按固定索引读取,有可能会出现错列风险,这个目前正在尝试调整。
但这些边界情况往往不是一次性能处理完的。像我这种按天推进的项目,每天能解决一个明确的问题就已经很有成就感了。DAY39 能做的是确认当前核心管道稳定、可靠、可观测,至于那些更刁钻的边界情况,留给后面的日子逐个击破吧。
6. 今日进展的延伸思考和对后续几天的规划
经过这一轮的改造,脚本已经从一个“跑通就行”的粗糙工具,变成了一个带缓冲、带日志、带灰度验证的准生产级小工具。这样一个小小的演化过程,其实就是很多个人项目的缩影:先解决“能不能用”,再解决“好不好用”,最后解决“出问题能不能及时发现”。
6.1 未来三天的目标
接下来的计划也相对清晰。第一步,把字段动态解析做起来,不再依赖固定索引,而是根据表头关键词自动映射。这样即使数据源调整列顺序,脚本也能自动适应,而不是默默出差错。
第二步,把去重模块的相似度算法再升级一版,重点解决文本较长时SequenceMatcher计算较慢的问题。目前想到的优化思路是引言里提到的,先对文本做归一化,再切片段做哈希,用基于特征向量的近似最近邻检索来缩小候选集,再对候选集做精确相似度计算。这样理论上可以把十万级数据的去重时间压缩到秒级。
第三步,也是我最想做的,是给脚本加上一个简单的前端界面,不用再在终端里敲命令启动。不一定要多华丽,只要能上传文件、点一下运行、看到实时日志和统计结果就够了。如果需要,我会考虑使用 Python 内置的 HTTP 服务或者一个轻量级的网页框架来搞定,这样日常使用会更友好。
6.2 这几天的感悟:工具的价值在于给人留出时间
最后说点心里的实话。做这个脚本,表面上省的是每天几十分钟,但真正让我觉得值得的,是它把原本高度依赖手工且容易出错的事,变成了一件随时可以交给它去办的事。人不用一直盯着流水线,偶尔看一眼日志就行,余下的时间可以去做更需要判断力的事。
DAY39 的更新就先写到这里。虽然写了不少字,但真正改动的代码可能就两三百行。不过我越来越觉得,这类工具型项目最难的地方从来不是代码量,而是每天面对真实数据时那些“意料之外的情况”——编码不统一、字段错位、响应超时、重复表达。每解决一个,工具就更皮实一分,而这个过程记录得越具体,后面回看的时候也越有价值。