埋点分析系统 — 数据库索引优化文档
文档版本:v1.0.0
检查日期:2026-07-30
目标表:
clklog.log_analysis
目录
1. 当前状态诊断
1.1 表基本信息
| 指标 | 值 |
|---|---|
| 总行数 | 2,911,810 (约 291 万) |
| 总大小 | 165.45 MB (0.16 GB) |
| 活跃分区数 | 340 |
| 活跃部件数 | 509 |
| 索引粒度 (index_granularity) | 8,192 |
1.2 表引擎与键结构
ENGINE = MergeTree
PARTITION BY stat_date -- 按日期分区 ✓ 合理
ORDER BY distinct_id -- 仅按用户ID排序 ✗ 不合理
SETTINGS index_granularity = 8192
1.3 数据跳过索引
当前状态:无任何数据跳过索引
所有查询均无法利用跳数索引,必须扫描分区内的全部数据。
1.4 异常分区(严重)
当前存在大量未来日期的异常分区:
| 分区日期范围 | 分区数 | 说明 |
|---|---|---|
| 2026-08 ~ 2026-12 | 多个 | 未来数月的分区(当前为 2026-07) |
| 2027-07-06 | 1 | 2027 年 |
| 2030-06-xx | 3 | 2030 年 |
| 2032-01/08 | 3 | 2032 年 |
| 2036-01-01 | 2 | 2036 年 |
| 2050-11-08 | 1 | 2050 年 |
| 2052-04/06 | 2 | 2052 年 |
影响:
- 这些异常分区虽然数据量不大(每分区仅几行),但增加了分区元数据开销
- 查询时需要遍历更多分区目录
- 反映出上游数据写入时
stat_date字段未做校验
1.5 关键列基数分析
| 列名 | 非空行数 | 去重数 | 基数比 | 建议索引类型 |
|---|---|---|---|---|
project_name | 2,911,810 | 1 | 0.00% | set (极低基数) |
typeContext | 2,911,810 | 3 | 0.00% | set (极低基数) |
event | 2,911,718 | 16 | 0.00% | set (极低基数) |
os | 1,471,124 | 47 | 0.00% | set (极低基数) |
screen_name | 24,195 | 81 | 0.33% | set (低基数) |
province | 2,911,742 | 108 | 0.00% | set (极低基数) |
city | 2,911,742 | 504 | 0.02% | set (低基数) |
distinct_id | 2,911,810 | 29,008 | 1.00% | bloom_filter (中基数) |
说明:由于当前
project_name仅有 1 个值(基数为 0.00%),短期收益有限,但系统设计支持多项目,长期仍建议建立索引。
1.6 近期查询模式分析(来自 system.query_log)
| 查询类型 | 扫描行数 | 耗时 | 涉及列 |
|---|---|---|---|
count(distinct distinct_id) 累计用户 | 2,870,198 | 227ms | distinctid, statdate |
count(distinct distinct_id) 日活/新增 | 40,190 ~ 48,594 | 8~9ms | distinctid, statdate |
| 页面路径分析 | 40,196 | 15~18ms | screenname, statdate, distinctid, eventsession_id |
| 事件统计 | 48,594 | 11ms | event, stat_date |
| 网络类型/设备统计 | 40,190 | 10ms | networktype, statdate |
关键发现:累计用户查询(无 stat_date 过滤)扫描了 287 万行,是当前最慢的查询。
2. 核心问题分析
问题 1:排序键设计不合理(高优先级)
现状:ORDER BY (distinct_id)
问题:
- 绝大多数查询都包含
statdate范围过滤和/或event/screenname/project_name过滤 - 排序键中无这些高频过滤列,导致 ClickHouse 无法利用主键索引(primary index)进行数据跳过
- 即使有时间分区,每个分区内仍需扫描全部数据
举例:一个 WHERE stat_date = '2026-07-30' AND event = '$AppClick' 查询:
- 利用分区键跳过了非
2026-07-30的数据 ✓ - 但该分区内有 4 万行数据,由于排序键是
distinct_id,ClickHouse 无法快速定位event = '$AppClick'的行,必须全部扫描 ✗
问题 2:无任何数据跳过索引(高优先级)
现状:未创建任何跳数索引
问题:
- 即使查询带有非常具体的等值条件(如
event = '$AppClick'、province = '上海'),也无法跳过不匹配的 granule - 每个查询必须读取每个涉及分区的所有列数据
问题 3:异常分区数据(中优先级)
现状:存在 2026-08 至 2052 年的异常分区
问题:
- 虽然这些分区数据量很小,但增加了查询时的分区元数据遍历开销
- 反映出上游数据质量问题,应从源头修复
问题 4:累计用户查询扫描全表(中优先级)
现状:count(distinct distinct_id) 无日期范围限制时扫描 287 万行,耗时 227ms
问题:
- 当前数据量小(291 万行),影响不大
- 随着数据增长到数亿行,该查询会成为严重瓶颈
3. 优化方案详解
3.1 方案一:添加数据跳过索引(优先实施,风险最低)
为高频过滤列添加数据跳过索引(Data Skipping Index)。
索引类型选择原则
| 索引类型 | 适用场景 | 原理 |
|---|---|---|
minmax | 数值/日期列,范围查询 | 存储每个 granule 的最小/最大值,查询时跳过不满足范围的 granule |
set(max_rows) | 低基数字符串列,等值查询 | 存储每个 granule 出现的所有取值,查询时如果值不在 set 中则跳过 |
bloom_filter | 中/高基数字符串列,等值查询 | 布隆过滤器,可能有误报但空间效率高 |
ngrambfv1 / tokenbfv1 | 文本搜索,LIKE 查询 | N-gram / Token 布隆过滤器 |
推荐索引列表(按优先级)
| # | 索引名 | 列名 | 类型 | 粒度 | 理由 |
|---|---|---|---|---|---|
| 1 | idx_event | event | set(0) | 1 | 16 种取值,几乎所有事件分析页都用 |
| 2 | idxscreenname | screen_name | set(0) | 1 | 页面路径分析高频过滤,81 种取值 |
| 3 | idx_province | province | set(0) | 1 | 地域分析,108 种取值 |
| 4 | idx_type | typeContext | set(0) | 1 | 仅 3 种取值,成本极低 |
| 5 | idx_os | os | set(0) | 1 | 设备分析,47 种取值 |
| 6 | idx_city | city | set(0) | 1 | 504 种取值,仍适合 set |
| 7 | idxdistinctid | distinct_id | bloom_filter | 1 | 29,008 种取值,布隆过滤高效 |
注:
set(0)表示不限制 set 大小(ClickHouse 会自动管理)。如果担心内存,可改为set(100)等具体值。
预期收益
- 带有
event等值条件的查询:预计可跳过 60-90% 的 granule - 带有
screen_name等值条件的查询:预计可跳过 70-95% 的 granule - 累计用户查询(配合 bloom_filter):预计可跳过部分 granule
3.2 方案二:优化排序键(评估后实施,成本较高)
当前:ORDER BY (distinct_id)
建议:改为 ORDER BY (projectname, statdate, event, distinct_id)
设计理由
project_name— 最高优先级的等值过滤(当前虽仅有 1 个项目,但系统设计支持多项目)stat_date— 第二高频条件(几乎所有查询都有日期范围),虽然已有分区键,但在排序键中可进一步优化event— 第三高频条件,事件分析页核心过滤列distinct_id— 保留在末尾,支持用户级查询
收益
- 所有带
projectname + statdate + event条件的查询将获得数量级性能提升 - 主键索引(primary index)将与实际查询模式匹配
代价
- 需要重建整个表(数据迁移),约 165 MB 数据预计耗时数分钟
- 期间可能影响写入(需视 ClickHouse 版本而定,新版本支持在线修改但仍有开销)
3.3 方案三:清理异常分区(快速实施)
删除所有 2030 年及以后的异常分区,以及 2026 年 8 月及以后的未来分区。
3.4 方案四:累计用户查询优化(中长期)
对于无日期限制的 count(distinct distinct_id) 查询,可考虑:
- 使用物化视图:预计算每日的
distinct_id集合,累计查询时合并 - 使用聚合表:如果只需要总数而非去重明细,可改用
AggregatingMergeTree - 增加时间窗口限制:业务上考虑是否真的需要"全历史累计",还是"近 90 天活跃"即可
4. 实施步骤与 SQL
阶段一:添加数据跳过索引(30-60 分钟,低风险)
该操作是在线的,不阻塞读写,但会消耗部分 CPU 和 IO。建议在业务低峰期执行。
-- ================================================
-- 步骤 1:添加 7 个数据跳过索引
-- ================================================
-- 1. event (16 种取值,最高频过滤)
ALTER TABLE clklog.log_analysis
ADD INDEX idx_event event TYPE set(0) GRANULARITY 1;
-- 2. screen_name (81 种取值,页面路径分析)
ALTER TABLE clklog.log_analysis
ADD INDEX idx_screen_name screen_name TYPE set(0) GRANULARITY 1;
-- 3. province (108 种取值,地域分析)
ALTER TABLE clklog.log_analysis
ADD INDEX idx_province province TYPE set(0) GRANULARITY 1;
-- 4. typeContext (3 种取值,成本极低)
ALTER TABLE clklog.log_analysis
ADD INDEX idx_type typeContext TYPE set(0) GRANULARITY 1;
-- 5. os (47 种取值,设备分析)
ALTER TABLE clklog.log_analysis
ADD INDEX idx_os os TYPE set(0) GRANULARITY 1;
-- 6. city (504 种取值,城市级分析)
ALTER TABLE clklog.log_analysis
ADD INDEX idx_city city TYPE set(0) GRANULARITY 1;
-- 7. distinct_id (29,008 种取值,用户查询)
ALTER TABLE clklog.log_analysis
ADD INDEX idx_distinct_id distinct_id TYPE bloom_filter GRANULARITY 1;
-- ================================================
-- 步骤 2:物化索引(让已有数据也能使用索引)
-- ================================================
-- 注意:MATERIALIZE 是最耗时的步骤,需在低峰期执行
-- 可以逐条执行,观察每条的耗时
ALTER TABLE clklog.log_analysis MATERIALIZE INDEX idx_event;
ALTER TABLE clklog.log_analysis MATERIALIZE INDEX idx_screen_name;
ALTER TABLE clklog.log_analysis MATERIALIZE INDEX idx_province;
ALTER TABLE clklog.log_analysis MATERIALIZE INDEX idx_type;
ALTER TABLE clklog.log_analysis MATERIALIZE INDEX idx_os;
ALTER TABLE clklog.log_analysis MATERIALIZE INDEX idx_city;
ALTER TABLE clklog.log_analysis MATERIALIZE INDEX idx_distinct_id;
-- ================================================
-- 步骤 3:验证索引已创建
-- ================================================
SELECT
name,
type,
expr,
granularity,
status
FROM system.data_skipping_indices
WHERE database = 'clklog'
AND table = 'log_analysis';
阶段二:清理异常分区(5 分钟,低风险)
-- ================================================
-- 先查看所有异常分区
-- ================================================
SELECT
partition,
sum(rows) as total_rows,
round(sum(bytes_on_disk) / 1024, 2) as size_kb
FROM system.parts
WHERE database = 'clklog'
AND table = 'log_analysis'
AND active
AND (partition >= '2026-08-01' OR partition < '2020-01-01')
GROUP BY partition
ORDER BY partition;
-- ================================================
-- 删除 2030 年及以后的分区(建议先执行)
-- ================================================
ALTER TABLE clklog.log_analysis DROP PARTITION '2030-06-18';
ALTER TABLE clklog.log_analysis DROP PARTITION '2030-06-28';
ALTER TABLE clklog.log_analysis DROP PARTITION '2030-06-29';
ALTER TABLE clklog.log_analysis DROP PARTITION '2032-01-01';
ALTER TABLE clklog.log_analysis DROP PARTITION '2032-01-02';
ALTER TABLE clklog.log_analysis DROP PARTITION '2032-08-02';
ALTER TABLE clklog.log_analysis DROP PARTITION '2036-01-01';
ALTER TABLE clklog.log_analysis DROP PARTITION '2050-11-08';
ALTER TABLE clklog.log_analysis DROP PARTITION '2052-04-29';
ALTER TABLE clklog.log_analysis DROP PARTITION '2052-06-05';
-- ================================================
-- 删除 2026 年 8 月及以后的未来分区(可选,执行前确认数据来源)
-- ================================================
-- 注意:如果这些分区是合法的测试数据或未来写入预留,请不要删除
-- 建议先确认上游数据写入逻辑后再决定
-- 查看有哪些未来分区
SELECT partition, sum(rows) as cnt
FROM system.parts
WHERE database = 'clklog' AND table = 'log_analysis' AND active
AND partition >= '2026-08-01' AND partition < '2030-01-01'
GROUP BY partition ORDER BY partition;
-- 如需删除,示例:
-- ALTER TABLE clklog.log_analysis DROP PARTITION '2026-08-01';
-- ... 依次执行
阶段三:评估并实施排序键改造(低峰期,2-4 小时)
重要:此操作需要重建表,请在充分评估后再执行。当前表仅 165MB,实际耗时可能很短。
方案 A:通过 MATERIALIZE TTL 或其他在线方式(如果 ClickHouse 版本支持)
-- 检查 ClickHouse 版本
SELECT version();
-- 21.x 及以上版本支持 ALTER TABLE ... MODIFY ORDER BY
ALTER TABLE clklog.log_analysis
MODIFY ORDER BY (project_name, stat_date, event, distinct_id);
方案 B:传统方式(新建表 → 迁移数据 → 切换表名)
-- ================================================
-- 步骤 1:创建新表(使用优化后的排序键)
-- ================================================
CREATE TABLE clklog.log_analysis_new
(
-- 此处省略所有列定义,与原表一致
-- ... 参考 SHOW CREATE TABLE clklog.log_analysis 的输出
)
ENGINE = MergeTree
PARTITION BY stat_date
ORDER BY (project_name, stat_date, event, distinct_id)
SETTINGS index_granularity = 8192;
-- ================================================
-- 步骤 2:拷贝数据
-- ================================================
INSERT INTO clklog.log_analysis_new
SELECT * FROM clklog.log_analysis;
-- ================================================
-- 步骤 3:验证数据一致性
-- ================================================
SELECT count() FROM clklog.log_analysis;
SELECT count() FROM clklog.log_analysis_new;
-- ================================================
-- 步骤 4:切换表名(需要短暂停机)
-- ================================================
-- 暂停写入后执行:
RENAME TABLE clklog.log_analysis TO clklog.log_analysis_old;
RENAME TABLE clklog.log_analysis_new TO clklog.log_analysis;
-- 恢复写入,验证正常后:
-- DROP TABLE clklog.log_analysis_old;
5. 效果验证方法
5.1 验证索引是否生效
-- 使用 EXPLAIN indexes = 1 查看索引使用情况
EXPLAIN indexes = 1
SELECT count(*)
FROM clklog.log_analysis
WHERE stat_date = '2026-07-30'
AND event = '$AppClick';
预期输出中应包含:
Index `idx_event` has dropped 1/2 granules.
5.2 性能对比测试
-- 测试 1:事件过滤查询(加索引前后对比)
SELECT event, count(*)
FROM clklog.log_analysis
WHERE stat_date BETWEEN '2026-07-23' AND '2026-07-30'
AND event = '$AppClick'
GROUP BY event;
-- 测试 2:页面路径查询
SELECT screen_name, count(*)
FROM clklog.log_analysis
WHERE stat_date BETWEEN '2026-07-23' AND '2026-07-30'
AND screen_name = '首页'
GROUP BY screen_name;
-- 测试 3:地域查询
SELECT province, count(distinct distinct_id)
FROM clklog.log_analysis
WHERE stat_date BETWEEN '2026-07-23' AND '2026-07-30'
AND province = '上海市'
GROUP BY province;
-- 测试 4:累计用户查询
SELECT count(distinct distinct_id)
FROM clklog.log_analysis;
记录每条查询的:
- 执行时间(querydurationms)
- 读取行数(read_rows)
- 读取字节数(read_bytes)
5.3 观察生产慢查询
实施索引后持续观察 system.query_log:
SELECT
query,
query_duration_ms,
read_rows,
memory_usage / 1024 / 1024 as memory_mb
FROM system.query_log
WHERE type = 'QueryFinish'
AND has(databases, 'clklog')
AND query LIKE '%log_analysis%'
AND query_duration_ms > 100
ORDER BY event_time DESC
LIMIT 30;
6. 风险与回滚
6.1 风险评估
| 操作 | 风险等级 | 说明 |
|---|---|---|
| 添加数据跳过索引 | 低 | 在线操作,不阻塞读写,仅增加部分 CPU/IO 开销 |
| 物化索引 | 低-中 | 消耗 IO,建议低峰期执行,可随时中断(中断后可重新 MATERIALIZE) |
| 删除异常分区 | 低 | 快速操作,确认数据可删后执行 |
| 修改排序键 | 中-高 | 需要重建表,视数据量和 ClickHouse 版本可能需要短暂停机 |
6.2 回滚方案
索引回滚
-- 删除某个索引
ALTER TABLE clklog.log_analysis DROP INDEX idx_event;
-- 删除所有索引(如需整体回滚)
ALTER TABLE clklog.log_analysis DROP INDEX idx_event;
ALTER TABLE clklog.log_analysis DROP INDEX idx_screen_name;
ALTER TABLE clklog.log_analysis DROP INDEX idx_province;
ALTER TABLE clklog.log_analysis DROP INDEX idx_type;
ALTER TABLE clklog.log_analysis DROP INDEX idx_os;
ALTER TABLE clklog.log_analysis DROP INDEX idx_city;
ALTER TABLE clklog.log_analysis DROP INDEX idx_distinct_id;
排序键回滚
如果使用方案 B(新表迁移):
- 保留旧表
loganalysisold至少 7 天 - 如需回滚:
RENAME TABLE clklog.loganalysis TO clklog.loganalysisbad; RENAME TABLE clklog.loganalysisold TO clklog.loganalysis;
分区回滚
DROP PARTITION 是不可逆操作。如果担心误删:
- 先
DETACH PARTITION而非DROP PARTITION(detach 后数据在 detached 目录,可重新 ATTACH) - 或先备份该分区数据
-- 安全方式:先 detach(可恢复)
ALTER TABLE clklog.log_analysis DETACH PARTITION '2030-06-29';
-- 确认无误后再彻底删除(detached 目录下手动清理)
-- 或重新附加:
ALTER TABLE clklog.log_analysis ATTACH PARTITION '2030-06-29';
附录:实施路线图
| 阶段 | 操作 | 建议时间 | 预计耗时 | 负责人 |
|---|---|---|---|---|
| 阶段一 | 添加 7 个数据跳过索引 | 立即(低峰期) | 10 min | DBA |
| 阶段一 | 物化索引 | 立即(低峰期) | 20-50 min | DBA |
| 阶段一 | 验证索引效果 | 索引物化完成后 | 5 min | 开发 |
| 阶段二 | 清理 2030+ 异常分区 | 1 周内(确认上游无问题) | 5 min | DBA |
| 阶段二 | 观察 1-2 周查询性能 | 索引上线后 | 持续 | 开发/DBA |
| 阶段三 | 评估排序键改造收益 | 索引上线 2 周后 | - | 开发/DBA |
| 阶段三 | 如需改造,低峰期实施排序键优化 | 下个维护窗口 | 0.5-2 h | DBA |
文档结束