
每日预警系统总体设计从业务诉求到架构落地的手记我想分享一下我们最近完成的一个「每日预警系统」——如何把一个看似简单的运营诉求设计成一个可追溯、可演进、性能可靠的工程方案。一、问题定义到底要预警什么业务诉求原始表达记录每天查询到有哪些用户近3天没有交易用户是通过审核的新建表方便查询哪天有哪些不交易的用户。这段话翻译成架构语言实际是三个需求诉求 架构解读连续3天无交易 判定规则需要定义「无交易」的数据口径记录每天方便查询 快照存储按天留存历史可回溯任意一天通过审核的用户 目标人群排除未激活、未认证的噪音我的第一反应 这不能做成「实时查交易记录」而应该做成每日快照。原因有三性能交易表 90 万行每次实时扫描不可接受历史可追溯运营想知道「上周五有哪些人开始沉默」需要留存历史解耦预警计算与实时交易解耦凌晨集中算一次二、数据流设计┌─────────────┐ ┌─────────────────────┐ ┌──────────────┐│ 交易记录 │ │ cn_user_daily_ │ │ cn_user_ ││ buy_record │───▶ │ flow_summary │───▶ │ churn_ ││ 90万行 │ │ 按天聚合 23万行 │ │ warning_ │└─────────────┘ └─────────────────────┘ │ record ││ ▲ └──────┬───────┘│ │定时填充 ││ │(每4分钟凌晨0:02) │└─────────────────┘ ▼┌──────────────┐│ 管理后台页面 ││ 展示最新快照 │└──────────────┘核心决策引入中间聚合层 cn_user_daily_flow_summary为什么不直接用 buy_record买 90 万行原始交易│├─ 问题1JOIN 用 OR 条件(sale_user_idx OR pay_user_idx)→ 索引失效├─ 问题2GROUP BY MAX UNION → 全表扫描└─ 问题3每天查询都重复扫描浪费│▼cn_user_daily_flow_summary按天预先聚合├─ 已经算好每个用户每天的交易数 transaction_count├─ 唯一索引 (stat_date, user_id) → 按天查询走索引└─ 数据量从90万降到23万这就是经典的**「读时计算 → 写时计算」**重构把高频查询的聚合结果提前算好。三、表结构设计预警快照表 cn_user_churn_warning_recordCREATE TABLE cn_user_churn_warning_record (id BIGINT AUTO_INCREMENT PRIMARY KEY,stat_date DATE NOT NULL COMMENT 统计日期,user_id BIGINT NOT NULL COMMENT 用户ID,phone VARCHAR(20),business_name VARCHAR(255),province VARCHAR(50),last_trade_time DATETIME COMMENT 最后交易日,create_time DATETIME DEFAULT CURRENT_TIMESTAMP,UNIQUE KEY uk_date_user (stat_date, user_id),KEY idx_stat_date (stat_date),KEY idx_user_id (user_id));设计要点决策 理由(stat_date, user_id) 唯一索引 同一天同一用户只能一条快照天然防重冗余 phone/business_name/province 反规范化查询免联表数据量小每天几千条冗余成本可忽略last_trade_time 冗余 展示时直接显示无需回查不设 del_flag 快照是历史记录不应删除用 TRUNCATE 做整表重置为什么不存「沉默天数」我一度纠结是否要冗余 silent_days 字段。最终决定不存因为last_trade_time 已经足够推导沉默天数是相对统计日期的如果快照固定存「当时的沉默天数」之后查看语义会混乱相对哪天保持数据最小化宁可前端现算四、核心 SQL连续3天无交易的判定判定逻辑架构师视角「连续3天无交易」有两种实现口径方案A最后交易距今 ≥ 3天MAX(create_time) DATE_SUB(NOW(), INTERVAL 3 DAY)优点一条 SQL简洁缺点是「最后一次交易很久以前」不是严格「连续3个自然日无交易」方案B昨天、前天、大前天这3个自然日都无交易最终采用LEFT JOIN ... s1 ON s1.stat_date 昨天 AND 有交易LEFT JOIN ... s2 ON s2.stat_date 前天 AND 有交易LEFT JOIN ... s3 ON s3.stat_date 大前天 AND 有交易WHERE s1 IS NULL AND s2 IS NULL AND s3 IS NULL优点语义精确符合「昨天前天大前天都没有交易」的原始诉求优点走 uk_stat_date_user_id 唯一索引三个点查极快缺点依赖 daily_flow_summary 的时效性我选择了方案B因为业务原始诉求明确指向「连续3个自然日」。且配合凌晨时序0:02 填昨日数据 → 3:01 统计保证数据就绪。防呆设计INNER JOIN cn_user_daily_flow_summary hist ON hist.transaction_count 0这条 INNER JOIN 确保只预警曾经交易过的用户。否则全平台没交易过的新用户全被捞进来预警就失去意义了。五、定时任务设计每日时间线00:02 fillV2(昨天) → 补齐昨天的 daily_flow_summary03:01 saveChurnWarning() → 跑预警统计写快照依赖时序 预警统计依赖 daily_flow_summary 完整所以必须放在凌晨0:02 填充之后。我特意把预警放到3:01留出近3小时缓冲避免填充任务异常导致预警漏判。幂等性 快照 SQL 用 ON DUPLICATE KEY UPDATE last_trade_time VALUES(last_trade_time)即使任务重复执行或凌晨3:01手动补跑同一天也只会有一条不会产生脏数据。六、接口与页面后端接口接口 作用POST /admin/mall/churnWarning/save 手动触发统计异步POST /admin/mall/churnWarning/list 查询最新快照分页异步化设计 save 接口用线程池异步执行立即返回避免大 SQL 阻塞 HTTP 请求导致超时。// 用项目已有的线程池而非 new Thread()AutowiredQualifier(asyncServiceExecutor)private Executor taskExecutor;PostMapping(/admin/mall/churnWarning/save)public AjaxResult save() {taskExecutor.execute(() - churnWarningService.saveChurnWarning());return AjaxResult.success(统计中稍后刷新);}前端页面手动统计按钮测试/初始化用关键词搜索手机号、企业名沉默天数标签按最后交易日计算绿/橙/红分级分页数据量大时分页展示设计权衡 页面只展示最新一次快照WHERE stat_date (SELECT MAX(stat_date) ...)默认不提供日期选择。因为业务方明确说「不用查历史日期」简化交互。七、架构复盘与改进空间做得对的快照模式把「实时查询」改为「每日归档」历史可回溯聚合中间层引入 daily_flow_summary避免大表反复扫描幂等写入唯一索引 ON DUPLICATE KEY容错重跑反规范化冗余展示字段查询免联表可改进的方向 说明预警通知闭环 目前只记录展示可扩展为企业微信/短信通知滑动窗口 当前是固定「昨天前天大前天」节假日可能误报可改为「最近N天滑动窗口无交易」多维信号 目前只看交易可叠加登录、浏览等行为信号提升准确率沉默天数冗余 如业务需要按沉默天数排序/分组可冗余存 silent_days数据分区 快照表按 stat_date 分区历史数据量大时可归档八、总结这个「每日预警系统」的本质是把业务规则翻译成可执行、可追溯、高性能的工程方案。从架构角度它体现了几条通用原则写时计算优于读时计算 — 聚合结果提前算好快照优于实时 — 历史可回溯性能可控冗余换取性能 — 反规范化存储展示字段幂等保证可靠 — 定时任务可安全重跑异步保护体验 — 大任务不阻塞请求它不是最复杂的系统但把「业务诉求 → 数据模型 → 任务调度 → 展示层」这条链路走通且跑稳对运营决策提供了实实在在的支撑。这大概就是架构的价值——不炫技但每一处设计都有据可依。