多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Civitai Entity Metrics 双轨架构解析:Postgres `*Metric` 定时任务与 ClickHouse 事件管线的共存、漂移与演进

Civitai Entity Metrics 双轨架构解析:Postgres `*Metric` 定时任务与 ClickHouse 事件管线的共存、漂移与演进 Civitai Entity Metrics 双轨架构解析Postgres*Metric定时任务与 ClickHouse 事件管线的共存、漂移与演进【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai导读Civitai 站点上每个实体的计数器打赏 tips、关注 followers、点赞 reactions、阅读 reads、浏览 views、下载 downloads 等并非来自单一存储仓库中并行运行着两套指标子系统——Postgres 反规范化*Metric表由 cron 从源表重算与 ClickHouse 事件管线物化视图连续聚合。本文以 docs/features/entity-metrics.md 为骨架结合 src/server/metrics、src/server/jobs/update-metrics.ts 与对账服务源码讲清两套系统的数据流、自愈能力差异、Image 双写的历史包袱、Comics 完整迁移到 Postgres 的实战案例以及“何时该建 Postgres 台账、何时该用 ClickHouse、何时必须做对账”的决策规则帮助你在引入或改造指标系统时直接复用这套权衡框架。一、先看全貌两套指标子系统并行运行entity-metrics.md开篇给出的 TL;DR 对比表是全文的地图先完整呈现A. Postgres*Metric表B. ClickHouse 事件管线存储每个实体一行反规范化数据事件日志 → 滚动聚合保鲜方式cron 从源表重算*.metrics.ts物化视图持续更新真相来源其他 Postgres 表BuzzTip、engagement、reactions…事件本身自愈能力有——每次运行从源头重算没有——事件丢失/重复会导致永久漂移除非对账任务纠正读取方式PrismadbRead.modelMetric…MetricService图片信息流适合场景状态型计数器有 DB 源事件型计数器无 DB 源 大规模读取关键结论先行这种并存部分是有意设计部分是迁移中途的产物详见第三节。理解这一点才能判断哪些冗余是合理的、哪些是应当清理的技术债。二、Subsystem A — Postgres*Metriccron 表2.1 整体数据流这是最原始的计数模式。每个实体对应一张反规范化指标表ModelMetric、ArticleMetric、BountyEntryMetric、Model3DMetric、ImageMetric、ComicProjectMetric等存放预计算好的计数器source-of-truth PG tables cron (every ~1 min) read path ───────────────────────── ───────────────────────────── ─────────────── BuzzTip ───────────────┐ ComicProjectEngagement ─┼──► src/server/metrics/entity.metrics.ts ──► EntityMetric ──► Prisma select reactions, comments… ──┘ (createMetricProcessor.update) (one row/entity) in the router │ registered in src/server/jobs/update-metrics.ts2.2 处理器工厂createMetricProcessor每个实体模块都是一个createMetricProcessor({ name, update })实例工厂定义在 src/server/metrics/base.metrics.ts。其核心参数与默认值name处理器标识用于任务日期记录metric:${name}、更新队列metric-update:${name}与功能开关键update真正执行重算的回调接收MetricProcessorRunContextupdateInterval默认DEFAULT_UPDATE_INTERVAL 60 * 10001 分钟rank与refreshInterval可选的排名表刷新配置默认DEFAULT_RANK_REFRESH_INTERVAL 60 * 60 * 10001 小时clearDay每日首次运行时清空当日数据的回调lockTime任务锁时长。每次update()运行前工厂会做三件关键准备工作通过getJobDate(metric:${name})取上次运行时间lastUpdate判断lastUpdate updateInterval是否已过期检查 sysRedis 中的指标开关REDIS_SYS_KEYS.SYSTEM.FEATURES的metric:${name}字段默认缺省视为true允许运行。注意源码里有一段 Buffer 强转逻辑HA/Sentinel 模式下 sysRedis 返回 BufferBuffer true恒为 false会静默把开关翻成关闭因此必须先toString(utf8)再比较——这是曾导致回归的坑见代码注释引用的 PR #2697从 Redis 队列metric-update:${name}取出显式入队的实体 ID供事件驱动式立即更新并把lastUpdate回拨 2 分钟dayjs(lastUpdate).subtract(2, minute)以覆盖 ClickHouse tracker 的追赶窗口。2.3 任务编排与调度update-metrics.ts所有处理器通过 src/server/jobs/update-metrics.ts 注册为createJob任务。metricSets映射了全部实体族models / model-collections / basemodels / users / bounties / posts / tags / collections / articles / model3ds / comics / app-listings调度规则默认*/1 * * * *每分钟但model-collections、basemodels、app-listings使用*/5 * * * *每 5 分钟。app-listings放慢的原因在注释中写得很直白安装/播放open计数不需要分钟级新鲜度且每次运行都伴随一次无界的 ClickHouse 扫描见appListing.metrics.sql.ts间隔本身就是一个成本旋钮。每个任务先顺序执行所有metric.update再执行所有metric.refreshRank用timedExecution记录每步耗时并作为任务返回值供监控与测试断言。2.4 增量重算的通用工具metric-helpers.tssrc/server/metrics/metric-helpers.ts 提供了三个被各模块复用的基元getAffected(ctx)(sql)查询“上次运行以来发生变化”的实体 ID与队列中的 ID 合并去重并回调addAffected查询通过pg.cancellableQuery执行注册到jobContext的cancel事件保证任务取消时查询可中断executeRefresh(ctx)(sql)执行实际的重算 SQL通常是一条INSERT … ON CONFLICT … DO UPDATEupsertsnippets生成按AllTime/Year/Month/Week/Day时间窗求和的SUM(CASE …)片段、COUNT(DISTINCT …)去重计数片段与 reactions 专用片段——这些是*Metric表里xxxCount、xxxAmountCount多时间窗列的标准生成器。自愈的关键属性因为每次运行都从源头BuzzTip、engagement 表、reactions 等重算坏值或缺失值会在下一轮被纠正。这套系统唯一的失效模式是 cron 本身停止运行。2.5 读取路径读取是单行主键查找反规范化行热路径上没有任何聚合且列可排序、可过滤——这正是信息流“按最多打赏排序”等功能的实现基础。Comics 侧的读取示例见 src/server/routers/comics.router.ts 的getComicMetricRows一次findMany按comicProjectId取回六个计数列直接塞进 Map 供各 procedure 使用。三、Subsystem B — ClickHouse 事件管线3.1 整体数据流这是较新的模式“v2 watcher”切换约 v5.0.1871 引入。计数器是增量事件的滚动求和events ClickHouse (materialized views) read path ────── ────────────────────────────────────── ──────────────────── a tip / view / read ──► entityMetricEvents_month │ (every metric is kindadditive) ▼ entityMetricSum_v3 ──► entityMetricTotal_v3 ──► entityMetricDailyAgg_v2 (view) (sumState) (refresher, every 1m) │ ▼ MetricService (image feed) (Redis-cached) ──► router核心公式total sum(metricValue)对(entityType, entityId, metricType)维度上的全部事件求和。3.2 读取路径与缓存读取通过MetricService图片信息流场景在 ClickHouse 之上加了一层Redis 缓存。而定时任务侧getEntityMetricTasks见 metric-helpers.ts会直接查询entityMetricEvents_month找出受影响实体再从最终视图聚合聚合表每 5 分钟刷新一次代码用AGG_REFRESH_INTERVAL_MS 5 * 60 * 1000加上AGG_REFRESH_BUFFER_MS 30 * 1000的缓冲来计算“聚合边界”getAggInterval/floorToAggInterval只有当lastUpdate跨越了新的聚合刷新边界hasCrossedAggBoundary才值得抓取新数据否则直接跳过——避免每个周期都对 ClickHouse 做无谓扫描最终读取的视图是entityMetricDailyAgg_v2由 src/server/flipt/client.ts 的buildEntityMetricPerDaySource(where)封装为子查询(SELECT entityId, metricType, day, total FROM entityMetricDailyAgg_v2 WHERE …)。注意注释特别提醒旧版 ReplacingMergeTree 表entityMetricDailyAgg_new已在 2026-06-24 从 ClickHouse 中删除任何仍指向它的读取都会报UNKNOWN_TABLE导致 500绝不可回退指向。3.3 关键弱点不自我修复管线对“运行求和”不做任何对账一条被丢弃的事件 → 永久少计数一条重复插入 → 永久多计数更隐蔽的是entityMetricSum_v3在 ReplacingMergeTree 去重之前就消费了每次插入所以即使原始事件表看起来是对的重复插入也会虚增总数。它适合纯事件型计数器没有 DB 台账的viewCount、generationCount以及读取量极大的场景图片信息流。漫画阅读量曾经也住在这里——下一节的工作示例会说明它为什么迁回了 Postgres。四、为什么同一份数据存在两个地方文档明确把原因拆成两条务必分开看待原因 1按计数器类型做合法拆分合理部分状态型计数器——tips、followers、hides、collects。它们是“当前事实”有干净的 Postgres 源BuzzTip、engagement 表一条SELECT count(…)永远精确子系统 A 完美适配。事件型计数器——views、generations。它们是“随时间累积”的量没有Postgres 台账只有事件流能重建 → 只能走子系统 B。反例是漫画阅读它看起来是纯事件流但根因是存储缺陷详见第六节最终选择“造一个台账”而非接受 ClickHouse。所以真正的判断标准不是“有没有 DB 台账”而是“值不值得造一个台账”。原因 2迁移中途的产物真正浪费的部分Image/Comic 的指标曾从子系统 A 迁往子系统 B但 Models/Articles/Bounties 从未迁移旧的ImageMetriccron 也一直没关。于是对部分实体尤其Image同一个 tip 总数被算了两遍——一遍写入ImageMetricPostgres cron一遍走 ClickHouse 管线——且不同读取路径各取各的副本。这个重复不是设计而是未完成的迁移。结论冗余真实存在但范围有限。把状态型计数器同时存进自愈的 PG 表和易漂移的 CH 管线毫无收益——这是没迁完的债。真正不冗余的场景是纯事件型计数器它们只存在于 ClickHouse。五、谁读什么当前状态实体Tips / followers状态型Views / reads事件型Model / ModelVersionPostgres*Metric—ArticlePostgresArticleMetric—Bounty / BountyEntryPostgres*Metric—Model3DPostgresModel3DMetric—ComicPostgresComicProjectMetricPostgresComicProjectMetricreads 来自ComicChapterReadImageClickHouse信息流和ImageMetric部分路径ClickHouse注意 Image 的特殊地位它在热信息流上从 ClickHouse 读 tips而 ClickHouse 的图片 tips 还同时喂给搜索排序stats.tippedAmountCountAllTime:desc和用户评分——因此它不能随意迁回 Postgres牵一发而动全身。六、漂移问题为什么这件事重要因为子系统 B 不对账其计数器会持续漂移。文档给出了在漫画上的实测数据ClickUp 868k4y401Tips漫画 2373 显示0真实值330事件丢失Followers约 92% 的漫画计数错误——125 部显示0 followers而实际有数十个如漫画 424显示 1实际 52。子系统 A 的实体Models/Articles/Bounties没有这个问题——它们的 cron 每次运行都从BuzzTip重算。对必须留在 ClickHouse的实体Image 家族的漂移修复单独跟踪在ClickUp 868k5m8bk设计是一个对账任务向事件流注入修正增量truth − current。6.1 仓库里已落地的对账实现超越原文档的当前代码原文档写的是“对账任务待办”而当前仓库已经实现了反应reaction管线的对账闭环可作为 868k5m8bk 方案的具体参照检测侧——src/server/services/metric-reconciliation.service.tsauditReactionHour(hourStart)按小时做PG → CH 覆盖率审计。它从 Postgres 读该小时所有ImageReaction行利用id是单调序列做二分查找定位 id 区间避免对 2.45 亿行的createdAt全表扫描再查entityMetricEvents_month按(imageId, userId, reaction)三元组比对输出coverage matched / comparable。方向刻意只断言PG → CHun-reaction 会删掉 PG 行但把1永远留在 ClickHouse所以原始计数比天然差几个百分点且随窗口变老而扩大而“PG 有行、CH 无1”则没有任何良性解释必然是数据丢失。代码还对 2025-11 的反应重命名做了处理entityMetricEvents_month里同时存在ReactionLike等旧拼写与Like等新拼写用multiIf折叠到规范名auditReactionExactness({ sampleSize, lookbackHours, seed })每晚对近期被触碰的图片抽样 200 张做逐三元组精确度审计比对 PG 的ImageReaction与entityMetricUserState_v3用与站点实际显示一致的谓词argMaxMerge(latest) 0。结果拆分recentCoverage24 小时内真实健康信号与backlogCoverage历史欠账的燃尽量不可告警否则每晚必响然后被静默phantomRate独立复现了已知约 0.37% 的 un-reaction 残留。告警与修复侧——src/server/jobs/metric-reconciliation-audit.tsreaction-volume-audit每小时第 10 分钟运行审计H-2小时而非 H-1因为批量写入与 flush 延迟让 H-1 尾部可能仍在途覆盖率阈值COVERAGE_WARN 0.999、COVERAGE_CRIT 0.99——健康分布是 1.0 处的点质量失败信号在 0.02–0.71所以 WARN 设在离观测噪声约 1000 倍处仍能捕获千分之一的丢失reaction-exactness-audit每天 04:40 运行种子按天变化Math.floor(Date.now() / 86_400_000)保证连续几晚覆盖不同图片审计任务刻意住在主应用而非 watcher 里——审计器若与被审计对象共享故障域就会在真正出事时一起沉默2025-12-01 至 2026-06-23 之间消费者静默丢弃了约 2160 万条反应事件而所有旧健康检查全绿修复通过setReactionRepairHook注册由 src/server/services/metric-reaction-repair.service.ts 的repairReactionMetrics执行对每张图片按用户比对 PG 与 CH把补偿性的1/-1插入entityMetricEvents_month。修复写入受Flipt 特性开关METRIC_REACTION_REPAIR门控关闭时每晚仅以 dry-run 模式跑 diff 产生观测证据防止检测器 bug 被放大成数据损坏。这套实现正是“对账注入修正增量”思路的活样本检测读真相源PG、修复写事件流CH、写入受开关门控。七、Comics —— 完整的工作示例当前代码7.1 全面 Postgres 化漫画目前完全由 Postgres 拥有每个计数器都计算进ComicProjectMetric由 src/server/metrics/comic.metrics.ts 的comicProjectMetrics处理器维护经 comics.router.ts 的getComicMetricRows读取。漫画已经没有 ClickHouse 读取路径——comicMetricsCache及其填充器已被删除连同它们作为最后使用者的应用内entitymetric:*Redis 缓存子系统一起移除。各计数器的来源映射源码头注释与实现双重印证状态型tippedCount、tippedAmountCount、followerCount、hiddenCount从BuzzTipentityType ComicProjectComicProjectEngagementtype Notify计数、type Hide计数重算阅读型readerCount、chapterReadCount从ComicChapterRead重算——这是按 (user, chapter) 一行、以稳定ComicChapter.id为键的表。readerCount COUNT(DISTINCT userId)、chapterReadCount COUNT(*)均过滤unread false。update()的 SQL 是一整条带三个LEFT JOIN子查询的INSERT … ON CONFLICT (comicProjectId) DO UPDATEupsert按BATCH_SIZE 1000分批、limitConcurrency(tasks, 5)并发执行任务取消时通过checkIfCanceled中断。增量检测由getAffectedComics完成它合并三个来源的“近期变更 ID”并去重-- 1) 近期打赏 SELECT entityId AS id FROM BuzzTip WHERE entityType ComicProject AND (createdAt :lastUpdate OR updatedAt :lastUpdate); -- 2) 近期关注/隐藏/取关软删除会 bump updatedAt SELECT projectId AS id FROM ComicProjectEngagement WHERE updatedAt :lastUpdate; -- 3) 近期阅读/取消阅读un-reads 软删除 bump updatedAt SELECT cc.projectId AS id FROM ComicChapterRead cr JOIN ComicChapter cc ON cc.id cr.chapterId WHERE cr.updatedAt :lastUpdate;配合部署迁移期的一次性全量回填增量 cron 只负责维护——因此任何漂移都会在该漫画下一次有活动时自愈。这修复了最初的 tip follower 漂移 bug也让漫画与 Models/Articles/Bounties 站到了同一个*Metric模式上。7.2 为什么阅读量会迁到 Postgres最值得读的部分漫画阅读量看起来像纯 CH 事件计数器但那只是因为一个存储缺陷。它原本存在ComicProjectEngagement.readChapters Int[]位置数组里键是章节的position——而position是可变序号ComicChapter的主键是(projectId, position)章节重排/重新发布时序号会移动。代码因此每次重新发布都会清空readChaptersPostgres 无法重建累积计数watcher 的 ClickHouse 总数就成了事实上的且会漂移的真相源。修复方案不是接受 ClickHouse而是给阅读量一个持久的 Postgres 台账阅读成为一等公民的ComicChapterRead行按稳定章节id键控重排/重新发布后依然存在、无需清空删除章节的阅读经 FK 级联消失重排/重复则完全不受影响。在漫画的体量约 5 万次阅读/月下一行一读的表微不足道。所以“没有 DB 台账 → 必须用 ClickHouse”的规则其实有第三个选项造台账——当这个计数器值得拥有且规模适中时。7.3 用软删除支撑增量检测两种“移除”都软删除增量 cron 才能捕获toggleComicEngagement置type None从不硬删markChapterUnread置ComicChapterRead.unread true。二者都会 bump 该行updatedAt于是getAffected重新计数该漫画计数下降——哪怕是一部休眠中的漫画。对照之下Article/Model 仍硬删 engagement且没有updatedAt它们会在实体下一次活动前一直持有过期的多计数。漫画是“增量 engagement 的正确做法”的参照实现把同一套配方updatedAt 软删除推广到其他*Engagement表就是通用的修复方向。八、未来该往哪走开放问题文档的落点是明确的这不是“把一切都迁到 Postgres”。两种存储各司其职目标只是消除重复而不是消灭 ClickHouse。最终态是混合架构按计数器逐个决策大规模纯事件型计数器views、generation——无 DB 台账且不值得造→ClickHouse。漂移靠对账868k5m8bk修复而不是迁移看似纯事件、但值得在适中规模下拥有的计数器漫画阅读→造 Postgres 台账ComicChapterRead再用*Metriccron。“没有 DB 源”往往是存储选择而非铁律——规模适中且计数器重要就给它一个源自愈、不漂移、可排序有干净 DB 源的适中规模状态型计数器漫画/文章/赏金的 tips、follows→Postgres*Metriccron——最简单且自愈大规模状态型计数器Image 家族→真正悬而未决。持续物化的 ClickHouse 聚合在数亿行规模下可能比周期性 PG cron 重算更可扩展——这很可能是图片当初迁往 CH 的原因但 Models 也在大规模下跑着 PG cron 且工作良好所以并未被证明。做决定要基于历史背景图片为何在 ~v5.0.1871 切换而非默认选项。要消除的冗余是两个存储都在算的状态型计数器如图片 tips 同时存在于ImageMetric和 CH 管线。每个计数器只选一个权威存储然后把读者都指过去不要两处都留。决策速查添加/读取计数器时的规则有 DB 源且实体规模适中→*Metriccron自愈、可排序、不会漂移。看起来是纯事件流但计数器重要且规模适中→先造DB 台账漫画用ComicChapterRead就是这么做的再 →*Metriccron。别因为“暂时没有源”就直奔 ClickHouse。真正的大规模纯事件型原始 views或超大/读热的实体→ ClickHouse但只认一个存储并加上对账防止漂移。同一个计数器绝不要两个地方都读。九、从这份文档中可复用的工程经验自愈 vs 精确的对价关系周期性从真相源重算子系统 A牺牲少量新鲜度换取永不漂移事件滚动求和子系统 B获得连续新鲜度但把正确性押在事件管线不丢不重上。选型前先明确计数器是“状态”还是“累积量”。“没有 DB 源”不是天然属性漫画案例证明存储缺陷可变序号作键会伪造出“只能靠事件流”的表象给重要计数器补一个以稳定 ID 为键的台账往往是比接受漂移更便宜的修复。增量任务的两个隐形坑一是移除操作必须软删除并 bumpupdatedAt否则休眠实体永远带着过期多计数Article/Model 的教训二是键必须稳定章节id而非position否则重排即数据灾难。对账系统要独立于被审计对象审计器与管线共享故障域等于没有审计2160 万事件丢失而全部旧检查绿灯的教训修复写入要受特性开关门控先 dry-run 积累证据再放量。删除遗留路径要彻底entityMetricDailyAgg_new已删表后仍有读取者报UNKNOWN_TABLE说明迁移收尾时更新所有读取点与删除缓存子系统同等重要。延伸阅读仓库内子系统 A 的处理器工厂与运行上下文src/server/metrics/base.metrics.ts各实体族的调度与注册src/server/jobs/update-metrics.ts增量重算通用工具与 CH 侧任务生成src/server/metrics/metric-helpers.ts漫画完整工作示例src/server/metrics/comic.metrics.ts漫画读取路径src/server/routers/comics.router.ts对账检测实现src/server/services/metric-reconciliation.service.ts对账任务与告警阈值src/server/jobs/metric-reconciliation-audit.ts修复写入与 Flipt 门控src/server/services/metric-reaction-repair.service.ts最终视图封装src/server/flipt/client.ts【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表