跳转至

后端-数据库-新闻链接缺少唯一约束导致并发重复

问题现象

  • 新闻列表中出现同一篇文章的多条记录,仅 ID 不同(如 ID=248 和 ID=266 的标题和链接完全相同)
  • 集中在金融监管总局(nfra.gov.cn)站点,每个链接出现 3 次重复
  • 共有 18 个链接重复,涉及 36 条冗余记录

核心问题

根因1(主要):WebNews.link 缺少数据库唯一约束

src/core/models.py:43WebNews.link 字段未设置 unique=True,而同类表 WechatArticle.url 有该约束。

根因2(次要):应用层去重存在竞态条件

modules/web-crawler/service.py:255-269 使用"先查后插"模式:

# 查询已有 -> 构建内存集合 -> 逐条检查 -> session.add() -> commit()

定时抓取 + 手动抓取并发时,两组操作同时读到"无重复"的集合,各自插入同一链接,无数据库约束兜底,导致重复。

根因3(次要):去重字段使用 OR 逻辑过于激进

原逻辑 title in existing_titles OR link in existing_links 使用 OR 判断,可能将同名不同文的内容错误跳过(虽然不会直接导致重复,但不精确)。

解决方法

1. 模型层:添加 unique=True 约束

# src/core/models.py
- link: Mapped[str] = mapped_column(String(1000), nullable=False, comment="新闻链接")
+ link: Mapped[str] = mapped_column(String(1000), unique=True, nullable=False, comment="新闻链接")

2. 服务层:改用 link 精确去重 + IntegrityError 兜底

# modules/web-crawler/service.py
- existing_titles = set()
- existing_links = set()
- select(WebNews.title, WebNews.link).where(...)
- if title in existing_titles or link in existing_links:
+ existing_links = set()
+ select(WebNews.link).where(...)
+ if link in existing_links:
+     logger.debug("skip_duplicate_link ...")
+     continue
+ existing_links.add(link)
+ try:
+     await session.commit()
+ except IntegrityError:
+     await session.rollback()

3. WeChat 模块:flush() + IntegrityError 逐条保护

# modules/wechat/wechat_crawler.py
  db.add(article)
+ try:
+     db.flush()
+ except IntegrityError:
+     db.rollback()
+     continue
  saved += 1

4. 清理历史重复数据

ssh news "cd /www/news && venv/bin/python scripts/cleanup_duplicate_news.py --fix"
# 结果:清理 18 条重复链接 → 删除 36 条冗余记录
# 创建唯一索引:uq_web_news_link、uq_wechat_articles_url

下次防错措施

快速定位重复

SELECT link, COUNT(*) AS cnt, GROUP_CONCAT(id) AS ids
FROM web_news
GROUP BY link
HAVING cnt > 1;

流程改进

  • 新建模型时检查同类模型是否已有 unique=True 约束(WechatArticle 有,WebNews 漏了)
  • 方案审核:涉及并发写入的表,必须有数据库层唯一约束兜底,不能仅依赖应用层去重

验证脚本

cd /www/news && venv/bin/python scripts/cleanup_duplicate_news.py    # 干跑
cd /www/news && venv/bin/python scripts/cleanup_duplicate_news.py --fix  # 执行清理

复发记录(2026-06-22)

新增现象

  • 服务器采集时,站点 "#8 金融监管动态" 的 6 条新闻全部失败
  • 报错:(sqlite3.IntegrityError) UNIQUE constraint failed: web_news.link
  • 进度显示 ✓ 国家金融监管总局: 采集到 6 条新闻 但实际 count=0,新闻未入库

新增根因

UNIQUE 约束生效后,_crawl_single_site 的 batch commit 遇到 IntegrityError,except 块执行 session.rollback() + count = 0整批丢弃而非仅跳过重复项。

涉及文件:modules/web-crawler/service.py - 模式 A(脚本爬虫):第 283-291 行 - 模式 B(XPath 爬虫):第 387-375 行

本次修复

批量提交 session.commit() 失败时,不再整批丢弃,而是回退到逐条提交

try:
    await session.commit()
except IntegrityError:
    await session.rollback()
    # 逐条重试:单个重复不应导致整批丢弃
    for item in results:
        ...
        session.add(news)
        try:
            await session.commit()
            count += 1
        except IntegrityError:
            await session.rollback()

commit: be60ee3fix: web-crawler IntegrityError 时逐条重试,避免整批丢弃

部署步骤

ssh news "cd /www/news && git pull origin main && systemctl restart news-backend"

复发记录(2026-06-28)

新增现象

  • 两次并发采集(定时采集 + 手动采集)同时运行时,_crawl_single_site 的逐条重试逻辑仍有竞态窗口
  • 现象与 06-22 类似:部分新闻因 UNIQUE 约束冲突而入库失败
  • 逐条 commit() 导致性能问题:N 条新闻需要 N 次数据库事务

新增根因

上一版修复(逐条重试)存在两个问题:

  1. 竞态窗口未消除:逐条重试时,定时任务和手动任务仍可交叉执行,A 任务读到"无重复"但仍可能在 B 任务的 commit 之后插入同一条 link
  2. 性能问题:N 条新闻需要 N 次独立的 session.commit(),每篇文章一个事务,延迟高

根本原因:应用层去重(SELECT ... WHERE link IN → 内存集合 → 条件判断)在并发场景下永远存在 TOCTOU 竞态窗口,无论用整批还是逐条提交。

本次修复

改用数据库原生 INSERT ... ON CONFLICT DO NOTHING,将去重逻辑完全交给数据库层:

# modules/web-crawler/service.py

- from sqlalchemy.exc import IntegrityError
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert

# 收集待插入数据 → 一次批量 INSERT,冲突自动跳过
values_list = [dict(title=..., link=..., ...), ...]
stmt = sqlite_insert(WebNews).values(values_list)
stmt = stmt.on_conflict_do_nothing(index_elements=[WebNews.link])
result = await session.execute(stmt)
await session.commit()
count = result.rowcount  # 实际插入行数

改进点: - 消除竞态ON CONFLICT DO NOTHING 是原子操作,数据库保证无竞态窗口 - 单次事务:N 条新闻只需一次 INSERT + 一次 commit() - 内存预过滤保留SELECT 查询 existing_links 仍保留,避免对已知重复发起不必要的 INSERT - 批次内去重seen_in_batch 集合防止同一批次内出现相同 link

commit: 6ed11defix: 使用 ON CONFLICT DO NOTHING 消除爬虫 UNIQUE 约束冲突

设计原则总结

经过两次复发,沉淀出核心原则:

并发写入场景的去重,最终防线必须是数据库层原子操作(UNIQUE 约束 + ON CONFLICT DO NOTHING),应用层去重仅作性能优化(减少不必要的 DB 往返)。

部署

ssh news "cd /www/news && git pull origin main && systemctl restart news-backend"