标签关联度表如何设计:增加行数还是增加列数?
建议先采用第一种结构:每个标签对、时间范围和年龄段的组合保存一行,以两个标签 ID、时间范围和年龄段组成联合主键。按约 2500 对标签、4 个时间范围和 4 个年龄段计算,一套完整的统计结果约有 40000 行。仅凭这个规模,没有必要优先采用 16 个数值列的宽表。
第三种结构可以减少重复的维度值和行开销,但实际能节省多少空间,需要把表和索引一起测量。不能直接根据行数推断“第一种占用超过四倍空间”。
为什么优先选择每个值一行
三种结构保存的是同一个关系:
(field_a_id, field_b_id, time_range, age_bracket) → relatedness
第一种结构直接表达了这个关系,时间范围和年龄段都是可以用来筛选、分组和连接的数据值。
第二种结构把年龄段放进列名,第三种则把时间范围和年龄段都放进列名。这样可以减少行数,但查询需要依赖列名。例如,要取某个年龄段的数据,就必须选择对应的 relatedness_brA 或 relatedness_raA_brA 列;要把所有年龄段统一排序或分组,还需要先将这些列转换成行。
采用第一种结构,新增年龄段通常只需要增加数据;采用后两种结构,则需要增加列,并调整相关查询和写入逻辑。
可以采用的 PostgreSQL 表结构
下面的示例用 day、week、month、all_time 表示四个时间范围,用 A、B、C、D 表示四个年龄段。这些只是示例编码,实际年龄边界应由业务定义。
CREATE TABLE tag_relatedness (
field_a_id integer NOT NULL,
field_b_id integer NOT NULL,
time_range varchar NOT NULL,
age_bracket varchar NOT NULL,
relatedness decimal,
PRIMARY KEY (
field_a_id,
field_b_id,
time_range,
age_bracket
),
CHECK (field_a_id < field_b_id),
CHECK (time_range IN ('day', 'week', 'month', 'all_time')),
CHECK (age_bracket IN ('A', 'B', 'C', 'D')),
CHECK (relatedness >= 0)
);
这个表结构有几个前提:
- 关联度对两个标签是对称的,因此统一将较小的标签 ID 放在
field_a_id,避免同时保存(a, b)和(b, a)。 - 不统计标签与自身的关联度,所以使用
field_a_id < field_b_id。 - 每个维度组合只保留当前统计结果。如果需要保存历史快照,还应增加统计时点,并把它纳入唯一性约束。
relatedness允许为NULL,可用于表示分母为零、无法计算的情况。如果业务不保存这类结果,也可以采用其他明确约定。
如果标签对具有方向性,或者需要统计自身关联,就应相应调整标签 ID 的约束。
查询和更新都可以保持统一
例如,查询标签 12 和 35 在过去一周、年龄段 A 下的关联度:
SELECT relatedness
FROM tag_relatedness
WHERE field_a_id = 12
AND field_b_id = 35
AND time_range = 'week'
AND age_bracket = 'A';
查询同一标签对的全部 16 个值:
SELECT time_range, age_bracket, relatedness
FROM tag_relatedness
WHERE field_a_id = 12
AND field_b_id = 35
ORDER BY time_range, age_bracket;
写入最新统计值时,可以通过主键处理冲突:
INSERT INTO tag_relatedness (
field_a_id,
field_b_id,
time_range,
age_bracket,
relatedness
)
VALUES (12, 35, 'week', 'A', 0.25)
ON CONFLICT (
field_a_id,
field_b_id,
time_range,
age_bracket
)
DO UPDATE SET relatedness = EXCLUDED.relatedness;
这个主键适合按标签对读取数据。如果常见需求是“查找某个标签在指定时间范围和年龄段下关联度最高的其他标签”,还需要根据具体查询设计索引;仅凭表结构,无法确定最合适的索引组合。
行数更多,不代表空间按同样比例增加
三种方案完整存储所有组合时,行数分别约为:
| 方案 | 行数 | 每行关联度值数量 |
|---|---|---|
| 每个值一行 | 40000 | 1 |
| 每个时间范围一行 | 10000 | 4 |
| 每个标签对一行 | 2500 | 16 |
三种方案保存的关联度值总数相同,都是约 40000 个。空间差异主要来自重复的维度值、行开销、对齐以及索引。
第一种结构通常会有更多行开销和主键索引项,但最终比例还取决于字段类型、实际数值、索引数量和数据分布。如果空间确实成为问题,可以先将重复的时间范围和年龄段编码改为较小的整数类型,并通过约束或维度表维护含义。
在 PostgreSQL 中,可以用下面的查询查看包含索引在内的关系总大小:
SELECT pg_size_pretty(
pg_total_relation_size('tag_relatedness'::regclass)
);
比较三种结构时,应装入相同的统计数据,并建立满足相同查询需求的索引。仅比较字段数量或行数,不能得出可靠的存储结论。
什么时候可以考虑宽表
如果四个时间范围和四个年龄段长期固定,应用几乎总是一次读取某对标签的全部 16 个值,而且实测表明减少行数有明确收益,也可以使用第三种结构。
第二种结构适用于查询通常以时间范围为单位、一次读取所有年龄段的情况。但它仍把年龄段写进列名,新增年龄段时需要修改表结构。
如果只是展示层需要 16 列,可以保留第一种存储结构,再通过条件聚合生成展示结果。例如:
SELECT
field_a_id,
field_b_id,
MAX(relatedness) FILTER (
WHERE time_range = 'day' AND age_bracket = 'A'
) AS relatedness_raA_brA,
MAX(relatedness) FILTER (
WHERE time_range = 'day' AND age_bracket = 'B'
) AS relatedness_raA_brB
FROM tag_relatedness
GROUP BY field_a_id, field_b_id;
这里只展示两列,其余组合可以按同样方式添加。主键保证每个组合最多有一个值,因此这里用 MAX 将行转换为列。
先明确关联度的分母
按照问题中“仅包含两个标签中的一个”的字面含义,设:
n_a:包含标签 A 的条目数,包括同时包含 B 的条目。n_b:包含标签 B 的条目数,包括同时包含 A 的条目。n_ab:同时包含 A、B 的条目数。
则公式为:
relatedness = n_ab / (n_a + n_b - 2 * n_ab)
这个比值可能大于 1,分母也可能为 0,因此不能直接把字段限制在 [0, 1]。SQL 计算时可以使用:
n_ab::numeric / NULLIF(n_a + n_b - 2 * n_ab, 0)
如果你实际想表达的是“同时包含两个标签的条目数,除以至少包含其中一个标签的条目数”,分母应为 n_a + n_b - n_ab,那是另一种指标。
无论选择哪种表结构,同一批时间范围的统计都应使用一致的截止时点。过去一天、过去一周和过去一个月的窗口会重叠,窗口移动后也需要刷新统计值。
备注:内容仅供参考。
