第七阶段:数据存储与部署
合辑 :Python 爬虫阶段学习手册 | 阶段 :7 / 7 | 定位 :将采集结果持久化到数据库,并把爬虫交付到服务器按计划自动运行 | 建议学时:14--18 小时
大家好,我是小马不起床。
阶段导语
前六个阶段解决的是"如何把数据取出来":构造请求、解析页面、对抗反爬、提高吞吐。第七阶段解决另外两个问题:数据存放在哪里,程序如何长期运行。
数据持久化(Persistence)是爬虫进入工程化的第一步。以文件形式存储难以支撑结构化查询与增量更新:数据量增长后检索效率下降,字段变动需要重写整个文件,去重要求全量读入内存。本阶段依次引入 MySQL(结构化数据)、MongoDB(原始抓取数据)与 Redis(去重、队列、缓存)三类存储,并给出各自的客户端用法与 Scrapy Pipeline 集成方式。
部署部分覆盖 Linux 基础操作、nohup 后台运行、Scrapyd 服务化部署、Docker 容器化(Containerization)以及 crontab / APScheduler 定时调度,最后以一段完整的上线脚本结束。完成本阶段后,爬虫从"本地手动执行的脚本"转变为"服务器上按计划运行的服务"。
学习目标
- 能按数据规模、结构稳定性与查询需求完成存储选型,并说明 MySQL、MongoDB、Redis 在爬虫链路中的分工
- 能使用
pymysql完成建表、插入、批量插入、查询、更新与删除,并正确处理唯一约束引发的重复数据 - 能使用 SQLAlchemy 定义模型并通过会话(Session)完成增删改查,说明对象关系映射(ORM, Object-Relational Mapping)与手写 SQL 的差异
- 能编写 Scrapy Pipeline,将 Item 分别写入 MySQL 与 MongoDB,并为重复数据实现更新策略
- 能使用 Redis 的 String、List 与 Set 实现计数器、请求队列、URL 去重与代理池
- 能使用 nohup、Scrapyd、Docker Compose 与 crontab / APScheduler 完成部署与定时调度,并配置日志输出
- 能说明连接池耗尽、字符集乱码、MongoDB 批量写入失败、crontab 不执行等常见故障的成因与处理方式
前置知识
- 第五阶段 Scrapy Pipeline :
ITEM_PIPELINES的配置方式,open_spider/close_spider/process_item三个生命周期方法的调用时机(参见《05_Scrapy爬虫框架》) - 第一阶段文件 I/O :JSON 与 CSV 的写入方式、
ensure_ascii与文件编码参数(参见《01_Python语言底座》) - 第四阶段并发 :大规模 URL 场景下队列与去重的需求来源(参见《04_高并发与高性能爬虫》)
- 运行环境:Python 3.10 及以上;一台本地或云端的 Linux 服务器;Docker 与 Docker Compose(容器化部署部分需要)
目录
- 数据存储方案选型
- [关系型数据库 --- MySQL](#关系型数据库 — MySQL)
- [非关系型数据库 --- MongoDB](#非关系型数据库 — MongoDB)
- [缓存数据库 --- Redis](#缓存数据库 — Redis)
- 部署方案
- 学习建议
1. 数据存储方案选型
1.1 选型依据
存储方案由数据规模、结构稳定性与使用方式共同决定,判断路径如下。
text
数据量小(少于 1 万条)
使用 JSON / CSV 文件存储即可满足需求(前几个阶段的方式)
数据量大(超过 1 万条),需要查询与分析
数据结构固定,字段可提前定义 → MySQL / PostgreSQL
数据结构灵活,字段随页面变化 → MongoDB(与爬虫数据的形态天然匹配)
需要去重、缓存或队列 → Redis
需要长期运行、自动化调度
部署到服务器 → Scrapyd / Docker / 定时任务
三项判断维度需要分别确认:
- 数据规模:决定文件存储是否仍然可用。文件方案的全量读写成本随数据量线性增长。
- 结构稳定性:决定关系型数据库与文档数据库的取舍。字段固定且需要事务与关联查询时选择 MySQL;字段随采集页面变化、需要频繁增删字段时选择 MongoDB。
- 访问模式:决定是否需要 Redis。去重判断、队列消费与临时缓存都属于高频读写,放入内存数据库可显著降低延迟。
1.2 存储方案对比
| 方案 | 数据模型 | 适用场景 | Python 客户端 |
|---|---|---|---|
| JSON / CSV 文件 | 半结构化文本 | 开发与调试阶段、百条以内的数据、一次性导出 | 标准库 json、csv |
| MySQL | 关系表(行列固定,需先定义表结构) | 清洗后的结构化数据、需要条件查询与统计聚合 | pymysql、SQLAlchemy |
| PostgreSQL | 关系表,支持 JSONB 字段 | 结构化数据同时需保留部分嵌套字段,或对事务与约束要求更高 | psycopg2、SQLAlchemy |
| MongoDB | 文档(BSON,结构灵活,无需建表) | 原始抓取数据、字段不定的多源数据、高写入吞吐 | pymongo |
| Redis | 键值(String / List / Set / Hash 等) | URL 去重、请求队列、代理池、计数器、页面缓存 | redis |
补充两点差异:MySQL 与 PostgreSQL 属于关系型数据库,数据以二维表组织,写入前需定义模式(Schema);MongoDB 属于文档型数据库,每条记录是一份 JSON 结构,字段可以逐条不同。Redis 数据常驻内存,读写延迟为微秒级,但容量受内存限制,通常不用于长期留存业务数据。
1.3 推荐组合与数据流向
爬虫项目的常见存储组合如下,三类存储各司其职,不存在相互替代关系。
text
1. MongoDB:存放原始抓取数据。数据本身就是 JSON 结构,无需设计表结构
2. Redis:负责 URL 去重、代理池与请求队列
3. MySQL:存放清洗与结构化转换之后的最终数据(按下游需求决定是否引入)
数据流向如下:
text
网页 → 爬虫 → MongoDB(原始数据)→ 清洗与转换 → MySQL(结构化数据)
↑
Redis 在抓取阶段提供 URL 去重与代理池支持
数据规模较小的项目无需一次引入三类存储。单一 MongoDB 即可覆盖原始数据的存储与查询需求,待出现明确的关联分析或报表需求后再补充 MySQL。
- 要点:存储选型依次看数据规模、结构稳定性与访问模式,三者共同决定方案。
- 要点:文件存储适合开发与调试阶段,难以支撑结构化查询与增量更新。
- 要点:MongoDB 存原始数据、MySQL 存清洗后的数据、Redis 承担去重与队列,是爬虫项目的标准分工。
- 要点:入门练习仅需 MongoDB 一种存储即可完成,后续按需求扩展。
2. 关系型数据库 --- MySQL
2.1 安装与连接
MySQL 的操作接口按抽象层次分为两级:pymysql 提供直接执行 SQL 语句的能力,SQLAlchemy 在其之上提供对象关系映射(ORM, Object-Relational Mapping),用 Python 类与对象表达表与记录。
bash
# 安装 pymysql(MySQL 驱动)
pip install pymysql
# 或同时安装 SQLAlchemy(ORM,功能更完整)
pip install sqlalchemy pymysql
服务端需要提前准备:本地安装 MySQL Server,或使用云数据库实例。以下示例假设已存在名为 spider_db 的数据库,账号为 root。生产环境应使用权限受限的账号,并按第 5 节的方式注入密码,不在代码中硬编码。
2.2 基础操作(pymysql)
覆盖建表、插入、批量插入、查询、更新与删除六类操作。
python
import pymysql
# 连接数据库
conn = pymysql.connect(
host="localhost", # 数据库地址
port=3306, # 端口
user="root", # 用户名
password="123456", # 密码
database="spider_db", # 数据库名
charset="utf8mb4", # 编码,utf8mb4 可完整存储中文与 emoji
)
cursor = conn.cursor()
# 创建表
cursor.execute("""
CREATE TABLE IF NOT EXISTS movies (
id INT AUTO_INCREMENT PRIMARY KEY, # 自增主键
title VARCHAR(255) NOT NULL, # 电影名
score DECIMAL(3, 1), # 评分
year INT, # 年份
url VARCHAR(500) UNIQUE, # 详情页 URL,唯一约束用于防重复
created_at DATETIME DEFAULT CURRENT_TIMESTAMP # 创建时间
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
# 插入数据
sql = "INSERT INTO movies (title, score, year, url) VALUES (%s, %s, %s, %s)"
data = ("流浪地球2", 8.3, 2023, "https://example.com/movie/1")
cursor.execute(sql, data)
conn.commit() # 提交事务,未提交的变更不会落库
print(f"插入成功,ID: {cursor.lastrowid}")
# 批量插入
movies = [
("满江红", 7.4, 2023, "https://example.com/movie/2"),
("哪吒", 9.0, 2025, "https://example.com/movie/3"),
("流浪地球2", 8.3, 2023, "https://example.com/movie/1"), # URL 与上方重复
]
for movie in movies:
try:
cursor.execute(sql, movie)
except pymysql.err.IntegrityError:
print(f"重复: {movie[0]},跳过")
conn.commit()
# 查询数据
cursor.execute("SELECT title, score, year FROM movies WHERE score > 8.0 ORDER BY score DESC")
results = cursor.fetchall() # 一次取出全部结果
for title, score, year in results:
print(f"{title} | {score}分 | {year}")
# 更新数据
cursor.execute("UPDATE movies SET score = %s WHERE title = %s", (8.5, "流浪地球2"))
conn.commit()
# 删除数据
cursor.execute("DELETE FROM movies WHERE year < %s", (2020,))
conn.commit()
# 关闭连接
cursor.close()
conn.close()
三处约定需要说明:
- 参数化占位符 :SQL 中使用
%s,参数由execute的第二个参数传入。该写法由驱动完成转义,同时避免 SQL 注入与引号转义错误,不应使用字符串拼接构造 SQL。 - 唯一约束去重 :
url VARCHAR(500) UNIQUE使重复 URL 的插入抛出IntegrityError。批量循环中捕获该异常并跳过,即可实现采集侧的幂等写入。 - 事务提交 :
INSERT/UPDATE/DELETE均需显式调用conn.commit(),否则连接关闭时变更回滚。
2.3 使用 with 上下文管理器
逐条手写 commit 与 close 容易在异常路径上遗漏连接释放。将连接获取封装为上下文管理器后,提交与回滚由统一入口处理。
python
import pymysql
from contextlib import contextmanager
@contextmanager
def get_db():
"""数据库连接上下文管理器:正常退出时提交,异常时回滚并关闭连接"""
conn = pymysql.connect(
host="localhost",
port=3306,
user="root",
password="123456",
database="spider_db",
charset="utf8mb4",
)
try:
yield conn
conn.commit() # 无异常时提交
except Exception:
conn.rollback() # 有异常时回滚
raise
finally:
conn.close()
# 使用
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM movies")
count = cursor.fetchone()[0]
print(f"共有 {count} 条记录")
2.4 爬虫实战:MySQL Pipeline
Scrapy 的数据写入统一由 Pipeline 承担。将下述代码放入项目的 pipelines.py,连接的建立与释放分别挂在爬虫启动与关闭的生命周期方法上。
python
"""
Scrapy Pipeline:将数据写入 MySQL
将此代码放入项目的 pipelines.py
"""
import pymysql
class MySQLPipeline:
"""Scrapy Pipeline:数据存入 MySQL"""
def open_spider(self, spider):
"""爬虫启动时建立数据库连接"""
self.conn = pymysql.connect(
host=spider.settings.get("MYSQL_HOST", "localhost"),
port=spider.settings.get("MYSQL_PORT", 3306),
user=spider.settings.get("MYSQL_USER", "root"),
password=spider.settings.get("MYSQL_PASSWORD", ""),
database=spider.settings.get("MYSQL_DATABASE", "spider"),
charset="utf8mb4",
)
self.cursor = self.conn.cursor()
def close_spider(self, spider):
"""爬虫关闭时释放连接"""
self.conn.close()
def process_item(self, item, spider):
"""逐条写入,URL 重复时更新评分"""
sql = """
INSERT INTO movies (title, score, year, url)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE score=VALUES(score)
"""
# ON DUPLICATE KEY UPDATE:唯一键冲突时改为更新评分,而不是抛出异常
data = (item["title"], item["score"], item["year"], item["url"])
self.cursor.execute(sql, data)
self.conn.commit()
return item
配置写在项目的 settings.py 中,连接参数由配置项提供,避免代码与服务端环境耦合。
python
# settings.py 配置
MYSQL_HOST = "localhost"
MYSQL_PORT = 3306
MYSQL_USER = "root"
MYSQL_PASSWORD = "123456"
MYSQL_DATABASE = "spider_db"
ITEM_PIPELINES = {
"my_spider.pipelines.MySQLPipeline": 300,
}
数字 300 为执行优先级,数值越小越早执行,多个 Pipeline 按该顺序串联处理同一份 Item。
2.5 使用 SQLAlchemy(ORM)
ORM 将表映射为类、行映射为对象,查询通过链式方法表达,最终 SQL 由框架生成。其收益在于表结构调整只需修改模型定义,且会话统一管理事务与连接。
python
"""
ORM = Object-Relational Mapping(对象关系映射)
用 Python 对象操作数据库,无需手写 SQL
"""
# pip install sqlalchemy pymysql
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, func
from sqlalchemy.orm import declarative_base, sessionmaker
# 定义模型
Base = declarative_base()
class Movie(Base):
__tablename__ = "movies"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255), nullable=False)
score = Column(Float)
year = Column(Integer)
url = Column(String(500), unique=True) # 唯一约束
created_at = Column(DateTime, default=func.now())
def __repr__(self):
return f"<Movie {self.title}>"
# 连接数据库
engine = create_engine(
"mysql+pymysql://root:123456@localhost:3306/spider_db?charset=utf8mb4",
echo=False, # 设为 True 时打印生成的 SQL 语句,便于调试
)
# 按模型定义创建所有表
Base.metadata.create_all(engine)
# 创建会话类并实例化
Session = sessionmaker(bind=engine)
session = Session()
# 插入
movie = Movie(title="流浪地球2", score=8.3, year=2023, url="https://example.com/1")
session.add(movie)
session.commit()
# 批量插入
session.add_all([
Movie(title="满江红", score=7.4, year=2023, url="https://example.com/2"),
Movie(title="哪吒", score=9.0, year=2025, url="https://example.com/3"),
])
session.commit()
# 查询
# 查询全部
movies = session.query(Movie).all()
# 条件查询并排序
good_movies = session.query(Movie).filter(Movie.score > 8.0).order_by(Movie.score.desc()).all()
for m in good_movies:
print(m.title, m.score)
# 查询单条
movie = session.query(Movie).filter_by(title="流浪地球2").first()
# 统计数量
count = session.query(Movie).count()
# 更新
movie = session.query(Movie).filter_by(title="流浪地球2").first()
if movie:
movie.score = 8.5
session.commit()
# 删除
session.query(Movie).filter(Movie.year < 2020).delete()
session.commit()
# 关闭会话
session.close()
连接串格式为 数据库方言+驱动://用户名:密码@主机:端口/库名?参数。create_engine 内部维护连接池,sessionmaker 从池中借出连接,因此无须像 pymysql 那样手工管理每条连接。若长时间空闲后出现连接失效,可在 create_engine 中增加 pool_pre_ping=True 与 pool_recycle=3600。
| 操作 | SQLAlchemy 写法 | 对应 SQL |
|---|---|---|
| 插入 | session.add(obj) / session.add_all([...]) |
INSERT INTO ... |
| 查询全部 | session.query(Movie).all() |
SELECT * FROM movies |
| 条件查询 | session.query(Movie).filter(Movie.score > 8.0) |
SELECT ... WHERE score > 8.0 |
| 查询单条 | session.query(Movie).filter_by(title="...").first() |
SELECT ... LIMIT 1 |
| 更新 | 修改对象属性后 session.commit() |
UPDATE ... SET ... |
| 删除 | session.query(Movie).filter(...).delete() |
DELETE FROM ... |
- 要点 :
pymysql直接执行 SQL,SQLAlchemy 以模型对象代替 SQL;前者的连接与事务需手工管理,后者由引擎连接池与会话托管。 - 要点 :SQL 一律使用
%s参数化占位符,不做字符串拼接。 - 要点 :
url字段上的唯一约束配合ON DUPLICATE KEY UPDATE或异常捕获,实现重复 URL 的幂等写入。 - 要点 :Pipeline 中连接在
open_spider建立、close_spider释放,连接参数全部来自settings.py。 - 要点 :写操作必须
commit;连接统一由上下文管理器或会话释放,避免连接泄漏导致连接池耗尽。
3. 非关系型数据库 --- MongoDB
3.1 MongoDB 与爬虫数据的适配性
MongoDB 属于文档型数据库,存储单位为 BSON 文档(Binary JSON,JSON 的二进制扩展),适配爬虫场景的原因有四点。
text
1. 原生存储 JSON:爬虫解析结果本身就是 JSON 结构,无需转换即可写入
2. 模式(Schema)灵活:同一集合内每条文档的字段可以不同,新增字段无需修改表结构
3. 写入速度快:适合爬虫持续高吞吐写入的场景
4. 水平扩展方便:数据量增长后可通过分片(Sharding)分散到多个节点
对爬虫而言,这意味着抓到的数据可以直接写入,无须预先设计表结构。页面改版、字段增减都只需修改解析逻辑,数据库侧不需要执行 ALTER TABLE。
| 对比维度 | MySQL | MongoDB |
|---|---|---|
| 数据结构 | 二维表,字段固定 | 文档,字段可变 |
| 写入前的准备 | 需先建表并定义列 | 集合可自动创建 |
| 字段变动成本 | 需修改表结构 | 直接写入新字段 |
| 事务与关联查询 | 支持完整 | 支持程度较低 |
| 爬虫中的定位 | 存放清洗后的结构化数据 | 存放原始抓取数据 |
3.2 安装与连接
bash
pip install pymongo
服务端安装与启动按官方文档完成,默认监听 27017 端口。设置访问密码后连接串形式见 3.3 节注释。
3.3 基础操作(pymongo)
覆盖插入、查询、更新、删除、计数与索引六类操作。集合(Collection)相当于 MySQL 的表,文档(Document)相当于 MySQL 的行。
python
from pymongo import MongoClient
# 连接
client = MongoClient("mongodb://localhost:27017")
# 带账号密码时:MongoClient("mongodb://user:password@localhost:27017")
# 选择数据库,不存在时于首次写入自动创建
db = client["spider_db"]
# 选择集合,相当于 MySQL 的表
collection = db["movies"]
# 插入
# insert_one:插入一条文档
movie = {
"title": "流浪地球2",
"score": 8.3,
"year": 2023,
"url": "https://example.com/movie/1",
"director": "郭帆",
"actors": ["吴京", "刘德华"],
}
result = collection.insert_one(movie)
print(f"插入 ID: {result.inserted_id}")
# insert_many:批量插入
movies = [
{"title": "满江红", "score": 7.4, "year": 2023, "url": "https://example.com/2"},
{"title": "哪吒", "score": 9.0, "year": 2025, "url": "https://example.com/3"},
]
result = collection.insert_many(movies)
print(f"插入了 {len(result.inserted_ids)} 条")
# 查询
# 查询全部
for movie in collection.find():
print(movie["title"], movie["score"])
# 条件查询,$gt 表示大于
for movie in collection.find({"score": {"$gt": 8.0}}):
print(movie["title"])
# 排序(-1 为降序)并限制返回条数
for movie in collection.find().sort("score", -1).limit(10):
print(movie["title"], movie["score"])
# 查询单条
movie = collection.find_one({"title": "流浪地球2"})
# 只返回指定字段(投影)
for movie in collection.find({}, {"title": 1, "score": 1}):
print(movie)
# 更新
collection.update_one(
{"title": "流浪地球2"},
{"$set": {"score": 8.5}} # $set 只修改指定字段,其余字段保留
)
collection.update_many(
{"year": 2023},
{"$set": {"tag": "2023年电影"}}
)
# 删除
collection.delete_one({"title": "满江红"})
collection.delete_many({"year": {"$lt": 2020}})
# 符合条件的文档计数
count = collection.count_documents({"score": {"$gt": 8.0}})
print(f"高分电影: {count} 部")
# 创建索引,用于提高查询速度
collection.create_index("title") # 单字段索引
collection.create_index([("score", -1)]) # 降序索引
collection.create_index("url", unique=True) # 唯一索引,用于防重复
查询条件由操作符表达,常用操作符如下。
| 操作符 | 含义 | 示例 |
|---|---|---|
$gt / $gte |
大于 / 大于等于 | {"score": {"$gte": 8.0}} |
$lt / $lte |
小于 / 小于等于 | {"year": {"$lt": 2020}} |
$eq / $ne |
等于 / 不等于 | {"year": {"$ne": 2023}} |
$in / $nin |
属于集合 / 不属于集合 | {"year": {"$in": [2023, 2025]}} |
$regex |
正则匹配 | {"title": {"$regex": "流浪"}} |
$set |
更新指定字段 | {"$set": {"score": 8.5}} |
$exists |
字段是否存在 | {"tag": {"$exists": True}} |
两点实践约束:
- 唯一索引前置 :
create_index("url", unique=True)应在首次写入前创建。已有重复数据时创建唯一索引会失败,需先清理重复文档。 - 批量写入控制 :
insert_many默认按顺序写入,遇到唯一键冲突会中断后续写入。批量导入时可传入ordered=False跳过冲突继续写入,或使用update_one(..., upsert=True)实现"不存在则插入、存在则更新"。
3.4 爬虫实战:MongoDB Pipeline
Scrapy 场景下将写入逻辑放入 Pipeline,重复 URL 由唯一索引拦截并转为更新。
python
"""
Scrapy Pipeline:数据存入 MongoDB
"""
from pymongo import MongoClient, errors
class MongoPipeline:
"""Scrapy Pipeline:数据存入 MongoDB"""
def open_spider(self, spider):
"""爬虫启动时建立连接并创建唯一索引"""
self.client = MongoClient(spider.settings.get("MONGO_URI", "mongodb://localhost:27017"))
self.db = self.client[spider.settings.get("MONGO_DATABASE", "spider")]
self.collection = self.db[spider.settings.get("MONGO_COLLECTION", "items")]
# 创建唯一索引防重复,此处以 url 作为去重依据
self.collection.create_index("url", unique=True)
def close_spider(self, spider):
"""爬虫关闭时释放连接"""
self.client.close()
def process_item(self, item, spider):
"""写入单条数据,唯一键冲突时改为更新"""
try:
self.collection.insert_one(dict(item))
except errors.DuplicateKeyError:
# URL 已存在,覆盖更新原文档
self.collection.update_one(
{"url": item["url"]},
{"$set": dict(item)}
)
return item
python
# settings.py
MONGO_URI = "mongodb://localhost:27017"
MONGO_DATABASE = "spider_db"
MONGO_COLLECTION = "movies"
ITEM_PIPELINES 的注册方式与 MySQL Pipeline 相同,将类路径与优先级数字写入配置即可。
3.5 不依赖 Scrapy 的 MongoDB 存储类
requests + 解析库的抓取流程同样需要一个统一的存储入口。将连接、索引与写入封装为独立类,供脚本直接调用。
python
"""
不使用 Scrapy 时,直接写入 MongoDB 的封装类
"""
from pymongo import MongoClient, errors
class MongoStorage:
"""独立的 MongoDB 存储类"""
def __init__(self, uri="mongodb://localhost:27017", db_name="spider", collection_name="data"):
self.client = MongoClient(uri)
self.db = self.client[db_name]
self.collection = self.db[collection_name]
# url 唯一索引
self.collection.create_index("url", unique=True)
def save(self, item):
"""保存单条数据:新数据插入,重复数据更新"""
try:
self.collection.insert_one(item)
return True
except errors.DuplicateKeyError:
# URL 重复时更新原数据
self.collection.update_one(
{"url": item["url"]},
{"$set": item}
)
return False
def save_many(self, items):
"""批量保存"""
for item in items:
self.save(item)
def close(self):
"""释放连接"""
self.client.close()
# 使用
storage = MongoStorage()
storage.save({"title": "流浪地球2", "score": 8.3, "url": "https://example.com/1"})
storage.close()
save 的返回值区分插入与更新,可用于统计本次运行的新增量。批量写入数据量较大时,save_many 的逐条写法会产生大量网络往返,可改用 update_one(..., upsert=True) 配合批量线程,或使用 bulk_write 接口提交批任务。
- 要点:MongoDB 原生存储 JSON、模式灵活、写入快,与爬虫数据形态匹配,适合作为原始数据存储。
- 要点 :
url字段的唯一索引是去重的前提,须在首次写入前创建。 - 要点 :写入策略统一为"插入失败即更新",
insert_one捕获DuplicateKeyError后转update_one。 - 要点 :批量写入需关注
insert_many的有序性与逐条写入的往返开销,必要时报错跳过或使用bulk_write。 - 要点:Pipeline 与独立存储类两种封装分别对应 Scrapy 项目与脚本型爬虫。
4. 缓存数据库 --- Redis
4.1 Redis 在爬虫中的作用
Redis 是内存数据库,读写在内存中完成,速度极快。在爬虫中的用途集中于高频小数据场景。
text
Redis 在爬虫中的五类用途:
1. URL 去重:用 Set 存放已爬取的 URL,千万级数量仍可保持毫秒级判断
2. 请求队列:用 List 实现先进先出(FIFO, First In First Out)队列,是 Scrapy-Redis 的核心
3. 代理池:用 Set 存放可用代理,随机取用并剔除失效项
4. 计数器:用 INCR 命令统计已抓取数量、失败数量
5. 页面缓存:存放已抓取的页面内容,避免重复请求同一 URL
对应到数据结构的选择如下表。
| 数据结构 | 常用命令 | 爬虫用途 | 特性 |
|---|---|---|---|
| String | set / get / incr / expire |
计数器、缓存页面、存 token 与 cookie | 支持过期时间与原子递增 |
| List | lpush / rpush / lpop / brpop |
请求队列、结果队列 | 有序,可阻塞弹出 |
| Set | sadd / sismember / scard / srandmember |
URL 去重、代理池 | 元素唯一,判断存在性为常数级 |
| Hash | hset / hgetall |
存放单条记录的结构化字段 | 字段级读写 |
| Sorted Set | zadd / zrangebyscore |
按优先级或延时排序的调度队列 | 按分值排序 |
4.2 安装与连接
bash
pip install redis
服务端默认监听 6379 端口。生产环境应设置 requirepass,不将 Redis 直接暴露于公网。
4.3 基础操作(String / List / Set)
python
import redis
# 连接
r = redis.Redis(
host="localhost",
port=6379,
password="", # 无密码时留空
db=0, # 数据库编号,取值范围 0-15
decode_responses=True, # 自动解码为字符串,否则返回 bytes
)
# 测试连接
print(r.ping()) # 返回 True 表示连接可用
# String(字符串)
r.set("key", "value") # 设置
r.set("count", 0)
print(r.get("key")) # "value"
r.incr("count") # 递增 1,结果为 1
r.incrby("count", 5) # 递增 5,结果为 6
print(r.get("count")) # "6"
r.expire("key", 3600) # 设置过期时间,单位秒
# List(列表,用作队列)
r.lpush("url_queue", "https://a.com") # 从左侧推入
r.rpush("url_queue", "https://b.com") # 从右侧推入
r.lpush("url_queue", "https://c.com", "https://d.com") # 批量推入
length = r.llen("url_queue") # 队列长度
url = r.lpop("url_queue") # 左侧弹出,与右侧推入配合实现先进先出
url = r.brpop("url_queue", timeout=5) # 阻塞弹出,最多等待 5 秒
# brpop 用于多消费者场景:队列暂无任务时阻塞等待,出现任务立即取出
# Set(集合,用于去重)
r.sadd("visited_urls", "https://a.com") # 添加元素
r.sadd("visited_urls", "https://a.com") # 重复添加不生效
r.sadd("visited_urls", "https://b.com", "https://c.com") # 批量添加
is_visited = r.sismember("visited_urls", "https://a.com") # 判断是否存在,返回 True
all_visited = r.smembers("visited_urls") # 取出全部已访问 URL
count = r.scard("visited_urls") # 元素数量
incr 与 incrby 为原子操作,多个进程同时累加不会丢失计数,这是计数器不采用"读取后写回"写法的原因。brpop 返回的是 (键名, 值) 元组,取值时需解包。
4.4 爬虫实战:URL 去重器
Set 的添加操作同时承担"判断"与"登记"两件事:sadd 返回 1 表示元素原本不存在,返回 0 表示重复。
python
class URLFilter:
"""基于 Redis Set 的 URL 去重器"""
def __init__(self, redis_client, key="url_filter"):
self.redis = redis_client
self.key = key
def is_duplicate(self, url):
"""检查 URL 是否已访问:未访问则登记并返回 False,已访问返回 True"""
return self.redis.sadd(self.key, url) == 0
# sadd 返回 1 表示新添加(未访问),0 表示已存在(重复)
def clear(self):
"""清空记录"""
self.redis.delete(self.key)
# 使用
rf = URLFilter(r)
urls = ["https://a.com", "https://b.com", "https://a.com", "https://c.com"]
for url in urls:
if rf.is_duplicate(url):
print(f"跳过(重复): {url}")
else:
print(f"抓取: {url}")
输出结果:
text
抓取: https://a.com
抓取: https://b.com
跳过(重复): https://a.com
抓取: https://c.com
URL 数量达到千万级时,Set 的内存占用随之增长。此时可改用布隆过滤器(Bloom Filter)以固定内存空间换取可接受的误判率,代价是判断存在少量假阳性且无法删除单个元素。
4.5 爬虫实战:分布式代理池
Set 的无序性与唯一性适合维护代理集合:随机取用实现负载均衡,失效后即时剔除。
python
class ProxyPool:
"""基于 Redis Set 的代理池"""
def __init__(self, redis_client, key="proxy_pool"):
self.redis = redis_client
self.key = key
def add(self, proxy):
"""添加代理"""
self.redis.sadd(self.key, proxy)
def get_random(self):
"""随机获取一个代理"""
return self.redis.srandmember(self.key)
def remove(self, proxy):
"""移除失效代理"""
self.redis.srem(self.key, proxy)
def count(self):
"""可用代理数量"""
return self.redis.scard(self.key)
典型用法是:定时任务抓取免费代理并写入 add,请求前用 get_random 取用,请求失败或超时后调用 remove 剔除。由于 Redis 数据常驻内存且可被多台机器共享,该结构同时支撑了分布式爬虫的代理协同。
- 要点:Redis 在爬虫中的四类核心用途为去重、队列、代理池与计数,均由其内存读写性能支撑。
- 要点:数据结构与用途对应,去重用 Set、队列用 List、计数与缓存用 String。
- 要点 :
sadd的返回值可直接作为重复判断依据,无须先查后写,避免并发下的重复抓取。 - 要点 :
decode_responses=True返回字符串,未设置时返回 bytes,需要在解析前解码。 - 要点:千万级 URL 场景下 Set 内存占用过高,可改用布隆过滤器。
5. 部署方案
部署的目标是使爬虫在无人工干预的条件下持续运行。四种方式的定位与取舍如下。
| 方式 | 适用对象 | 管理入口 | 定时能力 | 主要优势 | 主要局限 |
|---|---|---|---|---|---|
| nohup 后台运行 | 单个 Python 脚本 | 命令行与日志文件 | 需配合 crontab | 零改造成本,立即可用 | 进程与机器绑定,重启与多项目管理不便 |
| Scrapyd | Scrapy 项目 | HTTP API(默认 6800 端口) | 需配合 crontab 或插件 | 支持远程部署、启动、停止与状态查询 | 仅适用于 Scrapy,自身无调度能力 |
| Docker / Docker Compose | 爬虫及其依赖的数据库 | 容器编排命令 | 由外部调度或 entrypoint 控制 | 环境一致,依赖一并交付,迁移成本低 | 需要额外的镜像构建与学习成本 |
| 定时任务(crontab / APScheduler) | 周期性采集 | 系统配置或 Python 代码 | 本身即为调度 | 实现计划执行与增量采集 | 需要自行处理日志与失败重试 |
5.1 Linux 基础操作
爬虫的最终运行环境通常为 Linux 服务器,以下命令覆盖部署过程中的日常操作。
bash
# 文件和目录
ls -la # 查看文件详情
cd /home/project # 切换目录
pwd # 显示当前路径
mkdir spider # 创建目录
cp file1 file2 # 复制文件
mv file1 dir/ # 移动文件
rm -rf dir/ # 递归删除目录,操作不可逆,执行前确认路径
# 查看文件
cat file.txt # 输出全部内容
tail -f file.txt # 实时跟踪末尾新增内容,用于查看日志
less file.txt # 分页查看
# 权限
chmod +x script.sh # 添加执行权限
chown user:group file # 修改文件所有者与所属组
# 进程
ps aux # 查看所有进程
kill PID # 终止指定进程
top # 系统资源监控
nohup command & # 后台运行,终端关闭后进程继续
# 网络
curl https://example.com # 发起请求,用于接口连通性测试
wget https://example.com # 下载文件
5.2 部署方式一:直接运行加 nohup
改造成本最低的方案,适用于单脚本、单项目的场景。nohup 使进程脱离终端,输出重定向到日志文件,末尾的 & 将任务放入后台。
bash
# 后台运行爬虫,标准输出与错误输出均写入日志文件
nohup python my_spider.py > spider.log 2>&1 &
# 查看日志
tail -f spider.log
# 停止爬虫
ps aux | grep spider.py # 查找进程号 PID
kill PID # 按进程号终止
> spider.log 2>&1 中,2>&1 表示将标准错误合并到标准输出,否则异常堆栈不会进入日志文件,故障难以定位。
5.3 部署方式二:Scrapyd
Scrapyd 是 Scrapy 生态的部署服务,通过 HTTP API 完成项目的上传、启动、停止与状态查询,适合集中管理多个 Scrapy 项目。
bash
# 安装
pip install scrapyd
# 启动服务,默认监听 http://localhost:6800
scrapyd
部署需要安装客户端工具,并在 Scrapy 项目的 scrapy.cfg 中增加部署目标配置。
bash
# 安装部署客户端
pip install scrapyd-client
# 在 Scrapy 项目根目录下执行部署,prod 为配置中的目标名称
scrapyd-deploy prod -p my_spider
scrapy.cfg 中追加的配置内容:
text
[deploy:prod]
url = http://localhost:6800/
project = my_spider
服务端通过 HTTP 接口管理,以下命令均在服务器或运维机器上执行。
bash
# 列出所有项目
curl http://localhost:6800/listprojects.json
# 启动爬虫
curl http://localhost:6800/schedule.json \
-d project=my_spider \
-d spider=example
# 停止爬虫,job 为启动接口返回的任务 ID
curl http://localhost:6800/cancel.json \
-d project=my_spider \
-d job=JOB_ID
# 查看运行状态
curl http://localhost:6800/listjobs.json?project=my_spider
Scrapyd 默认仅监听本机地址,跨机器访问需修改其配置文件中的 http_port 与绑定地址,并通过防火墙限制可访问来源;服务本身不含鉴权,不应直接暴露于公网。
5.4 部署方式三:Docker 容器化
容器化(Containerization)将代码、依赖与运行环境打包为镜像,使爬虫在不同服务器上获得一致的运行结果,排除环境差异导致的故障。
镜像构建文件如下。
dockerfile
# Dockerfile
FROM python:3.11-slim
# 设置容器内工作目录
WORKDIR /app
# 先复制依赖清单并安装,利用构建缓存避免代码变动时重复安装依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目代码
COPY . .
# 容器启动时执行的命令
CMD ["python", "run_spider.py"]
依赖清单 requirements.txt 固定版本号,保证构建结果可复现。
text
requests==2.31.0
lxml==4.9.3
pymongo==4.5.0
redis==5.0.0
scrapy==2.11.0
爬虫、MongoDB 与 Redis 需要共同运行,使用 Docker Compose 一次性编排三个服务。
yaml
# docker-compose.yml
version: '3.8'
services:
# MongoDB 服务
mongodb:
image: mongo:7
restart: always
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
# Redis 服务
redis:
image: redis:7
restart: always
ports:
- "6379:6379"
# 爬虫服务
spider:
build: .
depends_on:
- mongodb
- redis
environment:
- MONGO_URI=mongodb://mongodb:27017
- REDIS_HOST=redis
restart: always
# 需要定时执行时,将 entrypoint 指向调度脚本
volumes:
mongo_data:
restart: always 使容器异常退出后自动重启;mongo_data 命名卷将数据库文件留在宿主机,容器重建不会丢失数据。容器之间以服务名互相访问,因此 MONGO_URI 中的主机名为 mongodb 而非 localhost。
bash
# 构建并启动所有服务,-d 表示后台运行
docker-compose up -d
# 查看指定服务的日志
docker-compose logs -f spider
# 停止并移除所有服务
docker-compose down
5.5 定时运行
方式一:Linux crontab
crontab 是系统自带的定时任务工具,字段顺序为"分 时 日 月 周 命令"。
bash
# 编辑当前用户的定时任务
crontab -e
# 查看已配置的任务
crontab -l
任务条目写法如下:
text
# 每天凌晨 2 点运行爬虫
# 格式:分 时 日 月 周 命令
0 2 * * * cd /home/project/spider && /usr/bin/python run_spider.py >> spider.log 2>&1
# 每 6 小时运行一次
0 */6 * * * cd /home/project/spider && /usr/bin/python run_spider.py >> spider.log 2>&1
# 每周一早上 9 点运行
0 9 * * 1 cd /home/project/spider && /usr/bin/python run_spider.py >> spider.log 2>&1
三条约束必须遵守:命令中的解释器与路径一律使用绝对路径,crontab 执行时不加载用户 shell 的环境变量;命令前先用 cd 切换到项目目录,否则相对路径与日志写入位置不可控;输出必须重定向追加到日志文件,否则错误信息无处可查。
方式二:APScheduler(Python 定时任务)
不便于使用 crontab 时(例如 Windows 环境,或调度逻辑需要写在程序内部),可使用 APScheduler。
python
"""
APScheduler:在 Python 进程内实现定时调度,跨平台可用
"""
# pip install apscheduler
from apscheduler.schedulers.blocking import BlockingScheduler
import requests
import json
from datetime import datetime
def crawl_job():
"""定时执行的爬虫任务"""
print(f"[{datetime.now()}] 开始抓取")
try:
response = requests.get("https://example.com/data", timeout=10)
data = response.json()
with open(f"data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"抓取完成,共 {len(data)} 条数据")
except Exception as e:
print(f"抓取失败: {e}")
# 创建调度器
scheduler = BlockingScheduler()
# 每天凌晨 2 点运行
scheduler.add_job(crawl_job, "cron", hour=2, minute=0)
# 每 6 小时运行一次
# scheduler.add_job(crawl_job, "interval", hours=6)
# 每周一早上 9 点运行
# scheduler.add_job(crawl_job, "cron", day_of_week="mon", hour=9, minute=0)
print("定时任务已启动")
scheduler.start()
BlockingScheduler 会阻塞当前线程并保持进程存活,因此该脚本自身需要以后台方式常驻运行,它替代的是任务内部的调度逻辑,而非 nohup 或 Docker 的进程守护。cron 触发器用于固定时刻执行,interval 触发器用于固定间隔执行。
5.6 完整部署脚本示例
以下脚本整合日志、MongoDB 存储与异常处理,构成一个可直接上线的最小实现,配合 crontab 或 APScheduler 即可实现无人值守采集。
python
"""
完整的上线方案:爬虫 + MongoDB + 定时任务
"""
import logging
from datetime import datetime
from pymongo import MongoClient, errors
# 配置日志,同时输出到文件与标准输出
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler("crawler.log", encoding="utf-8"),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
class ProductionCrawler:
"""生产环境爬虫"""
def __init__(self):
# MongoDB 连接参数
self.mongo_uri = "mongodb://localhost:27017"
self.db_name = "spider_prod"
self.collection_name = "data"
self.client = None
self.collection = None
def connect_db(self):
"""连接数据库并创建唯一索引"""
self.client = MongoClient(self.mongo_uri)
self.collection = self.client[self.db_name][self.collection_name]
self.collection.create_index("url", unique=True)
logger.info("MongoDB 连接成功")
def fetch_data(self):
"""抓取数据,此处为示例数据,实际替换为抓取逻辑"""
logger.info("开始抓取数据")
results = []
for i in range(5):
results.append({
"title": f"数据 {i}",
"value": i * 10,
"url": f"https://example.com/{i}",
"crawled_at": datetime.now().isoformat(),
})
return results
def save_data(self, items):
"""保存数据到 MongoDB,区分新增与更新数量"""
saved = 0
updated = 0
for item in items:
try:
self.collection.insert_one(item)
saved += 1
except errors.DuplicateKeyError:
self.collection.update_one(
{"url": item["url"]},
{"$set": item}
)
updated += 1
logger.info(f"新增 {saved} 条,更新 {updated} 条")
def run(self):
"""执行一次完整采集流程"""
try:
self.connect_db()
data = self.fetch_data()
self.save_data(data)
logger.info("本次运行完成")
except Exception as e:
logger.error(f"运行失败: {e}", exc_info=True)
finally:
if self.client:
self.client.close()
# 直接运行一次
if __name__ == "__main__":
crawler = ProductionCrawler()
crawler.run()
# 配合 APScheduler 定时运行
# scheduler = BlockingScheduler()
# scheduler.add_job(lambda: ProductionCrawler().run(), "interval", hours=6)
# scheduler.start()
该结构包含四项生产要素:日志记录(exc_info=True 保留异常堆栈)、连接释放(置于 finally)、幂等写入(唯一索引加更新策略)、运行统计(新增与更新条数)。接入调度时以 run 方法为任务入口,数据库地址等配置由环境变量注入而非硬编码。
- 要点:四种部署方式的差异在于管理入口与适用对象,脚本用 nohup,Scrapy 项目用 Scrapyd,环境复杂用 Docker,周期执行用定时任务。
- 要点 :日志重定向是部署的必要环节,
2>&1与>>决定故障能否被查到。 - 要点:crontab 中的命令必须使用绝对路径并显式切换目录,因其不继承交互式 shell 的环境变量。
- 要点:Docker Compose 内服务以名称互相访问,数据目录需挂载命名卷以避免容器重建后丢失。
- 要点:生产脚本应包含日志、连接释放、幂等写入与运行统计四项要素。
6. 学习建议
- 从文件存储起步:JSON 与 CSV 在开发阶段完全可用,数据库应在查询与增量需求出现后引入,避免过早增加链路复杂度。
- 优先掌握 MongoDB:数据格式与爬虫输出天然匹配,无需建表且写入速度快,推荐作为爬虫数据存储的首选。
- Redis 用于去重与队列:Redis 并非必选项,但采集百万级 URL 时,其去重能力是文件方案无法达到的。
- Docker 是当前部署的标准做法:容器化本身有学习成本,收益在于消除"本机能运行、服务器不能运行"的环境差异问题。
- 定时任务必须写日志 :部署到服务器后无法实时看到输出,缺少
logging时故障无从定位。 - 规划监控与告警:生产环境爬虫需要提前确定三类异常的处理方式:进程退出后的自动重启、数据量异常波动的告警通知、IP 被封禁后的代理自动切换。
- 要点:引入顺序为文件存储、MongoDB、Redis、部署与调度,每一步由实际需求触发。
- 要点:日志、监控与自动恢复属于部署的一部分,与代码同等重要。
- 要点:本手册的七个阶段至此完成,后续能力扩展方向见下一节。
阶段小结
| 知识模块 | 核心要点 | 在爬虫中的作用 |
|---|---|---|
| 存储方案选型 | 数据规模、结构稳定性、访问模式三项判断;文件 / MySQL / PostgreSQL / MongoDB / Redis 对比;三类存储的组合与数据流向 | 确定数据落库位置与链路,避免以文件承载结构化查询 |
| MySQL | pymysql 建表与增删改查;%s 参数化;commit 与事务;上下文管理器;SQLAlchemy 模型与会话;ON DUPLICATE KEY UPDATE |
存放清洗后的结构化数据,支撑条件查询与统计聚合 |
| MongoDB | 文档模型与灵活模式;pymongo 插入 / 查询 / 更新 / 删除;唯一索引;$gt 与 $set 等操作符;Pipeline 与独立存储类 |
存放原始抓取数据,承担高吞吐写入与字段可变场景 |
| Redis | String / List / Set 三类结构;incr 原子计数;brpop 阻塞队列;sadd 去重;URL 去重器与代理池封装 |
提供 URL 去重、请求队列、代理池与计数器,支撑分布式协作 |
| 部署方案 | Linux 常用命令;nohup 后台运行;Scrapyd 的 HTTP API;Dockerfile 与 docker-compose 编排 | 使爬虫脱离本机环境,在服务器上持续运行 |
| 定时调度与生产脚本 | crontab 表达式与绝对路径约束;APScheduler 的 cron 与 interval 触发器;日志、连接释放、幂等写入 |
实现按计划自动采集,并保证异常可追溯、数据可重跑 |
常见问题与排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
长时间运行后报 MySQL server has gone away 或连接超时 |
单条连接空闲超过服务端 wait_timeout 后被回收,Pipeline 仍在使用失效连接 |
在 open_spider 中重建连接,或改用 SQLAlchemy 并设置 pool_pre_ping=True、pool_recycle=3600 |
并发写入时报 QueuePool limit of size ... reached |
每个线程或协程独占连接且未及时归还,连接池被耗尽 | 显式设定 pool_size 与 max_overflow,操作结束调用 close,统一用上下文管理器或会话托管连接 |
中文写入后显示为 ??? 或乱码 |
连接、表或列的字符集不是 utf8mb4,仅靠 charset 参数不足以覆盖已存在的表结构 |
连接设置 charset="utf8mb4",同时确认表与列的字符集及排序规则为 utf8mb4 |
insert_many 报 BulkWriteError,部分文档未写入 |
默认按顺序写入,中途遇到唯一索引冲突即终止后续写入 | 传入 ordered=False 使冲突文档被跳过后继续,或改用 update_one(..., upsert=True) 逐条幂等写入 |
| crontab 配置后任务未执行或无输出 | 使用了相对路径或依赖交互 shell 的环境变量,PATH 中找不到 python;目录未切换;输出无处可查 |
解释器与脚本均写绝对路径,命令前 cd 到项目目录,用 >> spider.log 2>&1 记录输出,并以 crontab -l 核对配置 |
| Docker 容器内的爬虫无法连接 MongoDB 或 Redis | 容器内使用 localhost 指向自身而非数据库服务,或三者不在同一网络 |
主机名改用 Compose 服务名(如 mongodb、redis),配置 depends_on,确认镜像与数据库服务处于同一网络 |
合辑收尾与进阶方向
本阶段是《Python 爬虫阶段学习手册》的终点:七个阶段依次完成语言底座、网络与静态抓取、动态页面与浏览器自动化、并发与性能、Scrapy 框架、反爬与 JS 逆向、数据存储与部署,构成从取数到落库再到上线的完整链路。
后续进阶可沿四条方向展开:其一,分布式爬虫,以 Scrapy-Redis 共享请求队列与去重集合,把采集能力横向扩展到多机;其二,数据管道,引入消息队列与 ETL 流程,使原始数据到结构化数据的清洗环节自动化;其三,调度平台,使用 Celery、Airflow 等工具替代 crontab,获得依赖编排、失败重试与任务告警能力;其四,采集治理,完善代理管理、指纹去重、数据质量校验与监控告警,使采集体系具备长期可运维性。
技术方案之外仍需守住边界:遵守 robots.txt、控制请求频率、不采集个人隐私与授权范围外的数据,是任何阶段都不应让出的前提。