17 KiB
入库幂等设计(来源级去重 + 街拍实体键引入月份)
- 日期:2026-09-20
- 状态:设计已确认,待编写实现计划
- 范围:
- 爬虫入库管线(
ingest)的来源级幂等(第 3.1–3.5 节) - 街拍实体键
(city, year)→(city, year, month)(第 3.6 节)
- 爬虫入库管线(
1. 背景
1.1 现状:链路上现有四道去重,但没有一道能挡「同一篇文章重爬」
| 层 | 现有机制 | 能挡住 | 挡不住 |
|---|---|---|---|
| 传输 | ingest_nonces + HMAC 时间窗 |
同一请求被重放 | 爬虫重跑(每次生成新 nonce) |
| 内容 | downloadAndUpload 的 sha1 内容寻址 key |
重复图产生孤儿文件 | 重复草稿行 |
| 实体 | RunwayIDByEntity / StreetSnapIDByEntity |
— | 见 1.2 |
| 晋升 | SaveRunwayFromDraft / SaveStreetSnapFromDraft 聚合 approved 兄弟草稿图 |
重复正式行 | 只聚合 approved,pending 重复照留 |
1.2 「只判正式表」是刻意设计,不是缺陷
processRunway / processStreet 中的实体键去重只查正式表、不查 pending 草稿,注释写明了原因:
否则多来源(Vogue + theImpression)爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
因此不能简单地把实体去重扩展到草稿表——那会连带干掉多来源聚合。
1.3 根因:设计里没有「来源文章」这个维度
来源级幂等键历史上存在过,且爬虫侧还有 ExistsSourceURLs 预检,但已被移除。爬虫代码中留有原文:
// 历史上这里会调用后端 ExistsSourceURLs 预检、在抓取前跳过已爬图集以省流量;
// 现 source_url 已从 ingestion 管线移除,后端按「实体键(品牌+季节码+系列)」去重,
当前 dto.RunwayIngest 不含任何来源字段,后端无从判断「这篇是否已爬过」。
1.4 实测印证(来源维度缺失)
同一篇 theimpression 哥本哈根街拍,间隔 5 分钟上报两次(job 96 / job 98),产生 2 条 pending 草稿。
1.5 第二个独立缺陷:街拍 year=0 是一个「吸附桶」
街拍实体键为 (city, year),而 theImpression 的 parseYear 取不到年份时明确返回 0:
// 提取不到时返回 0(未知年份),不再回退到「当前年份」——那会把旧街拍伪造成今年。
而 theImpression 标题实测基本不含年份(实测日志:[Warning] 标题未含年份,按未知(0)处理: The Best Street Style From Copenhagen Fashion Week)。
后果链:
- 首次抓取产生
(Copenhagen, 0)草稿。 - 该草稿被审核通过、晋升到
street_snaps后,实体键(Copenhagen, 0)永久占位。 - 此后任何标题不含年份的 Copenhagen 街拍都会被判重、静默
MarkDone丢弃,不是延迟而是永久进不来。 - 推广:每个城市永远只能存在一张街拍专辑。
此外,即使修掉 year=0,(city, year) 仍然无法区分同城一年内的两季时装周(如 2 月 FW / 8 月 SS)——因为命中实体键的行为是跳过而非合并,第二季会被整批丢弃。
2. 决策记录
| 决策点 | 结论 | 理由 |
|---|---|---|
| 重爬语义 | 纯跳过:命中即整个任务 MarkDone,不下载、不写草稿 |
最省流量;有人工审核兜底,需补齐时后台手动重试 |
| 来源幂等键 | (source, source_url) |
来源站 + 文章详情页地址 |
| 「已存在」边界 | 任何状态都算(含 rejected、含软删) |
用户明确:爬过的文章不会更新,故「爬过」即终态 |
| 检查位置 | 方案 1:worker 处理前查重 | 与现有「去重 → 命中即 MarkDone」模式同构;202 接口保持只做 Enqueue |
| 来源索引形态 | 部分唯一索引,WHERE source_url <> '' |
存量行 source_url 为空串,全量唯一索引会因 ('','') 冲突导致建索引失败 → 启动崩溃 |
街拍 year 来源 |
抓取时间(不再回退 0) | 标题实测不含年份;0 会形成「每城永远一个专辑」的吸附桶(见 1.5) |
街拍 month |
进实体键 → (city, year, month) |
(city, year) 仍会撞同城两季时装周;月粒度可避免「整季被丢弃」 |
street 侧 parseYear |
删除(失去调用方) | year 与 month 必须同源才自洽;「年份取标题、月份取抓取」会产出 (Copenhagen, 2024, 9) 这类无意义键 |
| URL 解析年份优先 | 不做 | URL 中虽含季节年份(如 ...-spring-2027/),但形状依赖强、收益仅为精度提升,按 YAGNI 排除 |
| 历史测试数据 | 清理 | 用户确认:数据量小、全部为测试数据 |
2.1 已评估并否决的方案
- 方案 2(入队时查重):在
Submit()中先查草稿表,命中即返回「已跳过」。好处是省队列槽位,但把 DB 查询塞进刻意做薄的 ingest 接口,且引入 TOCTOU——并发提交时双方都查不到、都入队,最终仍须靠唯一索引兜底。多一层代码,未换取正确性。 - 方案 3(只靠唯一索引):不做前置查询,靠
INSERT冲突兜底。代价是每次重爬都要把全部图片下载并上传一遍才发现冲突,恰好浪费最想省下的那部分时间,直接违背目标。 - 爬虫侧预检(恢复
ExistsSourceURLs):可连文章页都不抓,但抓一个 HTML 页的成本与后台下载数十张图不在一个量级,收益太小且引入额外跨服务往返,按 YAGNI 排除。 - 街拍月份只做展示字段、不进键:可减少专辑碎片化,但同一城市同一年的第二季时装周会被整批丢弃(见 1.5 第 4 点)。用户明确选择进键。
- 爬虫解析 URL 中的季节年份:精度更高(
spring-2027→ 2027),但强依赖 URL 形状,按 YAGNI 排除。
3. 详细设计
3.1 契约:爬虫 → 后端
internal/dto/ingest.go 的 RunwayIngest 与 spider 侧 spider/internal/ingest/payload.go 的 RunwayIngest 两侧同时新增三个字段,JSON 名必须逐字一致:
| 字段 | JSON 名 | 类型 | 含义 | 取值 |
|---|---|---|---|---|
Source |
source |
string | 来源站标识 | vogue / theimpression |
SourceURL |
source_url |
string | 文章详情页地址 | 该篇 URL |
Month |
month |
uint8 | 抓取月份(street 用) | 1–12;runway 不填 = 0 |
填充点:
spider/internal/spider/vogue.go:Source: "vogue",SourceURL: requestURL(即现有sourceURL(info)的返回值);Month不填spider/internal/spider/theimpression.go:Source: "theimpression",SourceURL: tk.URL(即现有streetTask.URL);Year/Month均取抓取时刻的time.Now()
向后兼容:三个字段均可空。老爬虫不上送时 source_url == "",来源去重自动跳过,行为与现状完全一致。
3.2 Schema
(a)来源幂等:两张草稿主表各新增两列
使用 not null default '' 而非可空——NULL 在唯一索引中不参与比较,可空会导致「同键可重复插入」,使索引形同虚设。
source varchar(32) not null default ''
source_url varchar(512) not null default ''
对应模型:internal/model/runway_draft.go 的 BrandRunwayDraft、internal/model/street_snap_draft.go 的 StreetSnapDraft。模型上不加 uniqueIndex tag(GORM tag 无法表达部分索引条件)。
落点已变更(2026-09-21):服务启动时的自动迁移(
AutoMigrate/EnsureDedupSchema)已移除, 全库结构改由cmd/dbtool的 dump 维护。加列 / 加索引现在走db/migrations/下的一次性脚本: 对开发库执行 → 再dbtool dump -clean重新导出。本规格的列与索引已落在db/migrations/2026-09-21-03-ingest-source-idempotency.sql(已应用)。
索引定义(部分唯一索引——全量唯一索引会因存量大量 ('','') 冲突而建索引失败):
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_draft_source
ON brand_runway_drafts (source, source_url) WHERE source_url <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_draft_source
ON street_snap_drafts (source, source_url) WHERE source_url <> '';
两点取舍:
- 必须是部分索引(
WHERE source_url <> '')。存量行source_url全为空串,全量唯一索引会因大量('','')冲突而建索引失败,进而导致服务启动崩溃。该条件同时让老数据豁免。 - 不加
is_deleted条件。按决策「爬过即终态」,若把软删排除在外,则删除草稿即变相绕过幂等。
(b)街拍月份:两张街拍表各新增一列
month smallint not null default 0
对应模型:model.StreetSnapDraft、model.StreetSnap。由 AutoMigrate 托管,不建索引——现有 (city, year) 本就无索引,街拍表体量很小。
3.3 判定逻辑:worker 处理前查重
新增仓储方法(接口仍定义在消费方 IngestRepository,与项目现有做法一致):
// SourceDraftExists 按来源键 (source, source_url) 判断该文章是否已入库过。
// sourceURL 为空时直接返回 false(老爬虫兼容)。不限 status、不限 is_deleted,
// 与 uq_*_draft_source 部分唯一索引口径严格一致(决策:爬过即终态)。
SourceDraftExists(ctx context.Context, kind, source, sourceURL string) (bool, error)
实现要点:
sourceURL == ""→ 立即返回false(老爬虫兼容路径)。kind非runway/street→ 返回false(防御性;process()实际只会传入这两者)。- 否则按
kind选择草稿表,执行WHERE source = ? AND source_url = ? LIMIT 1的存在性查询。 source为空但source_url非空时,仍按('', <source_url>)参与去重,不做特殊处理。 实际接入的两个爬虫都会同时上送两字段,此规则仅为消除歧义。
插入点在 internal/service/ingest_service.go 的 process() 中:解析 payload、归一化 p.Kind 之后,switch p.Kind 分派 之前。此处是单一插入点,同时覆盖 runway / street 两条管线,且早于品牌校验与实体键去重。
命中后:按现有日志风格记录 + MarkDone + return,格式与现有实体去重日志对齐:
[ingest] job=%d %s 来源去重命中(source=%s url=%s),跳过 耗时=%v
3.4 写入草稿时带上来源
processRunway / processStreet 构造 draft 时填充 Source / SourceURL。这是唯一索引真正生效的落点:多实例并发时两个任务的前置查询都查不到,第二个 INSERT 将撞上唯一索引。
3.5 错误处理:唯一冲突必须当「跳过」而非「失败」
CreateRunwayDraft / CreateStreetSnapDraft 返回唯一冲突时,绝不能走 failOrRetry——否则会按指数退避白重试 3 次,最终在后台堆出一批假故障任务。
正确做法:识别冲突(复用 internal/repository/ingest_repository.go 中现成的 isDuplicateKey(err))→ 记录日志 → MarkDone,与 3.3 的前置查重共用同一条出口语义。
冲突时无需回滚已上传的图:两张草稿的图片 URL 完全相同,sha1 内容寻址会推出同一个对象 key,属覆盖写,不产生孤儿文件。此点须在代码注释中写明,避免后来人误加 cleanupUploads。
3.6 街拍实体键引入月份(已作废)
本节已作废(2026-09-21),由
2026-09-21-duplicate-review-design.md§3.7 取代。原设计把街拍实体键改为
(city, year, month)(同城同月自动收敛成一张专辑)。用户随后取消了「按月区分」与「合并成一张专辑」两项需求,改为一篇文章一张专辑,实体键变为(source, source_url)。影响:
- 本节描述的全部改动不再执行(原实现计划的任务 6 已删除)。
- 但 §3.1(契约新增
source/source_url)仍然有效、且更关键——这两个字段现在直接充当街拍实体键。Month字段不再需要(其唯一用途是月度分组):month列、spider 的Month填充、streetDraftEditable放行month、列表副标题加月份等一并取消。- 街拍
street_snaps正式表需新增source/source_url两列,见新规格 §3.7。
3.7 明确不动的部分
- runway 侧实体键去重
RunwayIDByEntity—— 保留原样(runway 有season_code,不存在街拍这种月份问题)。它与来源去重互补而非替代。 - 晋升聚合的通用机制(sibling union)—— 除 3.6 第 4 处的 month 条件外不变。
Submit/Enqueue—— 方案 1 刻意不碰,ingest 接口继续只做「入队 + 立即 202」。- 传输层 nonce 防重放 —— 不变。
4. 影响面
internal/dto/ingest.go 新增 Source / SourceURL / Month 三字段
internal/model/runway_draft.go BrandRunwayDraft 新增 source / source_url
internal/model/street_snap_draft.go StreetSnapDraft 新增 source / source_url / month
internal/model/street_snap.go StreetSnap 新增 month
internal/repository/ingest_repository.go 新增 SourceDraftExists;StreetSnapIDByEntity 加 month
internal/repository/review_repository.go SaveStreetSnapFromDraft / streetApprovedSiblingImages 加 month
internal/repository/review_repository.go streetDraftEditable 放行 month
internal/service/ingest_service.go process() 来源去重;两处填来源;processStreet 填 month
internal/service/review_service.go street 模块 Fields 加 month;卡片副标题加月份
internal/database/postgres.go EnsureDedupSchema 加两个部分唯一索引
../spider/internal/ingest/payload.go 新增三字段
../spider/internal/spider/vogue.go 填充 source / source_url
../spider/internal/spider/theimpression.go 填充 source / source_url / year / month;删除 parseYear
5. 测试
5.1 单元测试(不依赖数据库)
source_url == ""时不执行来源去重(老爬虫兼容路径)。- 命中已存在时:任务被
MarkDone,且上传器一次都未被调用(用计数 spy 上传器断言,构造方式参照现有TestFetchImagesCleansUpOnFailure)。 - 唯一冲突路径:
CreateXxxDraft返回重复键错误时任务MarkDone,且不触发重试(attempts不变)。 processStreet写入草稿时month被正确透传。
5.2 集成测试(需 PostgreSQL)
- 并发两次插入同一
(source, source_url)只保留一行。 - 存量空串行不影响索引创建,也不与他行冲突(验证部分索引生效)。
- 实体键含月份:
(city, year, month)不同月份不互相判重。
项目内已有 dedup_integration_test.go 可作参照。
6. 存量数据清理
6.1 盘点(2026-09-20 实测)
| 表 | 行数 | 内容 |
|---|---|---|
street_snap_drafts |
3 | id 1/2/3 ← job 95/96/98,均 Copenhagen / year=0、同标题、各 59 图、pending |
street_snap_draft_images |
177 | |
street_snaps / street_snap_images |
0 | 街拍从未晋升 |
brand_runway_drafts |
5 | id 88–92,全 approved,brand_id=7 |
brand_runways |
5 | 与上表一一对应 |
brand_runway_draft_images / brand_runway_images |
556 / 672 | |
ingest_jobs |
96 | 队列历史 |
6.2 结论
用户确认全部为测试数据,执行清理。
清理方式:
- 删除上表全部业务行(草稿主表 + 明细表 + 正式表 + 图片表)。按外键顺序或先删明细。
- 清理后,原被引用的 S4 对象成为孤儿。复用既有机制收尾:由
media_cleanup任务按引用计数判定,归零者才真删(purgeOrphanImages),不手写批量删除。 ingest_jobs一并清空,取得干净的监控起点。
6.3 与 3.2 部分索引的相互作用
清理后存量行归零,source_url 全空串的情形不再存在——但部分索引 WHERE source_url <> '' 仍然必须保留。理由:它是「老爬虫不上送 source_url」这一兼容路径的正确性保证,而非仅为绕过历史数据;若将来再出现空串行,全量唯一索引会再次导致建索引失败。此点已在 3.2 说明,清理不改变该结论。
7. 待确认 / 开放问题
- 是否需要在后台任务列表、审核列表中展示
source(便于人工判断来源)?当前设计未包含 UI 改动,按 YAGNI 暂缓。
8. 回滚
设计对既有行为基本是纯增量的,回滚成本低:
- 删掉两个部分唯一索引(
DROP INDEX IF EXISTS uq_br_draft_source/uq_ss_draft_source)即恢复「无来源去重」的旧行为;相关列可保留不动(空闲列无害)。 - 街拍月份回滚需同时回退 3.6 表中 4 处落点(任一处漏改都会造成晋升/去重口径不一致),因此该部分不建议单独部分回滚,应整体回退到改动前版本。
- 爬虫侧若先于后端回滚,多送的字段会被后端 JSON 反序列化静默忽略,不会报错。
- 后端若先于爬虫回滚/部署,老爬虫不送
source_url,走兼容路径,同样不影响。 - 两侧因此不存在必须同时发布的顺序约束。