资讯详情

资讯详情

图片采集工程化:从并发下载到感知哈希去重与元数据入库

我把一个图片网站的采集需求从零开始做成了完整工程整个过程踩了不少坑也沉淀出了几条很实用的经验。这篇博文就把这套“下载→感知哈希去重→元数据入库”的流水线完整拆开讲适合已经会用 requests 写简单爬虫、想把自己的脚本升级成工程化方案的人。1. 开工之前先想清楚一条图片流水线到底要做什么很多人在做图片采集时第一步就直接写 requests.get()然后循环下载。这个思路不是不行但一旦图片量到了几万张、几十万张你很快就会遇到三个非常恶心的问题大量重复图片、文件散落无法检索、下载中断后不知道哪些没下完。这就是为什么图片采集要做成“工程”而不是“脚本”。一条完整的图片采集流水线至少应该包含下面这几个环节目标分析确定采集源、页面结构、图片链接规律下载带并发、带重试、带限速的稳健下载去重感知哈希去重而不是文件 MD5 去重元数据入库把图片的 URL、下载时间、文件路径、尺寸、哈希值等信息存入数据库监控与断点续采记录每个 URL 的状态随时可以接续先说一个容易被忽略的点为什么要用感知哈希去重而不是直接算文件的 MD5因为 MD5 是按字节计算的只要文件字节有一丁点不同比如重新压缩、改了下尺寸、加了水印MD5 就完全不同了。而图片采集中同一张原图被不同站点处理后会变成各种尺寸、各种压缩率的版本MD5 完全失效。感知哈希的思路是把图片缩放到固定尺寸、转成灰度、计算每个像素的亮度差异最后生成一个“指纹”。只要图片内容一样哪怕尺寸和清晰度不同它们的感知哈希值也高度接近。这就实现了“以图识图”级别的去重。这套工程的核心链路就是标题里的箭头下载 → 感知哈希去重 → 元数据入库。我先把整体架构定下来再逐个环节深挖。2. 下载层设计concurrent.futures 与 Session 复用是关键下载是整条流水线的第一道工序看似最简单但恰恰是出错最多的地方。如果你用最朴素的方式写import requests for url in url_list: resp requests.get(url) with open(f{filename}.jpg, wb) as f: f.write(resp.content)这代码在 100 张图片时跑起来没问题但到 10000 张就会出现大量断连、超时、被服务端限流甚至封 IP。问题不在于 requests 本身而在于你没有做三件事连接复用、超时控制、重试机制。2.1 用 Session 复用 TCP 连接别每次新建连接requests.get() 每次调用都会建立新的 TCP 连接。HTTP 握手和 TLS 握手在批量场景下的开销非常可观而且频繁建连容易被服务端的防火墙盯上。正确做法是使用requests.Session(),它会自动维护连接池默认最多 10 个连接。对于并发任务可以手动调大连接池大小import requests from requests.adapters import HTTPAdapter session requests.Session() adapter HTTPAdapter(pool_connections20, pool_maxsize50) session.mount(https://, adapter) session.mount(http://, adapter)pool_connections表示不同主机缓存的最大连接数pool_maxsize是每个主机的连接池上限。并发 20 个线程时把pool_maxsize调到 50 比较稳妥。2.2 超时和重试不给网络问题甩锅的机会网络请求只要有一步卡住整个任务就会被拖死。我见过有人下载 5000 张图跑了 6 个小时就是因为没有超时卡在某个慢请求上干等。requests的超时参数必须同时传连接超时和读取超时resp session.get(url, timeout(5, 10))(5, 10)表示连接等待 5 秒读取等待 10 秒。连接超时解决的是连不上的问题读取超时解决的是连上了但不响应的问题。重试也不建议自己用循环写HTTPAdapter已经内置了重试机制直接用urllib3.Retry配置from urllib3.util.retry import Retry retry Retry( total3, connect3, read2, status3, backoff_factor0.5, status_forcelist[500, 502, 503, 504], allowed_methods[GET] ) adapter HTTPAdapter( pool_connections20, pool_maxsize50, max_retriesretry )backoff_factor表示重试间隔按指数退避0.5 意味着第一次重试等待 0.5 秒第二次 1 秒第三次 2 秒。这个设计能有效避免重试风暴——所有线程同时重试导致服务端压力陡增。2.3 并发设计ThreadPoolExecutor 是图片下载的甜点区图片下载是典型的 I/O 密集型任务线程池比多进程更合适。concurrent.futures.ThreadPoolExecutor是首选原因很简单代码简洁、不需要手动管理线程生命周期、异常能正常抛出。一个典型的下载器长这样from concurrent.futures import ThreadPoolExecutor, as_completed def download_one(task): url, filepath task try: resp session.get(url, timeout(5, 10)) if resp.status_code 200: with open(filepath, wb) as f: f.write(resp.content) return url, ok else: return url, fhttp_{resp.status_code} except Exception as exc: return url, str(exc) with ThreadPoolExecutor(max_workers16) as executor: future_map {executor.submit(download_one, task): task for task in tasks} for future in as_completed(future_map): url, result future.result() # 记录下载结果到日志这里有几个容易踩的细节线程数不是越大越好建议 8 到 16。线程太多会导致文件描述符耗尽也会加重对目标站的请求压力反而触发反爬。下载失败不要直接丢弃要把 URL 和失败原因记录下来。后面做断点续采时这些记录就是任务队列。同一个 Session 在多线程下是线程安全的可以放心复用。2.4 限速与优雅退出减少被限流的概率不过即使并发再稳妥也扛不住服务端的限流策略。我的做法是给下载器加一个简单的令牌桶限速把请求频率控制在每秒 20 个以内。少量抓取不需要这个但大批量采集时它是保护你自己的重要手段。另外下载必须支持 CtrlC 优雅退出。捕获KeyboardInterrupt把失败列表写回磁盘下次启动时继续处理。这个功能我在后面断点续采里会再提。3. 感知哈希去重aHash、pHash、dHash 的选型与实现细节下载层解决的是“能不能把图拿下来”去重层解决的是“拿下来的图值不值得留”。感知哈希是这一层的主角。3.1 三种哈希算法的核心区别一张表看懂感知哈希的常见实现有三种aHash平均哈希、pHash感知哈希、dHash差异哈希。它们的思路完全不同适合的场景也有区别。算法核心思想计算速度鲁棒性适用场景aHash像素灰度值与平均值比较大于为 1小于为 0最快较差对亮度敏感快速初筛、大量候选去重pHashDCT 变换后取低频系数比较慢强抗缩放和微小平移精确查重、以图搜图dHash相邻像素灰度差值为 1 或 0快中上抗亮度变化比 aHash 好图片去重的首选均衡方案aHash 的缺点在于对整体亮度太敏感同一张图一个版本调亮了 20%aHash 结果可能完全不同。pHash 鲁棒性最好但计算 DCT 在纯 Python 环境里较慢。工程上建议用 dHash 做主力去重它比 aHash 更抗亮度变化比 pHash 快得多而且实现只需几行代码。3.2 用 dHash 实现图片指纹16 行代码就够了dHash 的思路是先把图片缩放到 9x8 的灰度图然后比较每一行中相邻两个像素的亮度。如果左像素比右像素亮记为 1否则记为 0。这样 8 行每行 8 个比较位刚好生成 64 位的指纹from PIL import Image def dhash(image, hash_size8): image image.convert(L).resize((hash_size 1, hash_size), Image.LANCZOS) pixels list(image.getdata()) diff [] for row in range(hash_size): for col in range(hash_size): left pixels[row * (hash_size 1) col] right pixels[row * (hash_size 1) col 1] diff.append(1 if left right else 0) return bytes(diff)注意我缩放到的是(hash_size 1, hash_size)也就是 9x8而不是 8x8。这是因为需要相邻像素做比较9 列才能产生 8 个差值。3.3 用汉明距离判断“是否重复”阈值怎么定两个指纹的差异程度用汉明距离表示对应位不同的个数。距离为 0 代表完全一致距离越小越相似。判断阈值通常取 0 到 10。我实测的经验是距离 0完全一致可能是同一文件距离 0 到 5几乎可以肯定是同一张图的变体可以判重距离 5 到 10属于同一张图的概率较高但存在误杀风险距离大于 10建议视为不同图片汉明距离的实现也很简单def hamming_distance(h1, h2): return sum(1 for a, b in zip(h1, h2) if a ! b)有人会问为什么不用 OpenCV 或者现成的库因为你的去重逻辑只是整个流水线的一环用 Pillow 就能搞定的事没必要再引入一个重依赖。如果你的图片量级达到千万级那应该考虑换成 faiss 或 Milvus 做向量检索但那属于另一个量级的问题了。3.4 工程上的去重姿势判断时机比算法本身更影响效率感知哈希的去重判断放在下载之后、入库之前。但如果每下载一张图就去和库里所有图比一遍汉明距离数据量上来后会越来越慢。工程上的做法是维护一个“指纹集合”每个指纹按 8 字节存成十六进制字符串入库前遍历这个集合算距离。当集合超过 10 万时光遍历就很费 CPU 了。这时候可以做个简单的分桶预处理把 64 位指纹分成 4 个 16 位段先用其中任意一个段做粗筛只有段值相同的候选才计算完整汉明距离。这个思路类似倒排索引能把比较次数从全量降到近常量级。我建议这层逻辑还肩负一个问题某张图 dHash 距离最近的是 8到底保留新图还是删除新图我的规则是字段相同的情况保留已经入库的旧图因为它的元数据已经完整如果新图尺寸明显更大、分辨率更高则替换旧图并更新元数据。这个策略需要在实际运行中结合业务场景调整。4. 元数据建模与入库字段设计数据一致性与批量写入性能下载去重之后剩下的图片就要进入数据库。这一步最考验工程意识因为表结构设计得好不好直接决定后面统计分析、检索和排查问题时的体验。4.1 图片元数据表一个可实际落地的字段设计我把图片元数据表设计成下面这个样子这是反复迭代后的结果字段名类型说明idINTEGER PRIMARY KEY自增主键urlTEXT图片原始 URL唯一file_pathTEXT本地存储的相对路径hash_dhashTEXTdHash 十六进制指纹widthINTEGER图片宽度heightINTEGER图片高度file_sizeINTEGER文件字节数content_typeTEXTHTTP 响应头的 Content-Typedownload_timeTEXT下载完成时间ISO 格式source_siteTEXT图片来源站点标识statusTEXT状态pending/downloaded/duplicate/failedurl 加唯一索引因为同一张图可能被多个页面引用用它做唯一键可以天然防止同一 URL 的重复入库。hash_dhash 加普通索引因为需要做相似查询。source_site 和 download_time 最好也建索引方便按来源、按时间做统计。建表 SQL 大概是CREATE TABLE IF NOT EXISTS images ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, file_path TEXT, hash_dhash TEXT, width INTEGER, height INTEGER, file_size INTEGER, content_type TEXT, download_time TEXT, source_site TEXT, status TEXT DEFAULT pending ); CREATE INDEX IF NOT EXISTS idx_hash ON images(hash_dhash); CREATE INDEX IF NOT EXISTS idx_status ON images(status);4.2 文件命名文件名有信息量才能不看数据库就定位文件文件命名是一个极其重要但经常被忽视的环节。千万不要直接用 URL 的最后一段做文件名很多图片 URL 的结尾根本没有扩展名或者全是乱码参数。我的做法是用source_site 日期 自增序号做前缀后面跟 dHash 的短指纹site_20250115_000123_abr9f2k3.jpg这个命名方式的有用之处在于即使你不查数据库也能看出图片属于哪个站点、哪天采集的、是哪一批的甚至看到指纹片段就能在文件系统里定位重复文件。4.3 批量写入用 executemany别一条一条 insert元数据入库最笨的写法是循环里执行 execute commit每张图一次事务提交。这个做法在几百条时看不出差别几万条时就会明显拖慢整体速度。正确的做法是累积一批后一次性写入def batch_insert_images(conn, data_rows): sql INSERT OR IGNORE INTO images (url, file_path, hash_dhash, width, height, file_size, content_type, download_time, source_site, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) conn.executemany(sql, data_rows) conn.commit()INSERT OR IGNORE配合 url 的唯一索引碰到重复 URL 会自动跳过避免手动去查一遍数据库。如果使用 MySQL 或 PostgreSQL对应的语法是INSERT IGNORE或ON CONFLICT DO NOTHING。4.4 移动端图片还要考虑一个隐藏需求批量校验完整性入库后还有一个非常容易被忽视的环节校验文件完整性。下载过程中可能因为网络中断磁盘上留下 0 字节或半截文件。所以入库后我会跑一遍校验用 Pillow 打开文件、读取宽高、检查文件大小。如果 Pillow 打不开或尺寸为 0就把 status 改为 failed并把 url 放回重试队列。这个校验逻辑顺手解决了“元数据里宽高从哪里来”的问题——下载后立刻用 Pillow 读取然后随入库数据一起写入。批处理时Pillow 读取大图也比较耗时。如果后边发现 CPU 是瓶颈可以用Image.verify()只做解码校验比打开全图快不少。5. 断点续采与幂等设计任务跑到一半宕机怎么办工程跑久了你就明白一个残酷事实任务中断不是偶然而是必然。要么是你按 CtrlC 停掉要么是机器重启要么是网络故障。没有断点续采能力的爬虫工程中断一次就要从头再来这绝对不可接受。5.1 任务状态表让“继续跑”变成“只跑没完成的”最简单可靠的断点续采方案是在数据库里维护一张任务表记录每个 URL 的下载状态。采集程序启动时读取 status 为 pending 或 failed 的 url重新加入下载队列下载成功后更新为 downloaded下载失败则把失败原因写入任务表。def get_pending_tasks(conn, limit1000): sql SELECT url, file_path FROM images WHERE status IN (pending, failed) LIMIT ? return conn.execute(sql, (limit,)).fetchall()运行中断后重新执行采集脚本它自然只会处理未完成的任务。这个方案的幂等性很好同一个 url 即使被处理两次因为INSERT OR IGNORE的存在也不会产生重复数据。5.2 下载与入库要分离形成生产者-消费者模型断点续采的另一个关键是把下载和入库解耦。我推荐用生产者-消费者模型生产者线程从任务表里取 URL放入内存队列消费者线程从队列里取 URL 下载下载成功后把结果交给入库模块。两个环节的速率不一致时队列起到了缓冲作用不会因为入库慢导致下载空转也不会因为下载慢导致入库堆积。我用的队列是queue.Queue配合task_done()和join()控制全部任务结束import queue task_queue queue.Queue(maxsize200) def producer(conn): for task in get_pending_tasks(conn): task_queue.put(task) task_queue.join() # 等待所有任务处理完成 def consumer(task_queue): while True: task task_queue.get() try: result download_and_process(task) update_db(result) finally: task_queue.task_done()注意task_queue.join()是关键它会在队列里的所有任务都被调用task_done()后返回这样主程序就能确切知道所有图片都处理完了再退出。5.3 日志与监控日志打得好排查问题少一半断点续采没有日志等于白搭。我的日志规范是每张图片下载结束记录一行结构化的文本时间、URL、状态、耗时、文件大小。刚开始用 Python 自带的 logging 即可关键是要把 error 单独写到一个文件里import logging logging.basicConfig(levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, handlers[ logging.FileHandler(crawler.log), logging.StreamHandler() ]) error_logger logging.getLogger(error) error_handler logging.FileHandler(error.log) error_logger.addHandler(error_handler)到采集后期你会发现看 error.log 的时间比看数据时间还多。如果并发任务多到日志输出本身都成了瓶颈再考虑用concurrent_log_handler或转交给日志采集系统但初期不用这么重。5.4 重试机制的边界限制重试次数避免死循环给下载任务加重试很容易但一定要设定重试上限。我见过有人写的重试就是 while True结果对方网站临时故障程序对着同一批 URL 重试了三个小时。合理设计是限制每张图最多重试 3 次每次间隔呈指数增长1 分钟、2 分钟、4 分钟。3 次之后标记为 failed等下次启动任务时才重新尝试。这个规则保证了程序在大多数场景下能自我保护不会形成无意义的死循环。6. 反爬、镜像源与合规提醒工程上线前必须想清楚的几件事最后这部分算是经验教训区。图片采集工程跑起来容易但真正像一个工程需要处理的不只是代码还有一些更麻烦的问题。6.1 反爬应对UA、Referer、Cookie 是基本礼貌很多图片站会检查请求的来源信息。最基础的绕不过去的三样User-Agent、Referer、Cookie。对于普通公开图片至少带上真实的 UA 和 Referersession.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://example.com/ })如果目标站需要登录才能看大图还需要处理 Cookie 或者登录态。具体方案因站而异但核心原则是能用公开接口解决的不要用浏览器模拟能轻量解决的不要上重型框架。我看到很多新手动不动就推荐 Selenium 或 Playwright其实静态图片接口用 requests 完全够用上浏览器反而更容易被检测、也更耗资源。6.2 频率控制爬得越快死得越快无论单机还是分布式请求频率永远是需要主动控制的那个变量。我通常的做法是给每台机器的每秒请求数做一个软上限比如 10 到 20 个。这个值听起来很低但已经足够应付大多数中小型图片站的采集需求而且长时间运行时更不容易触发封禁。真正优雅的做法是根据目标站点的响应情况做动态调节发现 429 或 403 增多时自动降低并发恢复正常时再逐步提高。6.3 镜像与备份一份数据至少两个副本图片文件与数据库的备份策略需要分开考虑。数据库用 SQLite 时直接拷贝 .db 文件即可也可以导出成 CSV 或 JSON 做归档。图片文件的备份要按天做目录避免全量备份时的 I/O 压力。我用过的比较顺手的方式是rsync -av --progress ./images/ /backup/images_$(date %Y%m%d)/如果图片总量很大建议考虑对象存储服务或者自建文件服务上传让采集器只承担抓取任务存储完全外置。这个架构的扩展性会好很多。6.4 合规提醒robots 协议、授权边界和数据范围图片采集一定是存在合规边界的。作品版权和数据权益在国内外都很受重视个人学习、研究用途与商业使用、对外发布的边界完全不同。实际操作中我建议至少做到这几点遵守目标站点的 robots 协议、不采集需要登录才能访问的私密内容、不对站点造成过大的访问压力、不将采集的数据用于明显侵害原站利益的场景。采集公开数据做技术学习没有太大问题但发布数据、商用前必须获得授权。这一条放在全文最后说不是因为它不重要而是因为它是所有工程化方案能长期运行的基石。7. 实测效果与后续优化空间这套流水线跑一个实际项目时我在一个 8 核 16G 的 Linux 机器上用 16 线程并发每秒约 15 个请求的速率实测下载 5 万张图片约耗时 55 分钟感知哈希去重后剩下约 3.2 万张唯一图片去重率约 36%。这说明同一张图在网络上确实存在大量重复变体感知哈希给这个环节节省的磁盘空间和后续处理时间非常可观。至于后续优化如果图片量级突破百万数据库必须从 SQLite 切换到 PostgreSQL 或 MySQL去重部分要引入向量索引或分桶策略存储层要对接对象存储。如果再进一步我可以把去重算法整体替换成预训练模型提取 embedding用余弦相似度做更细粒度的查重——这会带来更高的去重精度但代价是引入深度学习的依赖链。不过这些都属于“等到量级真的到了再说”的事情。我个人实际用下来最深的体会是图片采集工程的重点从来都不在爬虫本身而在下载的稳健度、去重的策略、数据的一致性和流程的可恢复性。把这条流水线的每一个环节都做扎实你会发现爬虫只是其中最不起眼的一小段。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →