后端-数据库-新闻链接缺少唯一约束导致并发重复
问题现象
- 新闻列表中出现同一篇文章的多条记录,仅 ID 不同(如 ID=248 和 ID=266 的标题和链接完全相同)
- 集中在金融监管总局(nfra.gov.cn)站点,每个链接出现 3 次重复
- 共有 18 个链接重复,涉及 36 条冗余记录
核心问题
根因1(主要):WebNews.link 缺少数据库唯一约束
src/core/models.py:43 中 WebNews.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: be60ee3 — fix: 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 次数据库事务
新增根因
上一版修复(逐条重试)存在两个问题:
- 竞态窗口未消除:逐条重试时,定时任务和手动任务仍可交叉执行,A 任务读到"无重复"但仍可能在 B 任务的 commit 之后插入同一条 link
- 性能问题: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: 6ed11de — fix: 使用 ON CONFLICT DO NOTHING 消除爬虫 UNIQUE 约束冲突
设计原则总结
经过两次复发,沉淀出核心原则:
并发写入场景的去重,最终防线必须是数据库层原子操作(UNIQUE 约束 + ON CONFLICT DO NOTHING),应用层去重仅作性能优化(减少不必要的 DB 往返)。
部署
ssh news "cd /www/news && git pull origin main && systemctl restart news-backend"