看板/导出的快照覆盖判定此前只比对批次"声明窗口"(windowTo = 跑批当天), 但每天 03:00/05:30 跑批时当天还没签收完,数据只到跑批那一刻,于是把 "还没签收完的当天"误判成已覆盖:10-08 生成的批次只会算到 10-07(约 3.8 万), 当天签收的单(用户清单里那 5,820 条)整批缺失。 - 快照批次新增 dataThrough(该批真实最大签收时间),覆盖上限改为 min(windowTo - 1, dataThrough 当天):跑批当天永远不完整,只信任到前一天; 数据源本身滞后时再由 dataThrough 收窄。 - 生成任务逐片记录 dataThrough 并写回批次;旧批次缺值时按 windowTo - 1 兜底。 - 定时器默认 05:30 → 03:00;文档同步更新覆盖规则。 - migration: LogisticsArrivalSigningDurationSnapshotRun.dataThrough。 任务名:物流到货签收时长-deepseek
29 KiB
物流到货签收时长看板 · 订单明细每日快照
为什么要做
看板的全部数据来自远端只读报表视图 rpt.jst_order_express_details(外加 jst_qmcust_orderlist_item 条码明细和 SupplyChainProductArchive 产品档案富化)。
该视图是远端对象,一次扫描通常以分钟计,页面每次刷新、翻页、下钻、导出都要重新扫一遍,看板因此很难"高效运转"。
做法:每天把看板口径的订单明细物化到本地 Postgres,看板优先读本地快照,只有快照覆盖不到时才回落实时数据源。
数据与表
条码/供应商/采购的匹配来源(两套互补的订单表)
报表库把订单明细拆成了两套互不重叠的表,只看一套会漏掉近一半订单的条码:
| 表 | 覆盖平台 | 规模(实测) |
|---|---|---|
rpt.jst_qmcust_orderlist / rpt.jst_qmcust_orderlist_item |
淘宝天猫、阿里巴巴(1688)、淘工厂、天猫供销 | 408 万 / 576 万行 |
rpt.jst_Orders / rpt.jst_OrderItem |
京东商城、京东厂家直送、拼多多、头条放心购(抖音)、得物、唯品会、线下、微信视频号、小红书 | 124 万 / 175 万行 |
看板取条码时会同时查两张明细表并用 INNER_ORDER_ID(即 o_id)合并(listItemRows,两表按订单号各发一批查询后合并结果)。修复前只查 jst_qmcust_orderlist_item,导致京东/拼多多/抖音/得物/唯品会的订单条码全空;修复后新生成的快照条码匹配率为 100%(此前整体 52.3%)。
注:
rpt.jst_salesOutboundInfo(销售出库单头)可以读取,但只有o_id / so_id / l_id / shop_name / io_id / qty等出库单头字段,没有 SKU 列,不能单独提供条码。
纯新增两张表(迁移 prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot):
| 表 | 作用 |
|---|---|
LogisticsArrivalSigningDurationSnapshotRun |
批次元数据:snapshotDate、windowFrom、windowTo、status(RUNNING/COMPLETED/FAILED)、rowCount、message |
LogisticsArrivalSigningDurationSnapshot |
明细行:与看板 09 订单明细一一对应,含条码/供应商/采购/productItems 等快照时点已固化的富化结果 |
要点:
- 只有
status = COMPLETED的批次对读路径可见,写入中途或失败的批次不会被读到。 - 行唯一键
(snapshotDate, rowKey),rowKey与下钻键一致(内部单号 + 线上订单号 + 物流单号 + 签收时间),重复执行用skipDuplicates幂等。 (snapshotDate, signTime)有索引,按签收日期区间取数走索引。
生成任务
# 默认:快照日期=当天(Asia/Shanghai),窗口=最近 62 天(覆盖本月与上月),保留最近 2 批
npm run sync:logistics-arrival-signing-duration-snapshot -- \
--apply --confirm=sync-logistics-arrival-signing-duration-snapshot \
--expected-database-identity="<本地库身份>" \
--expected-source-database-identity="<远端报表身份>"
可调参数:--snapshot-date=YYYY-MM-DD、--window-days、--chunk-days(默认 7,按天分批抓取以控制内存)、--batch-size(默认 2000)、--retention、--force(当天批次已完成时强制重建)。
编排逻辑在 src/server/services/logistics-arrival-signing-duration-snapshot.service.ts:
- 当天批次已
COMPLETED且未--force→ 直接跳过; - 标记
RUNNING并清空当天旧行; - 按
chunk-days分片读远端并写入本地,同时记下真正抓到的最新签收时间dataThrough; - 全部成功才标记
COMPLETED(连同dataThrough),随后按retention清理更早批次;任一分片失败则标记FAILED并保留错误信息,读路径会自动继续用上一批快照; - 收尾关闭远端连接池(一次性任务必须关闭,否则进程挂在事件循环上)。
生产定时(systemd timer,默认北京时间 03:00):
sudo bash scripts/install-logistics-arrival-signing-duration-snapshot-timer.sh
首次安装会生成 $BASE/shared/logistics-arrival-signing-duration-snapshot.env 模板,需要填两个身份校验值(缺省为空会拒绝写入)。可用环境变量覆盖:LOGISTICS_SNAPSHOT_CALENDAR、LOGISTICS_SNAPSHOT_WINDOW_DAYS、LOGISTICS_SNAPSHOT_RETENTION。
读路径规则
src/server/repositories/logistics-arrival-signing-duration.repository.ts 的每个读方法都先判断快照是否可用:
- 取最近一批
COMPLETED快照;请求区间完全落在快照的实际覆盖范围内才读快照(否则回落实时视图,避免返回不完整结果)。覆盖上限 =min(windowTo - 1, dataThrough 当天):windowTo是跑批当天,当天还没签收完,所以只能信任到它前一天;- 再用批次记录的
dataThrough(该批真实最大签收时间)兜底,避免数据源滞后时把不完整的一天当成完整。 - 例:一批 10-08 生成、数据只到 10-08 05:30 的快照,对
10-01~10-08不再命中,看板/导出自动改用实时源,不会少算 10-08。
- 命中快照时返回值带
source: 'snapshot'与snapshotDate,页面头部显示"快照数据 · 日期";未命中为source: 'live',显示原来的"数据更新 · 时间"; - 快照表还没迁移或还没有任何批次时,
findLatestCompletedRun()返回 null(并吞掉异常),整条链路自动等价于改造前行为。
因此在以下情况仍会走实时数据源(较慢,但结果正确):请求区间超出窗口、任务当天尚未跑完或失败、本地还没生成过快照。
指标口径(整区间精确 vs 样本)
命中快照时,整块看板都是整区间精确值,与 2,000 条样本彻底解耦:
| 看板块 | 精确口径实现 |
|---|---|
| 经营总览(6 个 KPI) | aggregateDashboard() 一次扫描聚合 |
| 履约环节拆解(5 环节平均/中位/P90) | 同上,percentile_cont 直接给出分位数 |
| 发货→签收时效分布 | 同上,4 个区间计数 |
| 付款→签收 / 付款→发货 多维经营分析(01–08) | aggregatePayToSignAnalysis() / aggregatePayToSendAnalysis(),按签收日期 + 采购/供应商/条码(jsonb_array_elements 展开 productItems)+ 物流公司/发货仓/平台/店铺分组 |
| 发货仓 / 物流公司 超时排名 | aggregateThresholdRankings() |
| 09 订单明细下钻 | listDrilldownRows():按维度条件下钻,列出全量匹配订单并给出真实总数与页数 |
口径一致性由三点保证:筛选条件统一由 snapshotFilterConditions() 派生(Prisma 与原始 SQL 共用);比率/取整统一走 lib 的 buildLogisticsArrivalSigningDurationOverview、buildPayToSignMetrics、buildPayToSendMetrics;排序统一走 comparePayToSignGroups / comparePayToSendGroups / compareLogisticsArrivalSigningDurationThresholdRows。每个聚合块都用"把整区间明细喂给样本路径同样函数"的方式做过逐字段比对(MATCH)。
看板顺序与联动链路
履约环节拆解:目标值与看板宽度
经营总览:平均到货时长卡片与异常下钻
- 签收订单后面新增 付款→签收平均到货时长 卡片:主值是该环节平均时长,附行给出 本月目标(上月平均 × 98%)与 目标达成率,与履约环节拆解同一套口径。
- 缺少关键时间 / 时间异常 两张卡片可点击:点击后用
{ kind: 'anomaly', value: 'missing' | 'invalid' }条件下钻,09 订单明细列出对应订单;再点一次取消。判定口径与总览一致——缺少关键时间 = 四个时间任一为空;时间异常 = 四个时间齐全但至少一个环节时长算不出来(时间顺序异常)。 - 自动滚动只保留给经营总览的异常卡片(卡片上写着"点击看明细"):点它们会把 09 订单明细滚到视口顶部(
detailBoardRef+scrollIntoView,按"下钻签名"去重,不会在翻页时反复滚动)。经营对象维度(平台/店铺/发货仓/物流/采购/供应商/商品/签收日期)的点选不再自动滚动——那些看板是连着看的,一滚到底就没法接着看其他维度、还得手动往上拉;下钻筛选本身照常生效,09 明细就在原位置更新。 - 为保证"卡片数字 = 点击后的明细条数",命中快照时经营总览始终按整区间精确统计(
aggregateDashboard(..., includeStages=false),只算总览不算分位数);超出精确上限的大区间里,只有履约环节拆解/时效分布/经营分析/超时排名退回样本,页面脚注会写明。
经营总览视觉层次与看板工具条
-
经营总览从"7 张卡一张网格"改为两组网格:5 张履约指标卡一排(
--sc-kpi-cols,标准 5 / 紧凑 3 / 展开 2),2 张数据质量卡单独成排(--sc-kpi-alert-cols,标准 2 / 展开 1)。7 张卡在常见宽度下不再是 5+2、第二排右侧大片留白。 -
卡片排版统一:标签 12px 用
--sc-muted,数值 24px 且font-variant-numeric: tabular-nums,用margin-top: auto贴底、副标题保留 2 行最小高度(min-height: 2.9em)。同一行内标签换行不影响数值对齐,卡片等高、数字列对齐。 -
绿色强调卡(付款→签收平均到货时长、付款→签收履约率)改为"浅绿底 + 左侧 2px 竖条"(
inset 2px 0 0),不再整卡大面积着色;点击态的竖条与边框跟着状态色走。 -
缺关键时间 / 时间异常改为横向"告警条":左侧图标 + 名称/说明,数值贴右,用低饱和暖色表达"异常"语义;点击下钻、
is-active选中态、aria-pressed与aria-label(含条数)都保留。 -
修复看板工具条(导出 / 尺寸切换)样式不生效的根因:
BoardTools是本文件内的独立组件,styled-jsx 按组件注入作用域类,主组件里的.sc-logistics-icon-button/.sc-logistics-board-tools/.sc-logistics-board-size-label规则永远匹配不到它的 DOM,四个看板右上角一直是"裸图标"。现在样式放进BoardTools自己的<style jsx>(深色排名表头用:global(.sc-logistics-ranking-board-header)适配),并删除主样式块里的死规则。 -
每个环节多出一列 目标:
本月目标 = 上月同环节平均时长 × 98%(LOGISTICS_ARRIVAL_SIGNING_DURATION_TARGET_IMPROVEMENT_RATIO)。基准月取所选区间结束日期所在月的上一个月(跨年正确),基准值由aggregateStageAverages()用同一套筛选条件跑上个月区间得到;当前平均不高于目标时目标值标绿表示达标,鼠标悬停可看到"目标 = 上月平均 × 98%"的算式。 -
目标后面还有 目标达成率:该环节时长不超过目标的订单占比。精确口径由
aggregateStageTargetHits()按目标值统计命中订单数,样本口径由buildLogisticsArrivalSigningDurationStageAchievement()在样本上计算;无目标或无可计算订单时显示-,鼠标悬停显示"命中 / 可计算"明细。环节行的平均/中位/P90/目标/达成率都只显示数值,单位(小时)只在表头语义里体现。 -
环节行按
128px / 弹性条 / 74 / 74 / 86 / 92收紧列宽,列表支持横向滚动,避免 P90、目标被截断。 -
履约环节拆解与发货→签收时效分布之间加了可拖动的分隔条(
role="separator",支持左右方向键微调),比例默认 62% 并记在本地,可随时左右拖动调整两个看板的宽度。
经营对象问题排名各看板按"上级 → 下级"顺序排列,并逐级联动:
01 签收日期维度 → 02 平台维度 → 03 店铺维度 → 04 发货仓维度 → 05 物流维度
→ 06 采购维度 → 07 供应商维度 → 08 商品维度 → 09 订单明细
点击任意一级对象,都会保留更上级条件、丢弃更下级条件,并把这条链路作为筛选条件重新取数:
- 整区间口径:链路以
cascade(JSON 数组,LogisticsArrivalSigningDurationDrilldownFilter[])随 dashboard 请求下发,服务端在aggregatePayToSignAnalysis/aggregatePayToSendAnalysis/aggregateThresholdRankings的 WHERE 上追加cascadeSqlWhere,因此下级看板与 09 明细都只展示对应数据; - 样本口径(实时源或超出精确上限):前端用
logisticsArrivalSigningDurationRowMatchesCascade收窄样本后重算; - 店铺维度分组键是"平台 + 店铺",因此平台级联动能精确到该平台下的店铺;
- 联动刷新是原位刷新(只有页面筛选变化才显示整块加载态),排名看板标题会显示当前链路与"联动加载中…"提示;
- 经营总览、履约环节拆解、时效分布属于页级口径,不随联动链路变化。
经营对象的目标与目标达成率
02–08 经营对象表(平台 / 店铺 / 发货仓 / 物流 / 采购 / 供应商 / 商品)在原有指标后面追加 目标 与 目标达成率 两列;01 签收日期维度的「指标 × 日期」矩阵与总计不追加(按日算"上月同日目标"没有业务含义)。
- 目标 = 该对象上月同环节平均时长 × 98%,达成率 =
100% − (平均 − 目标) / 目标 × 100%,与履约环节拆解同一套口径,只是精确到每个经营对象——所以每个对象的目标值不同(例如 08 月各采购平均不同,9 月目标 52.7 / 56.5 / 60.5 …),达成的判断是"相对自己上个月是否改善 2%"。 - 数值口径与展示一致:平均、目标都按 1 位小数取值后再算达成率,便于和表里看到的数字对账;没有上月基线的对象(新采购、新店铺、当月新条码)显示
-。 - 取数:
aggregateObjectBaselines()(快照仓库)在上月区间一次扫描算出 7 个维度 × 付款→签收 / 付款→发货 两个环节的平均时长,随 dashboard 载荷以objectBaselines下发;前端用withObjectAnalysisTargets()按分组键(JSON.stringify(keyParts),与样本路径同一套键)合并,样本口径与整区间口径共用。 - 这条聚合约 2–4 秒,因此按「快照日期 + 基准月 + 关键词/排除词」在服务端缓存 10 分钟(这些平均值只在每日快照重建时变化),并与看板其余查询并行执行;查询失败只让这两列显示
-,不会把整块看板拖回实时源。 - 「经营对象问题排名」导出的 CSV 同步带上这两列。
看板分页
页面顶部的「经营管理 / 物流时效 + 看板标题 + 物流签收明细口径 + 明细条数/更新时间」页头已去掉(用户口径:无需展示,标签页标题已经说明是哪个看板);快照/缓存状态在数据源异常时的黄色提示条里仍会给出。
批量关键词查询
筛选区的「关键词」支持批量输入(与排除关键词同一套拆分规则):
- 按中文逗号
,、英文逗号,、换行拆分,自动去空、去重,最多 100 个、单个最长 120 字符(原始输入上限 4000 字符); - 多个关键词之间是"或":命中任一字段即算匹配。整区间(快照)口径覆盖:内部单号、线上订单号、物流单号、物流公司、店铺、平台、发货仓、收货地址,以及快照时点固化的条码、商品名称、供应商、采购(
KEYWORD_FIELDS)——所以能直接按采购/供应商/条码检索; - 走实时源时(区间超出快照窗口)只覆盖前 8 个报表字段,条码/采购/供应商这三类需要本地快照支持,这是两套口径的已知差异;
- 解析规则由
splitSearchKeywords()统一提供,服务端缓存键按关键词集合(排序后)计算,输入顺序不同不会重复缓存。 - 输入框与其它筛选控件等高(34px 单行高度,多行时框内滚动),"已识别 N 个关键词"显示在标签行右侧并在空间不足时省略,不再把这一格撑成两行高。
筛选区的三个按钮各管一件事,点下去立即按新条件查询(互不重复):
| 按钮 | 签收日期 | 关键词 | 排除关键词 | 结果 |
|---|---|---|---|---|
| 重置 | 清空(输入框留空) | 清空 | 清空 | 没有任何筛选条件;空日期由服务端兜底为本月 1 号~昨日(dateRangeCleared 让输入框保持空白,不被兜底值回填) |
| 恢复默认 | 回到本月 1 号~昨日(显式填回输入框) | 保留 | 恢复系统默认配置(全部勾选) | 默认口径 |
| 清空排除 | 回到本月 1 号~昨日(显式填回输入框) | 保留 | 置空 | 默认区间 + 不排除任何关键词 |
三者过去都只是"改输入、等用户点查询"(重置只清日期与关键词、另两个只改排除词),结果看起来一样容易混;现在各自的语义、界面状态与结果都区分开了,按钮 hover 有对应说明,筛选栏底部也有一行图例。空日期提交时服务端仍按默认区间查询(跨天且当日快照未生成时可能落实时源,较慢但结果正确)。
默认签收日期区间是「本月 1 号 ~ 昨日」(由 normalizeLogisticsArrivalSigningDurationFilters() 生成):当日签收数据仍在滚动,且每日快照只覆盖到昨日,用"到昨日"作默认值可以让首屏直接命中快照(整区间精确 + 秒级),不再因为区间含当天而回落到实时源。每月 1 号时会自动退化为"昨日当天"(本月 1 号还没到昨日)。
筛选控件(签收日期、关键词输入框,以及排除关键词选择器的摘要)都是受控并与服务端下发的筛选条件同步:从菜单重新进入(无参数 URL)、重置后查询、浏览器前进/后退时,控件会刷成当前真正生效的条件。这一点很重要——否则会出现"数据已经是默认区间,输入框还停在上次条件"的错觉。
排除关键词选择器
筛选区的「排除关键词」是按字段分组的勾选式选择器(ExcludeKeywordPicker.tsx)。关键词只能配置在它要匹配的那个字段上,不跨字段混用——这是这一版最重要的口径修正。
字段与匹配规则
| 字段(配置分组) | 数据列 | 匹配方式 |
|---|---|---|
| 发货仓 | warehouseName |
该字段值包含关键词(不区分大小写),命中即排除 |
| 物流公司 | logisticCompanyName |
同上 |
| 订单标签 | orderLabels(JSON 数组文本) |
同上 |
| 买家留言 | buyerMessage |
同上 |
| 卖家备注 | remark |
同上 |
配置与筛选都以「字段 → 关键词」存储(LogisticsArrivalSigningDurationExcludeKeywords)。SQL 侧对每个关键词只生成一条 NOT (该字段 ILIKE '%词%');旧版"命中 5 个字段里任意一个就排除"的口径已废弃——那正是「物流」会连带命中订单标签里的 商家指定物流、物流探查 和买家留言里"物流单号"的根因。
选择器交互
- 按字段分组:发货仓 / 物流公司 / 订单标签 / 买家留言 / 卖家备注,每组标题显示"共 N 项 · 已勾选 M 项",右侧索引条按字段跳转(字段没有候选词时不显示该组);
- 默认全选:候选词 = 该字段的默认排除词 ∪ 当前已排除词(旧链接里带的自定义词不会丢),进入看板时全部处于"排除"状态;取消勾选 = 本次不排除该项,确定后立即以当前表单条件重新查询(
requestSubmit()在受控值落到 DOM 后再触发);一个都不勾时以excludeKeywordsMode=clear提交,避免回落到默认排除词; - 选择器内含:搜索、
全部 / 已选项(n)页签、全选 / 反选 / 清空(作用于当前可见列表,全选带半选态)、紧凑模式(四列更密); - 新增候选词仅系统管理员可操作:底部先选字段、再输入关键词(回车或点「添加」),新词并入该字段的默认排除词并立即落库(
PUT …/settings,服务端assertCanManage同样校验管理员身份),提示"已保存到「物流公司」默认排除关键词:…";非管理员只看到"需要新增请联系系统管理员"; - 页面原先独立的「默认排除关键词」设置卡片已删除,维护入口统一收敛到选择器;
清空排除(不排除任何词)与恢复默认(回到全部默认排除词)两个按钮保留,只改输入、点查询生效。
编码与兼容
- 筛选栏摘要按字段给数量(如"已排除 14 项:发货仓 3 · 物流公司 5 · 订单标签 2 · 买家留言 2 · 卖家备注 2"),URL 用
excludeKeywords=字段:词,词;字段:词的紧凑写法; - 老链接的平铺写法按当前配置归位:
excludeKeywords=词1,词2会被逐个对应到配置里它所属的字段(大小写不敏感,bbff会归一到配置里的BBFF),配置里已经没有的词说明它没有字段、直接忽略——不会再把一个词摊到 5 个字段上(那会让选择器每组都列出全部关键词)。只有当配置为空、任何词都对应不上时才退回旧口径,避免老链接突然变成"不排除任何词"。 - 历史配置(数组)在
normalizeExcludeKeywords()里仍按旧口径挂到全部 5 个字段(那是当年真实的匹配语义);但配置一旦以新结构保存,后续解析都会走"按字段归位"。
当前初设配置(2026-09-30 落库):发货仓=自动发货仓 / 常州简尚家居有限公司 / 京东云仓;物流公司=京喜 / 物流 / 安能 / 自提 / 中铁;订单标签=配件 / 1688一件代发;买家留言=BBFF / SSDD;卖家备注=BBFF / SSDD。2026-09-01~09-02 实测:不排除 26,296 行 → 按字段排除后 17,506 行(旧口径同样 14 个词是 16,552 行,多出的 954 行正是"标签里带物流、物流公司里没有"的那些)。
经营对象问题排名里的每个看板都有独立翻页,不再用"显示更多"或无限滚动,也不会因为对象太多被截断:
- 01 签收日期维度:按日期列翻页(默认 10 列/页,可切 10/20/50/100);
- 02–08 采购/供应商/条码/物流公司/发货仓/平台/店铺:按对象行翻页(默认 20 行/页);
- 发货仓 / 物流公司 超时排名:按对象行翻页(默认 20 行/页)。
- 09 订单明细:翻页控件就在看板内部(不再挂在页面底部);未下钻时按 URL 的
page/pageSize走服务端分页(每页 25/50/100/200,命中快照时显示真实总数"共 N 条筛选结果"),下钻时用本地分页翻全量匹配明细。
分页条统一由 BoardPagination.tsx 渲染(含"共 N 条 · 第 x / y 页"、上/下一页与每页条数),行集合变化(例如切换采购后重新筛选供应商)时自动回到第 1 页。
每页条数切换的坑(已修):下拉框原先在 onChange 里同时调了 onPageSizeChange(新值) 和 onPageChange(1)。服务端分页(09 订单明细未下钻时)这两次都会触发 router.push,而后一次带着旧的 pageSize 构造 URL 并覆盖前一次,表现为"订单明细每页条数选了没反应、又跳回 50"。现在 BoardPagination 只通知"每页条数变了",回到第 1 页由调用方负责:服务端分页用 href(1, { ...filters, pageSize }),本地分页(各经营对象表、发货仓/物流公司排名、下钻后的 09 明细)在 setState 里带 page: 1。
店铺维度表在"店铺"列前额外展示"平台"列:点击平台即把该表筛成该平台的店铺(标题变成"店铺维度 · 天猫",再次点击取消),点击店铺名仍走 09 明细下钻。店铺分组的业务键是"平台 + 店铺",避免同名店铺跨平台互相覆盖;平台列在样本路径(JS 归组)与整区间路径(SQL GROUPING SETS ((platform, shop)))都做了同样的处理,并逐字段比对过一致性。
规模上限与样本回落
SQL 聚合成本与区间行数近似线性(约 5 万行/秒)。当**区间原始行数超过 LOGISTICS_ARRIVAL_SIGNING_DURATION_EXACT_SCOPE_MAX_ROWS(11 万条,约 11 天)**时,getDashboard 会退回区间内均匀抽取的 2,000 条样本(listDashboardSampleRows,5 层并行、按签收时间分层,不再是"取最近一段"),并在底部注释写明原因与"缩小签收日期范围即可得到精确值"。
返回值上的 dataScope(exact / sample)与 dataScopeReason(live-source / range-too-large)驱动卡片副标题、底部注释与 CSV 导出的口径说明。
实测(663k 行快照,含 13 个默认排除关键词):
| 区间 | 原始行数 | 口径 | 首屏耗时 |
|---|---|---|---|
| 1 天 | 约 1.3 万 | 整区间精确 | 0.55s |
| 2 天 | 约 2.6 万 | 整区间精确 | 1.7s |
| 5 天 | 约 6.5 万 | 整区间精确 | 3.1s |
| 本月 | 约 30 万 | 样本(超上限) | 3.8–5.0s |
服务端 60s 内重复访问直接命中缓存。要把大区间也做成亚秒精确,方向是在快照写入时就把 4 个时间戳、5 个环节时长落成列,并把多维指标的计数改为位掩码 GROUP BY(一次扫描产出全部桶计数),现有文档结论不变、只替换实现。
查询性能优化(2026-10)
上线后"每次查询生成太慢"的根因有三个,都在快照读路径上:
- 整表顺序扫描:聚合查询原先
SELECT全列(rowKey、productItems、十几个文本列), Postgres 只能对 900MB 的堆表做Seq Scan(28 天区间实测read=106409页、6.3 秒)。 现在按用途收窄列集合——FILTERED_TIMELINE_SELECT(只取 4 个时间戳)用于整区间总览/环节目标,FILTERED_DIMENSION_SELECT(时间戳 + 4 个维度列)用于签收日期/平台/店铺/发货仓/物流/超时排名—— 并新增覆盖索引LogisticsArrivalSigningDurationSnapshot_cover_idx((snapshotDate, signTime) INCLUDE (payTime, sendTime, fetchTime, warehouseName, platformName, shopName, logisticCompanyName)), 扫描变成 Index Only Scan(同样的 28 天区间 26ms、Heap Fetches: 0)。 - 有序集聚合把整行拖进排序:
percentile_cont(0.5/0.9) WITHIN GROUP原先和十几个计数写在同一条语句里, 输入行 14 列宽,PG 18 会把它整行送进排序(两个分位数单独就占 4.3 秒,环节分位数叠在一起甚至到分钟级)。 现在分位数按列拆成独立语句、只投影一个浮点列,并与计数语句并行下发(Promise.all)。 - CTE 表达式被重复内联:
measured/durations的CASE + EXTRACT会在每一处引用被重新计算, 同一条聚合里重复十几次。改为AS MATERIALIZED后只算一次(计数语句从 2.3s 降到 ~0.5s)。
另外把 环节目标(上月平均 × 98%) 也接入 10 分钟内存缓存(此前每次刷新都重跑上月聚合)。
优化前后(本机 Postgres 18、快照 648,689 行、冷启动、无缓存):
| 场景 | 优化前 | 优化后 |
|---|---|---|
仓库层 getDashboard(28 天,样本口径) |
7.3s | 2.3s |
仓库层 getDashboard(10 天) |
4.4s | 0.4s |
仓库层 getDashboard(2 天,精确口径) |
1.7s | 0.3s |
| 浏览器首屏出数(冷启动,含 5.6MB 载荷 + 渲染) | ~7–10s | 2.7s |
正确性用 2 天区间做过逐项对账:SQL 聚合与"取全量明细在 JS 里现算"在订单数、平均/中位/P90、 异常计数、达成率、时效分布、5 个环节的 可计算单量/平均/中位/P90 上完全一致。
时间戳在 SQL 里用 NULLIF(BTRIM(col),'')::timestamp 直接转型:快照写入的就是远端 CONVERT(varchar(19), …, 120) 结果,格式固定;早期版本用正则逐行校验,整区间聚合约慢一个数量级。
运维排查
-- 批次状态
SELECT "snapshotDate", status, "windowFrom", "windowTo", "rowCount", message, "finishedAt"
FROM "LogisticsArrivalSigningDurationSnapshotRun" ORDER BY "snapshotDate" DESC;
-- 当天行数
SELECT count(*) FROM "LogisticsArrivalSigningDurationSnapshot" WHERE "snapshotDate" = CURRENT_DATE;
- 页面长时间显示"数据更新"而不是"快照数据" → 先看批次是不是
FAILED,或窗口是否覆盖了页面的签收日期区间。 - 批次
RUNNING卡住(进程被杀)→ 该批次不会发布;直接重跑或加--force即可(重跑会先清空当天行)。 - 需要重建历史窗口 →
--window-days=调大 +--force。
部署步骤(生产)
- 部署新版本代码;
- 执行迁移
prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot(需按生产迁移流程授权、先备份); - 跑一次手工同步生成第一批快照(
--apply),确认批次为COMPLETED、行数与窗口符合预期; - 安装 timer 并核对
systemctl list-timers; - 打开看板确认头部显示"快照数据 · 日期"。
注意:Prisma Client 重新生成过,运行中的 dev/prod 进程需要重启才能拿到新模型。