4、Python数据采集框架:多线程/多进程、队列管理与数据落盘
好,咱们进入实战环节。数据采集这件事,说白了就是跟时间赛跑。你想想看,单线程爬一个页面要等几百毫秒,要是采集几百万条数据,那得等到猴年马月去?我刚开始做数据工程时,就吃过这个亏——用单线程跑了三天三夜,结果中间网络抖了一下,全白干了。
所以这一章,咱们重点聊聊怎么用Python把采集速度提上去。核心就四个字:并行采集。
4.1 多线程 vs 多进程:到底选哪个?
很多新手会纠结这个问题。我直接说结论:IO密集型任务用多线程,CPU密集型任务用多进程。
数据采集属于典型的IO密集型——大部分时间都在等网络响应、等磁盘写入。这时候多线程就够用了。Python的GIL(全局解释器锁)在IO等待时会自动释放,所以多线程能跑满带宽。
但如果你要做数据清洗、特征提取这类计算密集的操作,那就得上多进程了。我在项目中遇到过这种情况:采集回来的原始数据需要做实时解析,CPU占用直接飙到100%,多线程反而比单线程还慢。
- 网络请求、文件读写 → 多线程
- 数据解析、计算转换 → 多进程
- 混合场景 → 线程+进程组合使用
4.2 实战:多线程采集框架
先看一个最基础的多线程采集模板。我个人习惯用concurrent.futures模块,比手动管理threading要省心得多。
import concurrent.futures
import requests
import time
def fetch_url(url):
"""单个采集任务"""
try:
resp = requests.get(url, timeout=10)
return resp.text
except Exception as e:
print(f"采集失败: {url} - {e}")
return None
def multi_thread_collect(urls, max_workers=10):
"""多线程采集入口"""
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交所有任务
future_to_url = {executor.submit(fetch_url, url): url for url in urls}
# 收集结果
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
data = future.result()
if data:
results.append((url, data))
print(f"完成: {url}")
except Exception as e:
print(f"异常: {url} - {e}")
return results
嗯,这里要注意:max_workers不是越大越好。我曾经把线程数设到100,结果目标网站直接把我IP封了。一般建议控制在5-20之间,具体要看目标网站的承受能力。
4.3 队列管理:生产者-消费者模式
单靠多线程还不够。你想想看,如果采集速度比处理速度快,内存就会爆掉。这时候就需要队列来缓冲。
Python的queue.Queue是线程安全的,我特别喜欢用它来做任务调度。看这个经典的生产者-消费者模型:
import queue
import threading
import time
# 全局任务队列
task_queue = queue.Queue(maxsize=1000)
def producer(urls):
"""生产者:不断往队列里放任务"""
for url in urls:
task_queue.put(url)
print(f"放入队列: {url}")
# 放一个结束信号
task_queue.put(None)
def consumer():
"""消费者:从队列取任务并采集"""
while True:
url = task_queue.get()
if url is None: # 收到结束信号
task_queue.task_done()
break
# 执行采集
data = fetch_url(url)
if data:
# 这里可以写入缓冲区或直接落盘
print(f"采集完成: {url}")
task_queue.task_done()
# 启动
producer_thread = threading.Thread(target=producer, args=(url_list,))
consumer_threads = [threading.Thread(target=consumer) for _ in range(5)]
producer_thread.start()
for t in consumer_threads:
t.start()
producer_thread.join()
task_queue.join() # 等待所有任务完成
maxsize,生产者跑得比消费者快时,内存会无限增长。我曾经在采集电商数据时没设上限,结果跑了半小时内存就爆了,数据全丢了。
4.4 数据缓冲:别让磁盘成为瓶颈
数据落盘也是个讲究活。每条数据都直接写文件,磁盘IO会成为新的瓶颈。我的做法是:先缓冲,再批量写入。
看这个缓冲区的实现:
import threading
import json
import time
class DataBuffer:
"""数据缓冲区,批量写入磁盘"""
def __init__(self, file_path, buffer_size=100, flush_interval=5):
self.file_path = file_path
self.buffer_size = buffer_size
self.flush_interval = flush_interval
self.buffer = []
self.lock = threading.Lock()
self.last_flush_time = time.time()
# 启动定时刷新线程
self._start_flush_timer()
def add(self, data):
"""添加数据到缓冲区"""
with self.lock:
self.buffer.append(data)
if len(self.buffer) >= self.buffer_size:
self._flush()
def _flush(self):
"""实际写入磁盘"""
if not self.buffer:
return
with open(self.file_path, 'a', encoding='utf-8') as f:
for item in self.buffer:
f.write(json.dumps(item, ensure_ascii=False) + '\n')
self.buffer.clear()
self.last_flush_time = time.time()
def _start_flush_timer(self):
"""定时强制刷新"""
def timer_loop():
while True:
time.sleep(self.flush_interval)
with self.lock:
if time.time() - self.last_flush_time >= self.flush_interval:
self._flush()
t = threading.Thread(target=timer_loop, daemon=True)
t.start()
def close(self):
"""关闭时强制刷新所有数据"""
with self.lock:
self._flush()
4.5 完整框架:把一切串起来
好了,现在我们把多线程、队列、缓冲区整合成一个完整的采集框架:
class CollectPipeline:
"""完整的数据采集流水线"""
def __init__(self, urls, output_file,
num_workers=10, buffer_size=100):
self.urls = urls
self.output_file = output_file
self.num_workers = num_workers
self.task_queue = queue.Queue(maxsize=500)
self.buffer = DataBuffer(output_file, buffer_size)
self.stats = {'total': 0, 'success': 0, 'fail': 0}
self.stats_lock = threading.Lock()
def _worker(self):
"""工作线程"""
while True:
url = self.task_queue.get()
if url is None:
self.task_queue.task_done()
break
try:
data = fetch_url(url)
if data:
self.buffer.add({'url': url, 'data': data})
with self.stats_lock:
self.stats['success'] += 1
else:
with self.stats_lock:
self.stats['fail'] += 1
except Exception as e:
print(f"工作线程异常: {e}")
finally:
self.task_queue.task_done()
with self.stats_lock:
self.stats['total'] += 1
def run(self):
"""启动流水线"""
# 启动消费者线程
workers = []
for _ in range(self.num_workers):
t = threading.Thread(target=self._worker)
t.start()
workers.append(t)
# 生产者:放入所有URL
for url in self.urls:
self.task_queue.put(url)
# 放入结束信号(每个线程一个)
for _ in range(self.num_workers):
self.task_queue.put(None)
# 等待所有任务完成
self.task_queue.join()
for t in workers:
t.join()
# 关闭缓冲区,确保数据落盘
self.buffer.close()
print(f"采集完成: 总计{self.stats['total']}, "
f"成功{self.stats['success']}, 失败{self.stats['fail']}")
# 使用示例
pipeline = CollectPipeline(
urls=['http://example.com/page/{}'.format(i) for i in range(1000)],
output_file='data.jsonl',
num_workers=10,
buffer_size=200
)
pipeline.run()
4.6 性能调优建议
最后分享几个我在实战中总结的调优经验:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 线程数 | 5-20 | 取决于目标网站和网络带宽 |
| 队列大小 | 200-1000 | 防止内存溢出,同时保证吞吐量 |
| 缓冲区大小 | 100-500条 | 平衡内存占用和磁盘IO |
| 刷新间隔 | 5-30秒 | 防止数据丢失,不要太频繁 |
- IO密集型用多线程,CPU密集型用多进程
- 队列是生产者和消费者之间的缓冲带
- 数据缓冲区能大幅提升落盘效率
- 一定要加定时刷新,防止数据丢失
- 线程数不是越多越好,要测试找到最优值
这一章的内容就到这里。说白了,数据采集框架的核心就是并行、缓冲、落盘这三个环节。下一章我们会聊聊采集到的数据怎么清洗和预处理,那又是另一番天地了。