2.5 亿条 Works,全量拉不动
我们在数据治理平台里需要把 OpenAlex 的学术数据(Works、Authors、Institutions 这些)同步到本地 MySQL,再通过 StarRocks 外表做多维分析。OpenAlex 的全量 Works 超过 2.5 亿条,每次全量拉,不仅耗时巨大,还频繁触发对方的限流。
所以诉求很明确:增量拉取,而且重复运行不能留脏数据。
游标、状态表,再加一个天然主键
方案拆开就三件事。
第一,增量靠游标分页加日期过滤。OpenAlex 支持 filter=from_publication_date 和 cursor 游标分页,天然适合增量:每次从上次记下的游标继续,不用从头翻页。
第二,断点靠一张 sync_state 表。每个实体记最后游标、最后同步日期和更新时间,任务中断了能接着跑。
第三,去重靠 OpenAlex 自己的 ID。每条记录都有全局唯一 ID(W123456 这种),直接拿来当业务主键,冲突就更新,不做插入。
同步任务跑在 Temporal Worker 上,单页失败自动重试,整个流程可观测、可恢复。

请求就一个 GET
请求封装很薄,带上了 mailto,后面会讲为什么:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| type OpenAlexClient struct {
baseURL string
email string // 加入 polite pool
client *http.Client
}
type WorksResponse struct {
Meta struct {
Count int `json:"count"`
NextCursor string `json:"next_cursor"`
} `json:"meta"`
Results []Work `json:"results"`
}
func (c *OpenAlexClient) FetchWorksPage(ctx context.Context, cursor, fromDate string) (*WorksResponse, error) {
u := fmt.Sprintf("%s/works?filter=from_publication_date:%s&per_page=200&cursor=%s&mailto=%s",
c.baseURL, fromDate, url.QueryEscape(cursor), c.email)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result WorksResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
|
冲突就更新,别插入
去重写入用 GORM 的 OnConflict 子句,按 openalex_id 冲突时更新所有字段:
1
2
3
4
5
6
7
8
9
10
11
| func (r *WorkRepo) UpsertBatch(ctx context.Context, works []Work) error {
if len(works) == 0 {
return nil
}
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "openalex_id"}},
UpdateAll: true,
}).
CreateInBatches(&works, 200).Error
}
|
主循环:翻页、落库、记游标
同步主循环从状态表读游标,逐页拉取并落库,最后更新游标:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| func (s *Syncer) SyncWorks(ctx context.Context) error {
state, _ := s.stateRepo.Get(ctx, "works")
cursor := state.Cursor
if cursor == "" {
cursor = "*"
}
for {
page, err := s.client.FetchWorksPage(ctx, cursor, state.LastDate)
if err != nil {
return err // Temporal 会重试
}
if err := s.workRepo.UpsertBatch(ctx, page.Results); err != nil {
return err
}
if page.Meta.NextCursor == "" {
break
}
cursor = page.Meta.NextCursor
_ = s.stateRepo.UpdateCursor(ctx, "works", cursor)
}
return nil
}
|
四个坑
游标不是永久有效的。间隔过久再拿同一个游标去请求,可能直接报错。我们的做法是每次同步完成后检查:如果游标已过期,就回退到按日期重新拉最近 7 天的数据,靠 Upsert 兜底去重。
per_page 最大 200。一开始设的 100,翻页次数多了一倍;调到 200 之后整体耗时明显下降,代价是单次响应体变大,HTTP client 的超时要设置得合理一些。
限流和 politeness 直接挂钩。请求里带上 mailto 参数就能进 Polite Pool,限流明显宽松:不加的话大约 10 请求/秒就可能被 429,加了之后基本能跑到 20 以上。一行参数的事,没理由不加。
MySQL 写完还要进 StarRocks。我们用 Routine Load 订阅 Binlog 做同步,避免双写。偶尔 DDL 变更会把 Routine Load 暂停掉,这个只能靠监控告警兜着,不然数据就悄悄断流了。
后来
这套方案上线后稳定跑了几个月,没出过数据重复或丢失。回头看没有什么高深的地方:游标管"从哪继续",ID 当主键管"重复了怎么办",失败重试交给 Temporal。都是笨办法,拼在一起反而省心。
封面图:BinaryApe / Flickr · CC BY 2.0