第一版主键就错了
第一版表是这么建的:主键 request_id,属性列放 keyword、day、status、credits_charged 和整段 payload。
写入很顺:SerpBase 一次网页搜索 1 credit,响应原样落库。等到要查「某个关键词最近 30 天的排名」时,问题来了:Tablestore 只能按主键做范围扫描,request_id 是无序的,按 keyword + day 查等于全表扫。
那一刻我才意识到,这张表的查询方式在建模时就被主键定死了——字段可以后加,主键顺序不行。
为什么选 Tablestore
快照数据有三个特点:写多读少、按时间范围查、单条记录比较大(整段 JSON)。关系库能存,但按天分区、冷热分离、扩容都要自己操心。
Tablestore 的模型刚好贴合:宽列、主键有序、按范围扫描、按量计费。它不是分析型数据库,做聚合不如 OLAP,但做「按关键词 + 时间取快照」这件事很顺手。
主键顺序决定查询方式
改成三列主键:
keyword (STRING) | day (STRING) | request_id (STRING)
顺序不是随便排的。Tablestore 的主键是有序的,范围扫描从第一列开始匹配:
- 按
keyword查一段时间 → 前两列就能定位 - 按
day扫全站 → 第一列不是day,只能扫全表(如果要这个查询,得另建索引表或换主键顺序) - 按
request_id点查 → 单独建一张「request_id → keyword/day」的映射表
这就是取舍:一张表只能优化一种主要查询。我们选的是「关键词 + 时间」,因为这是报表和排障最常用的路径。
建表和写入大致是这样(SDK 细节以阿里云文档为准):
from tablestore import (
OTSClient, TableMeta, TableOptions, ReservedThroughput,
CapacityUnit, Row,
)
client = OTSClient(endpoint, ak_id, ak_secret, instance_name)
client.create_table(
TableMeta("serp_snapshots", [
("keyword", "STRING"),
("day", "STRING"),
("request_id", "STRING"),
]),
TableOptions(),
ReservedThroughput(CapacityUnit(0, 0)),
)
row = Row(
[("keyword", "serp api"), ("day", "2026-09-21"), ("request_id", data["request_id"])],
[
("status", data["status"]),
("credits", data.get("credits_charged")),
("payload", json.dumps(data, ensure_ascii=False)),
],
)
client.put_row("serp_snapshots", row)
credits_charged 允许为空,所以写入时不要 int() 强转——null 就是未知,落库也要保留这个语义。
三种查询
按关键词取一段时间的快照:
from tablestore import Direction, INF_MIN, INF_MAX
start = [("keyword", "serp api"), ("day", "2026-09-01"), ("request_id", INF_MIN)]
end = [("keyword", "serp api"), ("day", "2026-09-30"), ("request_id", INF_MAX)]
rows = client.get_range(
"serp_snapshots", Direction.FORWARD, start, end,
["status", "credits", "payload"],
)
按 request_id 点查:走映射表,一次 get_row。
按天统计:不在这张表上做。把当天的数据同步到 AnalyticDB 或 MaxCompute 跑聚合,Tablestore 只负责存和取。
这个分工很重要——用宽列库做 GROUP BY,最后一定会难受。
保留策略:按月建表
快照会一直长。我们的做法是按月建表:serp_snapshots_202609、serp_snapshots_202610。查询时按月份路由,过期月份整表归档到 OSS,再删表。
好处是保留策略变成表级操作,不用逐行删除;坏处是查询要自己拼表名,跨月查询得合并结果。对快照这种「按时间看」的数据,这个代价可以接受。
一个当时没想清楚的问题
payload 到底存整段 JSON,还是只存解析后的模块?我们最后存了整段——因为解析规则会变,原文是唯一能回放的东西。代价是存储变大,但省下了「解析错了只能重采」的风险。
如果重来
我会在第一版就把三件事定下来:主键顺序、按月分表、映射表。第一版只错了主键顺序,代价是重写了一遍写入逻辑和两张查询表。
Tablestore 的模型与计费方式见 阿里云官方文档;SerpBase 的响应字段见 SerpBase 文档。主键顺序这件事,建表前多想十分钟,比上线后改一周划算。