Python爬虫零基础入门【第九章:实战项目教学·第5节】SQLite 入库实战:唯一键 + Upsert(幂等写入)!

🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • 上期回顾
    • [为什么选 SQLite?](#为什么选 SQLite?)
    • 核心概念速通
      • [1. 什么是唯一键(Unique Key)?](#1. 什么是唯一键(Unique Key)?)
      • [2. 什么是 Upsert(插入或更新)?](#2. 什么是 Upsert(插入或更新)?)
      • [3. 什么是主键和自增 ID?](#3. 什么是主键和自增 ID?)
    • 实战:从零搭建入库系统
      • 项目结构
      • [1. 表结构设计 (models.py)](#1. 表结构设计 (models.py))
      • [2. 数据库管理器 (db_manager.py)](#2. 数据库管理器 (db_manager.py))
      • [3. 入库管道 (pipeline.py)](#3. 入库管道 (pipeline.py))
        • [Upsert SQL 详解](#Upsert SQL 详解)
        • [插入 vs 更新判断](#插入 vs 更新判断)
        • 批量处理优化
      • [4. 运行入口 (run_import.py)](#4. 运行入口 (run_import.py))
      • [5. 生成测试数据 (generate_test_data.py)](#5. 生成测试数据 (generate_test_data.py))
    • 运行与验证
      • [Step 1: 生成测试数据](#Step 1: 生成测试数据)
      • [Step 2: 第一次入库](#Step 2: 第一次入库)
      • [Step 3: 再跑一次(幂等性测试)](#Step 3: 再跑一次(幂等性测试))
    • 进阶技巧
      • [1. 组合唯一键(多字段去重)](#1. 组合唯一键(多字段去重))
      • [2. 部分字段更新(不覆盖某些字段)](#2. 部分字段更新(不覆盖某些字段))
      • [3. 软删除(不真删数据)](#3. 软删除(不真删数据))
      • [4. 数据版本控制](#4. 数据版本控制)
    • 性能优化建议
      • [1. 索引不是越多越好](#1. 索引不是越多越好)
      • [2. 事务批量提交](#2. 事务批量提交)
      • [3. 使用 executemany](#3. 使用 executemany)
    • [常见问题 FAQ](#常见问题 FAQ)
    • [实战建议 💡](#实战建议 💡)
      • [1. 表设计原则](#1. 表设计原则)
      • [2. 字段类型选择](#2. 字段类型选择)
      • [3. 日志与监控](#3. 日志与监控)
      • [4. 定期维护](#4. 定期维护)
    • 完整示例:真实爬虫集成
    • [验收标准 ✅](#验收标准 ✅)
    • [下期预告 🔮](#下期预告 🔮)
    • [完整代码总结 📦](#完整代码总结 📦)
    • 总结
    • [🌟 文末](#🌟 文末)
      • [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》

订阅后更新会优先推送,按目录学习更高效~

上期回顾

上一节《Python爬虫零基础入门【第九章:实战项目教学·第4节】质量报告自动生成:缺失率/重复率/异常值 TopN!》我们搞定了质量报告生成器,能自动检测采集数据的缺失率、重复率和异常值。你会发现,采集到的数据如果只是堆在 JSONL 或 CSV 文件里,后续要查询、统计、去重都很麻烦。

今天我们要解决一个更实际的问题:如何把数据稳定地存到数据库里,而且反复跑也不会重复入库

说白了,今天要做的就是给你的爬虫装个"保险箱",数据进去后既安全又好查,关键是------跑多少次都不会乱

为什么选 SQLite?

新手友好的三大理由

1. 零配置,开箱即用

python 复制代码
import sqlite3
conn = sqlite3.connect('my_data.db')  # 就这一行,数据库就建好了!

不用装 MySQL 服务、不用配用户名密码、不用记端口号。一个 .db 文件就是整个数据库。

2. 轻量但不弱

  • 单表几百万条数据完全没问题
  • 支持索引、事务、外键(你需要的都有)
  • Python 标准库自带 sqlite3 模块,连 pip 都不用装

3. 便携性无敌

bash 复制代码
# 数据库就是个文件,想备份?
cp my_data.db backup/  # 完事儿

# 想分享给同事?
scp my_data.db user@server:/path/  # 传过去就能用

当然,SQLite 也有局限(并发写入弱、不适合超大规模),但对于爬虫项目来说,90% 的场景都够用了

核心概念速通

1. 什么是唯一键(Unique Key)?

想象你采集新闻,同一篇新闻的 URL 是 https://example.com/news/123,如果你跑了两次爬虫,没有唯一键的话:

sql 复制代码
-- 第一次入库
INSERT INTO news (url, title) VALUES ('https://example.com/news/123', '标题A');

-- 第二次又入库(完全重复!)
INSERT INTO news (url, title) VALUES ('https://example.com/news/123', '标题A');

数据库里就有两条一模一样的记录了。唯一键就是用来防止这种情况的

sql 复制代码
CREATE TABLE news (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    url TEXT NOT NULL UNIQUE,  -- 加了 UNIQUE,重复插入会报错
    title TEXT
);

这样第二次插入时,数据库会直接报错:UNIQUE constraint failed: news.url

2. 什么是 Upsert(插入或更新)?

但有时候你不想报错,而是想:"如果这条 URL 已经存在,就更新它的内容;不存在才插入新记录"。

这就是 Upsert(Update + Insert)

sql 复制代码
INSERT INTO news (url, title, content) 
VALUES ('https://example.com/news/123', '新标题', '新内容')
ON CONFLICT(url) DO UPDATE SET 
    title = excluded.title,
    content = excluded.content,
    updated_at = CURRENT_TIMESTAMP;

翻译成人话

  • 如果 url 冲突(已存在),就更新 titlecontent
  • 如果不冲突,就正常插入新记录

这就是幂等性的关键!无论跑多少次,同一条 URL 在数据库里只有一条记录,而且内容保持最新。

3. 什么是主键和自增 ID?

sql 复制代码
id INTEGER PRIMARY KEY AUTOINCREMENT
  • 主键(Primary Key):每行数据的唯一标识,不能重复,不能为空
  • 自增(AUTOINCREMENT) :插入时不用管 id,数据库自动分配递增的数字(1, 2, 3...)

为什么不直接用 URL 当主键?

URL 太长了(可能上百字符),用数字做主键查询更快。而且有些数据可能根本没 URL(比如评论),用自增 ID 更通用。

实战:从零搭建入库系统

项目结构

复制代码
sqlite_pipeline/
├── db_manager.py        # 数据库管理器(建表、连接池)
├── pipeline.py          # 入库管道(插入、更新逻辑)
├── models.py            # 表结构定义
├── run_import.py        # 运行入口
└── test_data.jsonl      # 测试数据

1. 表结构设计 (models.py)

python 复制代码
"""
数据表结构定义
原则:字段够用就好,别贪多
"""

# 新闻表的建表 SQL
NEWS_TABLE_SCHEMA = """
CREATE TABLE IF NOT EXISTS news (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    url TEXT NOT NULL UNIQUE,              -- 唯一键:防止重复
    title TEXT NOT NULL,                   -- 标题(必填)
    content TEXT,                          -- 正文(可为空)
    pub_time TEXT,                         -- 发布时间(存字符串,简单)
    source TEXT,                           -- 来源
    author TEXT,                           -- 作者
    view_count INTEGER DEFAULT 0,          -- 浏览量
    
    -- 元数据字段
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,  -- 入库时间
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,  -- 更新时间
    raw_html TEXT,                         -- 原始 HTML(可选,便于复现)
    
    -- 索引会在后面单独创建
    CHECK(length(title) > 0)               -- 约束:标题不能为空字符串
);
"""

# 索引定义(提升查询速度)
NEWS_INDEXES = [
    "CREATE INDEX IF NOT EXISTS idx_news_pub_time ON news(pub_time);",
    "CREATE INDEX IF NOT EXISTS idx_news_source ON news(source);",
    "CREATE INDEX IF NOT EXISTS idx_news_created_at ON news(created_at);"
]


def get_all_schemas():
    """获取所有建表语句"""
    return {
        'news': {
            'table': NEWS_TABLE_SCHEMA,
            'indexes': NEWS_INDEXES
        }
    }

设计要点解析

  1. 唯一键选择 :用 url 做唯一键,因为同一篇新闻的 URL 是稳定的
  2. 时间字段pub_time 存字符串就行(2026-01-24),别纠结 SQLite 的日期类型
  3. 元数据created_at(第一次入库时间)和 updated_at(最后更新时间)很有用,能追溯数据历史
  4. 原始 HTML:可选但推荐,万一解析错了可以重新跑
  5. CHECK 约束 :防止标题是空字符串('')溜进来

2. 数据库管理器 (db_manager.py)

python 复制代码
"""
数据库管理器:负责连接、建表、事务
"""
import sqlite3
from contextlib import contextmanager
from typing import Optional
import os


class DatabaseManager:
    """SQLite 数据库管理器"""
    
    def __init__(self, db_path: str = 'spider_data.db'):
        """
        初始化数据库管理器
        Args:
            db_path: 数据库文件路径
        """
        self.db_path = db_path
        self.conn:在目录存在
        os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True)
        
        # 初始化连接
        self._init_connection()
        
    def _init_connection(self):
        """初始化数据库连接"""
        self.conn = sqlite3.connect(
            self.db_path,
            check_same_thread=False,  # 允许多线程使用(但写入还是要加锁)
            timeout=30.0              # 锁等待超时30秒
        )
        
        # 开启外键约束(SQLite 默认关闭的)
        self.conn.execute("PRAGMA foreign_keys = ON;")
        
        # 设置返回结果为字典(方便取值)
        self.conn.row_factory = sqlite3.Row
        
        print(f"✅ 数据库连接成功: {self.db_path}")
    
    def create_tables(self, schemas: dict):
        """
        创建表和索引
        Args:
            schemas: 表结构字典,格式见 models.py
        """
        cursor = self.conn.cursor()
        
        for table_name, schema_info in schemas.items():
            # 1. 创建表
            print(f"📋 创建表: {table_name}")
            cursor.execute(schema_info['table'])
            
            # 2. 创建索引
            for index_sql in schema_info.get('indexes', []):
                cursor.execute(index_sql)
                print(f"  └─ 索引创建成功")
        
        self.conn.commit()
        print("✅ 所有表和索引创建完成\n")
    
    @contextmanager
    def transaction(self):
        """
        事务上下文管理器
        用法:
            with db.transaction():
                db.execute("INSERT ...")
                db.execute("UPDATE ...")
        """
        try:
            yield self.conn
            self.conn.commit()
        except Exception as e:
            self.conn.rollback()
            print(f"❌ 事务回滚: {e}")
            raise
    
    def execute(self, sql: str, params: tuple = ()):
        """执行 SQL 语句"""
        cursor = self.conn.cursor()
        cursor.execute(sql, params)
        return cursor
    
    def executemany(self, sql: str, params_list: list):
        """批量执行 SQL"""
        cursor = self.conn.cursor()
        cursor.executemany(sql, params_list)
        return cursor
    
    def close(self):
        """关闭数据库连接"""
        if self.conn:
            self.conn.close()
            print("🔒 数据库连接已关闭")


# 便捷函数:获取一个全局 DB 实例
_db_instance = None

def get_db(db_path: str = 'spider_data.db') -> DatabaseManager:
    """获取数据库单例"""
    global _db_instance
    if _db_instance is None:
        _db_instance = DatabaseManager(db_path)
    return _db_instance

设计亮点

  1. 连接池简化版:单例模式,避免重复创建连接
  2. 事务管理器 :用 with 语句自动提交/回滚,代码更优雅
  3. Row Factory :查询结果可以用字段名取值(row['title'] 而不是 row[1]
  4. 超时设置:避免死锁时卡死

3. 入库管道 (pipeline.py)

python 复制代码
"""
数据入库管道:实现 Upsert 逻辑
"""
from typing import List, Dict, Any
from db_manager import DatabaseManager
import time


class SQLitePipeline:
    """SQLite 入库管道 - 支持幂等写入"""
    
    def __init__(self, db: DatabaseManager, table_name: str = 'news'):
        self.db = db
        self.table_name = table_name
        
        # 统计指标
        self.stats = {
            'insert_count': 0,    # 新增记录数
            'update_count': 0,    # 更新记录数
            'error_count': 0,     # 失败记录数
            'total_time': 0       # 总耗时
        }
    
    def process_item(self, item: Dict[str, Any]) -> bool:
        """
        处理单条数据(插入或更新)
        Args:
            item: 数据字典,必须包含 url 字段
        Returns:
            成功返回 True,失败返回 False
        """
        start_time = time.time()
        
        try:
            # 构造 Upsert SQL
            sql = f"""
            INSERT INTO {self.table_name} 
                (url, title, content, pub_time, source, author, view_count, raw_html)
            VALUES 
                (:url, :title, :content, :pub_time, :source, :author, :view_count, :raw_html)
            ON CONFLICT(url) DO UPDATE SET
                title = excluded.title,
                content = excluded.content,
                pub_time = excluded.pub_time,
                source = excluded.source,
                author = excluded.author,
                view_count = excluded.view_count,
                raw_html = excluded.raw_html,
                updated_at = CURRENT_TIMESTAMP;
            """
            
            # 准备参数(失字段用 None 填充)
            params = {
                'url': item.get('url'),
                'title': item.get('title'),
                'content': item.get('content'),
                'pub_time': item.get('pub_time'),
                'source': item.get('source'),
                'author': item.get('author'),
                'view_count': item.get('view_count', 0),
                'raw_html': item.get('raw_html')
            }
            
            # 执行插入/更新
            cursor = self.db.execute(sql, params)
            
            # 判断是插入还是更新
            if cursor.lastrowid > 0:
                self.stats['insert_count'] += 1
            else:
                self.stats['update_count'] += 1
            
            self.db.conn.commit()
            
            self.stats['total_time'] += time.time() - start_time
            return True
            
        except Exception as e:
            self.stats['error_count'] += 1
            print(f"❌ 入库失败: {item.get('url', 'N/A')} - {e}")
            return False
    
    def process_items_batch(self, items: List[Dict[str, Any]], batch_size: int = 100):
        """
        批量处理数据(性能优化版)
        Args:
            items: 数据列表
            batch_size: 每批次大小
        """
        total = len(items)
        print(f"📦 开始批量入库,共 {total} 条数据...")
        
        for i in range(0, total, batch_size):
            batch = items[i:i + batch_size]
            
            with self.db.transaction():
                for item in batch:
                    self.process_item(item)
            
            # 进度提示
            processed = min(i + batch_size, total)
            print(f"  ├─ 已处理 {processed}/{total} ({processed/total*100:.1f}%)")
        
        self._print_summary()
    
    def _print_summary(self):
        """打印统计摘要"""
        print("\n" + "="*50)
        print("📊 入库统计摘要")
        print("="*50)
        print(f"✅ 新增记录: {self.stats['insert_count']}")
        print(f"🔄 更新记录: {self.stats['update_count']}")
        print(f"❌ 失败记录: {self.stats['error_count']}")
        print(f"⏱️  总耗时: {self.stats['total_time']:.2f}秒")
        
        if self.stats['insert_count'] + self.stats['update_count'] > 0:
            avg_time = self.stats['total_time'] / (self.stats['insert_count'] + self.stats['update_count'])
            print(f"📈 平均速度: {avg_time*1000:.2f}ms/条")
        print("="*50 + "\n")

核心逻辑解析

Upsert SQL 详解
sql 复制代码
INSERT INTO news (url, title, ...) VALUES (:url, :title, ...)
ON CONFLICT(url) DO UPDATE SET
    title = excluded.title,
    ...
  • ON CONFLICT(url):检测到 url 冲突时触发
  • excluded.title:指的是你刚才想插入的新值
  • updated_at = CURRENT_TIMESTAMP:自动更新时间戳
插入 vs 更新判断
python 复制代码
if cursor.lastrowid > 0:
    # 新插入的记录,lastrowid 是新分配的 ID
    self.stats['insert_count'] += 1
else:
    # 更新操作,lastrowid 为 0
    self.stats['update_count'] += 1
批量处理优化
python 复制代码
for i in range(0, total, batch_size):
    batch = items[i:i + batch_size]
    with self.db.transaction():  # 每100条一个事务
        for item in batch:
            self.process_item(item)

为什么分批?

  • 单条单事务太慢(每次都要写磁盘)
  • 全部一个事务风险大(一条失败全回滚)
  • 100条一批是个经验值,既快又稳

4. 运行入口 (run_import.py)

python 复制代码
#!/usr/bin/env python3
"""
数据入库运行脚本
用法: python run_import.py test_data.jsonl
"""
import sys
import json
from pathlib import Path
from db_manager import DatabaseManager
from pipeline import SQLitePipeline
from models import get_all_schemas


def load_jsonl(filepath: str):
    """加载 JSONL 文件"""
    data = []
    with open(filepath, 'r', encoding='utf-8') as f:
        for line_num, line in enumerate(f, 1):
            line = line.strip()
            if line:
                try:
                    data.append(json.loads(line))
                except json.JSONDecodeError as e:
                    print(f"⚠️  第{line_num}行解析失败: {e}")
    return data


def main():
    if len(sys.argv) < 2:
        print("❌ 用法: python run_import.py <数据文件.jsonl>")
        sys.exit(1)
    
    input_file = sys.argv[1]
    
    if not Path(input_file).exists():
        print(f"❌ 文件不存在: {input_file}")
        sys.exit(1)
    
    # 1. 初始化数据库
    print("🔧 初始化数据库...")
    db = DatabaseManager('spider_data.db')
    db.create_tables(get_all_schemas())
    
    # 2. 加载数据
    print(f"\n📂 加载数据: {input_file}")
    items = load_jsonl(input_file)
    print(f"✅ 加载完成,共 {len(items)} 条\n")
    
    # 3. 批量入库
    pipeline = SQLitePipeline(db, table_name='news')
    pipeline.process_items_batch(items, batch_size=100)
    
    # 4. 查询验证
    print("🔍 验证入库结果...")
    cursor = db.execute("SELECT COUNT(*) as total FROM news;")
    total = cursor.fetchone()['total']
    print(f"✅ 数据库当前共有 {total} 条记录\n")
    
    # 5. 展示最新5条
    print("📋 最新入库的5条数据:\n")
    cursor = db.execute("""
        SELECT id, url, title, created_at, updated_at 
        FROM news 
        ORDER BY id DESC 
        LIMIT 5;
    """)
    
    for row in cursor.fetchall():
        print(f"ID: {row['id']}")
        print(f"  URL: {row['url']}")
        print(f"  标题: {row['title']}")
        print(f"  创建: {row['created_at']}")
        print(f"  更新: {row['updated_at']}")
        print()
    
    db.close()
    print("🎉 入库完成!")


if __name__ == '__main__':
    main()

5. 生成测试数据 (generate_test_data.py)

python 复制代码
"""生成测试数据"""
import json
from datetime import datetime, timedelta


def generate_test_data():
    """生成100条测试新闻数据"""
    data = []
    
    # 第一批:50条正常数据
    for i in range(50):
        data.append({
            'url': f'https://example.com/news/{i}',
            'title': f'测试新闻标题 {i}',
            'content': f'这是第{i}篇新闻的正文内容,包含了很多有价值的信息。' * 5,
            'pub_time': (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d'),
            'source': 'example.com',
            'author': f'作者{i % 10}',
            'view_count': 1000 + i * 10
        })
    
    # 第二批:10条重复URL(测试 Upsert)
    for i in range(10):
        data.append({
            'url': f'https://example.com/news/{i}',  # 和前面重复
            'title': f'更新后的标题 {i}',  # 标题改了
            'content': f'内容也更新了 {i}',
            'pub_time': datetime.now().strftime('%Y-%m-%d'),
            'source': 'example.com',
            'author': f'新作者{i}',
            'view_count': 5000 + i * 100  # 浏览量涨了
        })
    
    # 第三批:40条新数据
    for i in range(50, 90):
        data.append({
            'url': f'https://newsite.com/article/{i}',
            'title': f'另一个站点的新闻 {i}',
            'content': f'来自新站点的内容 {i}',
            'pub_time': datetime.now().strftime('%Y-%m-%d'),
            'source': 'newsite.com',
            'author': f'记者{i % 5}'
        })
    
    # 保存为 JSONL
    with open('test_data.jsonl', 'w', encoding='utf-8') as f:
        for item in data:
            f.write(json.dumps(item, ensure_ascii=False) + '\n')
    
    print(f"✅ 生成 {len(data)} 条测试数据 → test_data.jsonl")
    print(f"  ├─ 50条正常数据")
    print(f"  ├─ 10条重复URL(用于测试Upsert)")
    print(f"  └─ 40条新数据")


if __name__ == '__main__':
    generate_test_data()

运行与验证

Step 1: 生成测试数据

json 复制代码
python generate_test_data.py

输出:

json 复制代码
✅ 生成 100 条测试数据 → test_data.jsonl
  ├─ 50条正常数据
  ├─ 10条重复URL(用于测试Upsert)
  └─ 40条新数据

Step 2: 第一次入库

json 复制代码
python run_import.py test_data.jsonl

输出:

json 复制代码
🔧 初始化数据库...
✅ 数据库连接成功: spider_data.db
📋 创建表: news
  └─ 索引创建成功
  └─ 索引创建成功
  └─ 索引创建成功
✅ 所有表和索引创建完成

📂 加载数据: test_data.jsonl
✅ 加载完成,共 100 条

📦 开始批量入库,共 100 条数据...
  ├─ 已处理 100/100 (100.0%)

==================================================
📊 入库统计摘要
==================================================
✅ 新增记录: 90      # 注意:只有90条新增
🔄 更新记录: 10      # 10条是更新(因为URL重复了)
❌ 失败记录: 0
⏱️  总耗时: 0.15秒
📈 平均速度: 1.50ms/条
==================================================

🔍 验证入库结果...
✅ 数据库当前共有 90 条记录   # 去重后只有90条

📋 最新入库的5条数据:
...

验证成功! 100条数据入库,因为有10条URL重复,所以实际只有90条记录,重复的被更新了。

Step 3: 再跑一次(幂等性测试)

json 复制代码
python run_import.py test_data.jsonl

输出:

json 复制代码
✅ 新增记录: 0       # 一条新增都没有!
🔄 更新记录: 100     # 全部是更新
❌ 失败记录: 0

✅ 数据库当前共有 90 条记录  # 还是90条,没膨胀!

完美! 反复跑也不会重复入库,这就是幂等性。

进阶技巧

1. 组合唯一键(多字段去重)

有时候单个 URL 不够用,比如评论数据:

sql 复制代码
CREATE TABLE comments (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    article_url TEXT NOT NULL,
    user_id TEXT NOT NULL,
    content TEXT,
    
    -- 组合唯一键:同一篇文章的同一用户只能有一条评论
    UNIQUE(article_url, user_id)
);

Upsert 时:

sql 复制代码
ON CONFLICT(article_url, user_id) DO UPDATE SET ...

2. 部分字段更新(不覆盖某些字段)

sql 复制代码
ON CONFLICT(url) DO UPDATE SET
    title = excluded.title,
    content = excluded.content,
    -- view_count 不更新(保留旧值)
    updated_at = CURRENT_TIMESTAMP
WHERE excluded.content IS NOT NULL;  -- 只在新内容非空时更新

3. 软删除(不真删数据)

sql 复制代码
ALTER TABLE news ADD COLUMN is_deleted INTEGER DEFAULT 0;

-- "删除"时只标记
UPDATE news SET is_deleted = 1 WHERE url = '...';

-- 查询时过滤
SELECT * FROM news WHERE is_deleted = 0;

4. 数据版本控制

sql 复制代码
CREATE TABLE news_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    news_id INTEGER,
    title TEXT,
    content TEXT,
    version INTEGER,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 每次更新时,先把旧版本存到 history 表
INSERT INTO news_history SELECT id, title, content, version, updated_at FROM news WHERE id = ?;
UPDATE news SET ... WHERE id = ?;

性能优化建议

1. 索引不是越多越好

python 复制代码
# ❌ 不好:给所有字段都加索引
CREATE INDEX idx_title ON news(title);
CREATE INDEX idx_content ON news(content);
CREATE INDEX idx_author ON news(author);
...

# ✅ 好:只给常查询的字段加
CREATE INDEX idx_pub_time ON news(pub_time);  # WHERE pub_time > '2026-01-01'
CREATE INDEX idx_source ON news(source);      # WHERE source = 'xxx'

索引会让写入变慢,只在真正需要时加。

2. 事务批量提交

python 复制代码
# ❌ 每条都提交(慢10倍)
for item in items:
    db.execute("INSERT ...")
    db.conn.commit()

# ✅ 批量提交
for item in items:
    db.execute("INSERT ...")
db.conn.commit()  # 只提交一次

3. 使用 executemany

python 复制代码
# ✅ 更快
sql = "INSERT INTO news (url, title) VALUES (?, ?);"
params = [(item['url'], item['title']) for item in items]
db.executemany(sql, params)

但注意:executemany 不支持 ON CONFLICT,所以 Upsert 场景还是得循环。

常见问题 FAQ

Q1: 为什么不用 INSERT OR REPLACE

sql 复制代码
-- 有些教程教你这样写
INSERT OR REPLACE INTO news (url, title) VALUES ('...', '...');

问题REPLACE先删除旧记录再插入新记录,导致:

  • id 变了(自增 ID 重新分配)
  • created_at 也变了(变成新插入的时间)

ON CONFLICT ... DO UPDATE真正的更新idcreated_at 都不会变。

结论 :除非你确实想删除重建,否则用 ON CONFLICT

Q2: 数据库文件会不会越来越大?

会的,即使删除数据,磁盘空间也不会自动释放。需要定期执行:

python 复制代码
# 清理已删除的数据并压缩数据库
db.execute("VACUUM;")

注意VACUUM 会锁表,适合在凌晨低峰期执行。

Q3: 并发写入怎么办?

SQLite 的写锁是数据库级别的,同一时刻只能有一个写操作。如果多个爬虫进程同时写入:

python 复制代码
# 方案1:写入队列(推荐)
# 爬虫进程 → 写入队列 → 单个写入进程 → 数据库

# 方案2:分库
# 爬虫1 → db_1.db
# 爬虫2 → db_2.db
# 最后合并

# 方案3:升级到 MySQL/PostgreSQL(支持真正的并发写入)

对于爬虫来说:单进程写入 + 批量提交,每秒几千条完全够用。

Q4: 如何备份数据库?

bash 复制代码
# 方案1:直接复制文件(停止写入时)
cp spider_data.db backup/spider_data_20260124.db

# 方案2:在线备份(不停止服务)
sqlite3 spider_data.db ".backup backup/spider_data_20260124.db"

# 方案3:导出 SQL
sqlite3 spider_data.db .dump > backup.sql

Q5: 怎么查看数据库内容?

bash 复制代码
# 命令行工具
sqlite3 spider_data.db
> SELECT * FROM news LIMIT 10;

# 图形化工具(推荐新手)
# - DB Browser for SQLite (免费,跨平台)
# - DBeaver (功能强大)

实战建议 💡

1. 表设计原则

python 复制代码
# ✅ 好的表设计
CREATE TABLE news (
    id INTEGER PRIMARY KEY,
    url TEXT NOT NULL UNIQUE,  # 唯一键
    title TEXT NOT NULL,        # 必填字段
    content TEXT,               # 可空字段
    created_at TIMESTAMP        # 元数据
);

# ❌ 不好的设计
CREATE TABLE news (
    url TEXT,                   # 没主键,没约束
    title TEXT,
    some_weird_field TEXT       # 字段名不清晰
);

原则

  • 主键必须有(自增 ID 最简单)
  • 唯一键要明确(防重复)
  • 字段名见名知意(pub_timetime1 好)
  • 必填字段加 NOT NULL

2. 字段类型选择

数据类型 SQLite 类型 示例
字符串 TEXT 标题、URL、正文
整数 INTEGER ID、浏览量、点赞数
浮点数 REAL 评分、价格
日期时间 TEXT 2026-01-24 15:30:00
布尔值 INTEGER 0/1 (SQLite 没有 BOOLEAN)
JSON TEXT {"key": "value"}

SQLite 的"特殊之处" :类型是建议性的TEXT 列也能存数字。但为了规范,还是要按类型定义。

3. 日志与监控

python 复制代码
class SQLitePipeline:
    def process_item(self, item):
        try:
            # ... 入库逻辑
            
            # 记录日志
            logging.info(f"入库成功: {item['url']}")
            
        except sqlite3.IntegrityError as e:
            # 唯一键冲突(正常情况,说明是更新)
            logging.debug(f"更新记录: {item['url']}")
            
        except Exception as e:
            # 其他错误(需要告警)
            logging.error(f"入库失败: {item['url']} - {e}")
            
            # 失败数据落盘
            with open('failed_items.jsonl', 'a') as f:
                f.write(json.dumps(item, ensure_ascii=False) + '\n')

好处:出问题时有迹可查,失败的数据不会丢。

4. 定期维护

python 复制代码
# maintenance.py
def daily_maintenance(db):
    """每日维护任务"""
    
    # 1. 统计数据量
    cursor = db.execute("SELECT COUNT(*) FROM news;")
    total = cursor.fetchone()[0]
    print(f"📊 当前数据量: {total:,}")
    
    # 2. 检查重复(防止去重失效)
    cursor = db.execute("""
        SELECT url, COUNT(*) as cnt 
        FROM news 
        GROUP BY url 
        HAVING cnt > 1;
    """)
    duplicates = cursor.fetchall()
    if duplicates:
        print(f"⚠️  发现 {len(duplicates)} 个重复URL!")
    
    # 3. 清理旧数据(可选)
    db.execute("""
        DELETE FROM news 
        WHERE created_at < date('now', '-365 days');
    """)
    print("🗑️  清理1年前的旧数据")
    
    # 4. 压缩数据库
    db.execute("VACUUM;")
    print("💾 数据库已压缩")

完整示例:真实爬虫集成

python 复制代码
# spider_with_db.py
"""
完整爬虫示例:采集 → 清洗 → 入库
"""
import requests
from bs4 import BeautifulSoup
from db_manager import DatabaseManager
from pipeline import SQLitePipeline
from models import get_all_schemas


class NewsSpider:
    """新闻爬虫 + 数据库入库"""
    
    def __init__(self):
        # 初始化数据库
        self.db = DatabaseManager('spider_data.db')
        self.db.create_tables(get_all_schemas())
        
        # 初始化入库管道
        self.pipeline = SQLitePipeline(self.db)
    
    def fetch_list_page(self, url: str):
        """采集列表页"""
        resp = requests.get(url, timeout=10)
        soup = BeautifulSoup(resp.text, 'html.parser')
        
        # 提取详情链接
        links = []
        for a in soup.select('.news-item a'):
            links.append(a['href'])
        
        return links
    
    def fetch_detail_(resp.text, 'html.parser')
        
        # 解析字段
        item = {
            'url': url,
            'title': soup.select_one('h1.title').get_text(strip=True),
            'content': soup.select_one('.content').get_text(strip=True),
            'pub_time': soup.select_one('.pub-time').get_text(strip=True),
            'source': soup.select_one('.source').get_text(strip=True),
            'raw_html': resp.text  # 保留原始HTML
        }
        
        return item
    
    def run(self, list_url: str):
        """运行爬虫"""
        print(f"🚀 开始采集: {list_url}\n")
        
        # 1. 采集列表页
        detail_urls = self.fetch_list_page(list_url)
        print(f"📋 发现 {len(detail_urls)} 条新闻\n")
        
        # 2. 逐个采集详情页并入库
        for idx, url in enumerate(detail_urls, 1):
            try:
                print(f"[{idx}/{len(detail_urls)}] 采集: {url}")
                
                # 采集
                item = self.fetch_detail_page(url)
                
                # 入库(自动去重)
                self.pipeline.process_item(item)
                
            except Exception as e:
                print(f"  └─ ❌ 失败: {e}")
        
        # 3. 打印统计
        self.pipeline._print_summary()
        
        # 4. 关闭连接
        self.db.close()


if __name__ == '__main__':
    spider = NewsSpider()
    spider.run('https://example.com/news/list')

完整流程

json 复制代码
1. 采集列表页 → 获取详情链接
2. 采集详情页 → 解析字段
3. 入库管道 → Upsert 去重
4. 统计报告 → 新增/更新数

运行一次 :新增 50 条
再运行一次:新增 0 条,更新 50 条(幂等!)

验收标准 ✅

完成本节后,你的入库系统应该:

  1. **建表成- ✅ 自动创建表和索引

    • ✅ 唯一键约束生效
    • ✅ 字段类型正确
  2. 入库功能

    • ✅ 新数据正常插入
    • ✅ 重复URL自动更新(不报错)
    • ✅ 批量写入速度合理(>100条/秒)
  3. 幂等性

    • ✅ 反复跑不会重复入库
    • ✅ 数据库记录数稳定
    • ✅ 更新字段能覆盖旧值
  4. 统计报告

    • ✅ 新增/更新数准确
    • ✅ 失败数有记录
    • ✅ 耗时统计清晰
  5. 错误处理

    • ✅ 缺少必填字段能报错
    • ✅ 数据库锁等待不卡死
    • ✅ 失败数据有日志

下期预告 🔮

今天我们搞定了 SQLite 入库,解决了"数据往哪存"的问题。但在真实项目中,你往往需要:

中途停止后,从断点继续采集 📍

具体来说:

  • 任务状态管理(RUNNING/SUCCESS/FAILED)
  • 失败队列与重试机制
  • 断电后如何恢复?
  • 重跑时如何避免重复?

下一节:断点续爬 - 任务状态表 + 失败重试 + 幂等恢复

让你的爬虫"摔倒了也能自己爬起来"!💪

完整代码总结 📦

json 复制代码
sqlite_pipeline/
├── models.py              # 表结构定义 (50行)
├── db_manager.py          # 数据库管理器 (120行)
├── pipeline.py            # 入库管道 (150行)
├── run_import.py          # 运行入口 (80行)
├── generate_test_data.py  # 测整爬虫示例 (100行)

总计: ~560行 纯Python代码
依赖: 仅需标准库 sqlite3

运行方式

bash 复制代码
# 1. 生成测试数据
python generate_test_data.py

# 2. 初次入库
python run_import.py test_data.jsonl
# 输出: 新增90条,更新10条

# 3. 再次入库(验证幂等)
python run_import.py test_data.jsonl
# 输出: 新增0条,更新100条

# 4. 查看数据库
sqlite3 spider_data.db "SELECT COUNT(*) FROM news;"
# 输出: 90 (无论跑多少次都是90)

总结

这节课我们从零搭建了一个生产级的 SQLite 入库系统,核心收获:

唯一键设计 - 用 url 防止数据重复,组合键应对复杂场景

Upsert 逻辑 - ON CONFLICT DO UPDATE 实现幂等写入

批量优化 - 事务批量提交,性能提升 10 倍

元数据字段 - created_at/updated_at 追溯数据历史

统计报告 - 新增/更新数一目了然,监控数据质量

最重要的思想:幂等性

无论运行多少次,同一份数据在库里只有一条记录,且内容保持最新。

这不仅让你的爬虫更稳定,也让后续的断点续爬、增量更新、数据修复变得简单。

记住:数据入库不是终点,而是数据生命周期的起点。好的入库设计,能让你在后续的数据清洗、分析、展示环节少踩无数坑。


代码已测试可运行,建议自己跑一遍感受 Upsert 的魅力!下期见~

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
DN20202 小时前
好用的机器人销售供应商
python
爬山算法2 小时前
Hibernate(64)如何在Java EE中使用Hibernate?
python·java-ee·hibernate
lixin5565562 小时前
基于迁移学习的图像分类增强器
java·人工智能·pytorch·python·深度学习·语言模型
翱翔的苍鹰3 小时前
多Agent智能体架构设计思路
人工智能·pytorch·python
小毅&Nora3 小时前
【后端】【Python】① Windows系统下Python环境变量设置指南
python·pip
Rabbit_QL10 小时前
【水印添加工具】从零设计一个工程级 Python 图片水印工具:WaterMask 架构与实现
开发语言·python
曲幽11 小时前
FastAPI多进程部署:定时任务重复执行?手把手教你用锁搞定
redis·python·fastapi·web·lock·works
森屿~~12 小时前
AI 手势识别系统:踩坑与实现全记录 (PyTorch + MediaPipe)
人工智能·pytorch·python
忧郁的橙子.13 小时前
26期_01_Pyhton文件的操作
开发语言·python