1. 为什么需要一套“短流量”分析系统:这事不是加个计数器那么简单
1.1 短流量到底指什么
“短流量”这个词在不同团队里定义不一样,有的叫短时流量,有的叫活动流量,还有的干脆叫流量脉冲。我在这套系统里给它的定义很明确:在一个较短时间内快速爬升、短促爆发、随后回落的访问波峰。电商大促、直播间投放、热点新闻、扫码领券、线下快闪活动,这些场景产生的流量都属于这个范畴。日常长尾流量是按天平稳分布的,统计模型和缓存策略都很成熟;但短流量是突发的,你不知道它什么时候来,一来就是分钟级甚至秒级的大浪。
传统做法是给系统加一个访问计数器,再让运维盯监控。但运营真正需要的是另外一个东西:流量来了之后,到底从哪个渠道来、哪个素材带来的转化高、哪个地域的用户点得最多、哪些页面留存最好。这些信息不靠计数,靠多维分析。所以我在做这套项目时没有走老路,直接用 SpringBoot + Vue + MyBatis + MySQL 搭了一套完整的数据分析与可视化管理系统,也就是标题里说的 ABO 系统。ABO 全称是 Analytics for Business Operations,翻译过来就是“面向业务运营的分析中台”,核心目标只有一个:让数据在业务还没结束前就能变成决策依据。
1.2 传统看数方式顶不住的核心原因
坦白讲,很多团队最开始压根不会考虑自建分析系统,都是直接看第三方统计平台。可实际用下来会发现三个绕不开的问题。
第一个是口径不统一。前端埋点上报的数字和后端访问日志的数字经常对不上,运营说今日 UV 是 10 万,技术说你接口只收到 9.2 万,中间差在哪没人说得清。短流量活动因为投放渠道多、跳转链路短,这种差异会被放大得特别明显。
第二个是链路太长。日志先落盘,再等离线任务跑批,结果出来已经是第二天中午。短流量活动的关键是前 30 分钟就要判断投放策略要不要调整,延迟一天的数据价值接近于零。
第三个是查询能力跟不上。给运营开个后台,一条 SQL 把一小时内的 PV、UV、渠道、地域全部 group by 出来,如果没有合适的索引和分区,MySQL 磁盘 IO 直接被打满,连正常业务都跟着遭殃。
这套系统的设计就是针对这三个问题来的:统一接入端做字段清洗和幂等去重,统一指标层做口径管理,统一可视化看板让运营自主拆解维度。数据链路短,结果秒出,不用每次都拉研发写临时 SQL。
1.3 系统闭环长什么样
我先把整体链路摆在这里,后面章节再逐个拆开。
前端埋点或服务端事件上报到达 SpringBoot 接入接口后,会经过参数校验、维度标准化和 event_id 幂等去重,然后批量写入 MySQL。分析服务按分钟、小时、自然日三个粒度做聚合,聚合结果放到 Caffeine 或 Redis 缓存里。Vue 管理后台读取聚合后的接口,用 ECharts 渲染趋势图、漏斗图、渠道排行和地域分布。
一句话概括:从事件产出到看板刷新,不经过 Hadoop 全家桶也能承接千万级日事件量。这个闭环是我刻意控制的。很多系统一上来就上 Kafka + Flink + ClickHouse,架构很正确,但维护成本和学习成本都不低。对日活跃百万到千万级别的运营后台来说,SpringBoot + Vue + MyBatis + MySQL 这套组合完全够用,后续量大了再平滑迁移也不迟。
2. 技术选型背后的取舍:SpringBoot+Vue+MyBatis+MySQL够不够用
2.1 Spring Boot:版本不是越高越好,兼容性才是第一位的
我见过不少团队卡在“SpringBoot 版本太高”这个坑里。比如把 Spring Boot 升级到 3.x,项目里还留着 MyBatis 2.x 的 starter,一启动就报ClassNotFoundException: javax.servlet.Filter,其实就是 Boot 3 把javax换成了jakarta包名,老依赖不兼容。解决方式很简单:要么用 Spring Boot 2.7.x + JDK 8,要么用 Spring Boot 3.x + MyBatis-Spring-Boot-Starter 3.x,不要混着来。
我当前这套项目跑在生产环境的是 Spring Boot 2.7.18,配 MyBatis-Spring-Boot-Starter 2.3.1,Java 版本 8。之所以不追最新版本,是因为分析系统没有太多新语法需求,稳定性和团队熟悉度更重要。如果你是要开新项目,用 Boot 3.2 + JDK 17 也没问题,但一定要先把依赖版本矩阵列清楚,别在框架升级上浪费排查时间。
Spring Boot 在这个项目里最大的价值不是自动装配,而是把连接池、事务、定时任务、监控这些基础设施一次性配好。我用 HikariCP 做连接池,用@Scheduled做小时级聚合任务,生产环境直接用 fat jar 跑,不需要额外装 Tomcat,这些东西对后台类系统来说是恰到好处的。
2.2 MyBatis:复杂报表场景下,SQL可控比什么都重要
选 MyBatis 而不是 JPA,是因为这套系统的绝大多数核心查询都是“带动态条件的多维聚合 SQL”。这种 SQL 用 JPA 写会非常别扭,各种 Specification 的拼接最后仍然要落到原生 SQL 上,还不如一开始就用 MyBatis 的 XML 写清楚。
MyBatis 对复杂聚合的支持是让我最放心的地方。一行 SQL 可以写清楚 PV、UV、去重人数、漏斗转化率,查询条件能按活动、渠道、时间、地域任意组合,索引执行计划掌握在开发手里,不用猜框架会生成什么 SQL。对数据分析系统来说,这是决定性的优势。
刚接触 MyBatis 的同学,我建议先去理解一个东西:XMLConfigBuilder。它是 MyBatis 启动时解析mybatis-config.xml和 Mapper XML 的入口,会把<select>、<insert>、<update>、<delete>注册成 MappedStatement。搞清楚这个过程,你就能明白为什么 mapper 接口方法名必须和 XML 的 statement id 对应,也就能理解@MapperScan和mapper-locations配置的作用了。
关于 MyBatis 缓存我也多说一句:二级缓存看着好用,但在这种实时聚合场景下最容易出问题。活动维度表可以开缓存,原始事件表绝对不能开,否则你会看到看板上的 PV 数字“卡住不动”。后面第五章我会专门展开。
2.3 Vue:后台管理场景下,它的上手速度和维护成本是优势
前端我没选 React,而是选了 Vue 3 + Vite。后台系统里百分之八十的页面是表格、表单、菜单、看板,这些场景 Vue 的单文件组件和指令系统写起来非常顺手,模板也更直观。Vue 的动态路由配合后台权限返回的菜单树,能实现不同角色看到不同页面的效果。组件库我用的是 Element Plus,图表用 ECharts,复杂交互靠组合式 API 组织,代码量比 jQuery 时代少了不是一点半点。
如果你团队前端主要做中后台,Vue 的学习曲线比 React 要缓不少,招人容易,后期接手也快。前端这块的具体实现我在第六章展开。
2.4 MySQL:它不是一个“不能用在高并发”的数据库
MySQL 在高并发写入场景里被很多人误解。实际上,百万级日活系统只要把“每条事件一次一 insert”改成“批量 insert”,把历史表按时间分区,把查询走聚合索引,MySQL 完全能扛住毫秒级的聚合查询。这套系统里的短流量事件表我按天做 RANGE 分区,单表存储压力被拆到多个物理分区,删除过期数据直接 drop partition,根本不用跑 DELETE 大事务。
当然,如果单日事件量超过一亿,MySQL 确实会吃力,这时候再考虑迁移到 ClickHouse 或者 StarRocks。但架构上要提前埋好伏笔,比如事件表设计成独立的 schema,聚合查询都走独立的 service 层,后续换存储引擎时不用大改业务代码。这就叫“边界内把一件事情做到极致”。
3. 系统模块拆解:从事件接入到可视化看板的闭环
3.1 数据接入模块
短流量数据接入主要有两条通道:前端 JS 埋点和服务端事件上报。
前端埋点适用于 Web 页面,用户在页面上的曝光、点击、滚动、跳转,由埋点 SDK 统一组装后发送到/api/v1/collect/events。服务端事件上报适用于服务器之间传输,比如下单成功、支付回调、优惠券核销这类后端才能拿到的事件。
统一的接入报文大概是这样的:
{ "event_id": "20250601103000001-a1b2c3d4", "project_key": "wap_shop", "activity_id": 1024, "channel_id": 8, "event_type": "click", "user_id": "u_10001", "device_id": "d_89898", "ip": "114.114.114.114", "ua": "Mozilla/5.0 ...", "page_url": "https://shop.example.com/promo", "referer_url": "https://ad.example.com/landing?id=8", "extra": { "item_id": "sku_233", "order_amount": 99.9 }, "event_time": "2025-06-01 10:30:00" }接入服务拿到事件后先做三件事:校验必填字段、格式清洗、幂等去重。幂等去重我直接用event_id做唯一键,因为网络重试和 SDK 重复上报在短流量场景里极其常见,不去重的话 PV 能虚增好几个百分点。这里有个细节:唯一键必须包含 event_time,因为分区表要求所有唯一索引包含分区键,下一章会讲。
3.2 指标分析模块
指标分析层是这套系统的核心,我把指标分成三类。
基础流量指标:PV、UV、IP 数、人均访问次数、平均停留时长、跳出率。渠道转化指标:曝光人数、点击人数、点击率、下单人数、转化率、GMV。路径漏斗指标:从落地页到注册页、从注册页到加购、从加购到支付,每一步的流失率是多少。
做漏斗时不能直接把埋点 log 一条条算,成本太高。我的做法是先把原始事件按activity_id + event_type + user_id + event_time聚合成会话表,再在会话表上做漏斗计算。这样既避免重复计算,也能保证口径一致。运营只需要在前端选择活动和时间范围,后端就会根据配置好的漏斗模型自动返回每一步的人数。
3.3 可视化看板模块
看板模块直接面向运营和决策层,我分了三个视图。
运营视图:默认显示实时趋势、渠道排行、地域分布、活动效果对比,核心是盯投放过程中的实时数据。决策视图:按周/月维度展示整体流量趋势、转化率、ROI,用于活动复盘和下期预算分配。配置视图:管理活动、渠道、埋点事件、看板权限。
运营视图里的看板通过接口轮询刷新,短流量活动期间一般 30 秒拉一次,不需要上 WebSocket。ECharts 组件的折线图、柱状图、漏斗图、地图在 Vue 里封装成通用组件后,新看板只需要传配置项就能生成,不用每个页面重复写图表初始化代码。这块的组件逻辑放在第六章。
3.4 权限与系统管理
后台系统多数场景需要按角色控制菜单和数据范围。菜单权限用 Vue Router 的动态路由实现,后端把当前用户能访问的菜单和按钮编码返回,前端注册路由。数据权限控制在 SQL 层,比如活动负责人对应的账号只能查自己的活动数据,在 MyBatis 动态 SQL 里拼一个owner_id条件就行。
权限这块不用整太复杂,短流量分析系统不像 ERP,用户角色就三类:超级管理员、运营、只读访客。把这三类角色分清楚,菜单、按钮、数据范围三层权限做好,基本就够用了。
4. 数据库建模:短流量事实表的写入与查询平衡术
4.1 核心表结构设计
短流量系统的数据模型可以简化成一张事实表和若干张维度表。事实表叫flow_event,维度表有dim_activity、dim_channel、dim_project。事务表不存冗余的大字段,所有可枚举的维度全部存 ID。
以下是flow_event表的核心结构:
CREATE TABLE `flow_event` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `event_id` VARCHAR(64) NOT NULL COMMENT '事件唯一ID,含时间戳', `project_key` VARCHAR(32) NOT NULL COMMENT '项目标识', `activity_id` BIGINT NOT NULL COMMENT '活动ID', `channel_id` INT NOT NULL COMMENT '渠道ID', `event_type` VARCHAR(64) NOT NULL COMMENT '事件类型', `user_id` VARCHAR(64) DEFAULT NULL, `device_id` VARCHAR(64) DEFAULT NULL, `ip` VARCHAR(64) DEFAULT NULL, `page_url` VARCHAR(512) DEFAULT NULL, `referer_url` VARCHAR(512) DEFAULT NULL, `extra` JSON DEFAULT NULL COMMENT '扩展字段', `event_time` DATETIME NOT NULL COMMENT '事件时间', PRIMARY KEY (`id`, `event_time`), KEY `idx_activity_time` (`activity_id`, `event_time`), KEY `idx_type_time` (`event_type`, `event_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 PARTITION BY RANGE (TO_DAYS(`event_time`)) ( PARTITION p20250526 VALUES LESS THAN (TO_DAYS('2025-05-27')), PARTITION p20250527 VALUES LESS THAN (TO_DAYS('2025-05-28')), PARTITION p20250528 VALUES LESS THAN (TO_DAYS('2025-05-29')), PARTITION p20250529 VALUES LESS THAN (TO_DAYS('2025-05-30')), PARTITION p_max VALUES LESS THAN MAXVALUE );这里有几个关键点要解释。
第一,PRIMARY KEY (id, event_time)是因为 MySQL 分区表要求任何唯一索引都必须包含分区键,所以主键要把event_time带进去。
第二,event_id我没有建唯一索引,因为分区表唯一索引包含 event_time 后,实际的去重逻辑会变得复杂。我更推荐在接入层用 Redis 短期去重,比如以event_id为 key 的 SET 只保留 24 小时,能覆盖绝大多数重复上报场景,省下唯一索引的写入开销。如果你一定要数据库层去重,就把event_id和event_time建联合唯一索引,但要接受更大的写入消耗。
第三,extraJSON 字段用来存非核心但有价值的扩展信息,比如商品 ID、订单金额、优惠信息。不要因为一个需求就加一个字段,JSON 更适合多变的业务属性,需要查询再抽取成单独列。
4.2 分区和索引怎么配合
短流量写入是持续高并发的,查询却集中在最近两三天,这个特点非常适合按天分区。查询时 MySQL 会自动做分区裁剪,只扫描活动当天的分区,而不是整张表。
索引方面不要贪多,写入为主的表索引越多越慢。我实际保留的索引只有两个:(activity_id, event_time)和(event_type, event_time)。前者支撑看板按活动维度查询趋势,后者支撑按事件类型做漏斗分析。
很多团队会把(channel_id)也建上索引,但在活动场景里,单一渠道的查询频率很低,更多是渠道对比,而对比查询又总是带上活动 ID,所以(activity_id, channel_id, event_time)才是更合适的联合索引。索引设计一定要回到真实查询场景,不要条件反射式地给每个字段都加索引。
4.3 批量写入和聚合预处理
短流量事件落库最忌讳 for 循环里单条 insert。我用的 JDBC 批量提交,配合 URL 参数rewriteBatchedStatements=true,在 MySQL 8.0 下批量插入吞吐能提升好几倍。批量大小建议控制在 500 到 1000 条一条事务,太大反而容易锁竞争。
原始事件表不会直接支撑看板查询。我的做法是定时跑一个小时级聚合任务,把原始事件按activity_id + channel_id + 小时聚合到flow_event_hourly表:
INSERT INTO flow_event_hourly (activity_id, channel_id, stat_hour, pv, uv, ip) SELECT activity_id, channel_id, DATE_FORMAT(event_time, '%Y-%m-%d %H:00:00') AS stat_hour, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv, COUNT(DISTINCT ip) AS ip FROM flow_event WHERE event_time >= #{startTime} AND event_time < #{endTime} GROUP BY activity_id, channel_id, stat_hour;这个聚合表才是看板查询的主力,查询速度几百毫秒就能返回。原始表只负责保存明细,超过 7 天的活动明细按分区直接 drop。短流量数据就是这样,时间越久价值越低,没必要无限堆磁盘。
5. SpringBoot+MyBatis后端实现:动态SQL与批处理的关键代码
5.1 项目结构
后端的包结构我习惯按业务模块拆,而不是按技术分层拆。这样写的好处是改一个功能不用同时改四五个包,代码聚合性更好。
abo-admin ├── src/main/java/com/abo │ ├── config # 数据源、缓存、异步线程池配置 │ ├── controller # 接入和数据查询接口 │ ├── service # 业务逻辑 │ ├── mapper # MyBatis Mapper接口 │ ├── model # 实体和DTO │ ├── job # 聚合任务 │ └── common # 通用返回值、异常、工具类 └── src/main/resources ├── mapper # MyBatis XML文件 ├── application.yml └── mybatis-config.xmlmybatis-config.xml虽然可以直接写在application.yml里,但我单独拆出来,目的是让项目成员都清楚 MyBatis 的初始化入口在哪。XMLConfigBuilder启动时解析这个文件,加载 settings、类型别名、mapper 注册。你要是想自定义 MyBatis 行为,比如加拦截器、改默认执行器类型,都是在这个配置上做文章。
5.2 application.yml 配置
spring: datasource: url: jdbc:mysql://localhost:3306/abo_flow?useSSL=false&rewriteBatchedStatements=true&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8mb4 username: abo password: Abo@2025 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.abo.model configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有几个配置是和踩坑经验直接相关的。
第一,rewriteBatchedStatements=true不能少,没有它批量更新性能会退化到和单条差不多。第二,useSSL=false是针对内网测试环境;如果你在公网访问数据库,反而要开启 SSL,不要照抄。第三,log-impl用StdOutImpl是为了开发阶段直接打印 SQL,但生产环境要关掉或者换成日志文件输出,否则每条 SQL 都打控制台,性能损耗明显。
5.3 核心 Mapper:多维趋势查询
短流量看板最常见的查询是按小时输出趋势。这个查询用动态 SQL 做条件组合,groupId 和 channelId 都可以为空:
<select id="countTrend" resultType="map"> SELECT DATE_FORMAT(event_time, '%Y-%m-%d %H:00:00') AS time_bucket, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv, COUNT(DISTINCT ip) AS ip FROM flow_event_hourly <where> <if test="activityId != null"> AND activity_id = #{activityId} </if> <if test="channelId != null"> AND channel_id = #{channelId} </if> <if test="startTime != null"> AND stat_hour >= #{startTime} </if> <if test="endTime != null"> AND stat_hour < #{endTime} </if> </where> GROUP BY time_bucket ORDER BY time_bucket </select>注意 XML 里的小于号必须写成<,直接写<会把 XML 解析器搞挂,这是 MyBatis 新手最常见的报错。
5.4 批量事件接入
接入层最强的需求就是“撑住瞬时洪峰”。我用两个手段解决:异步线程池 + MyBatis 批量 SQL。Controller 收到一批事件后,先丢到BlockingQueue,由线程池异步落库,接口立即返回成功,这样接入接口本身不会被数据库拖慢。
落库用的批量模式:
public void batchInsert(List<FlowEvent> events) { if (CollectionUtils.isEmpty(events)) { return; } SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false); try { FlowEventMapper mapper = sqlSession.getMapper(FlowEventMapper.class); for (FlowEvent event : events) { mapper.insert(event); } sqlSession.commit(); } finally { sqlSession.close(); } }注意,直接用@Transactional和for循环插入,和上面这种ExecutorType.BATCH方式效果是有差异的。前者每一条都会走一次 SQL 解析和网络往返,后者的 JDBC 驱动会把多条 insert 攒成一个批次发送,性能差距在事件量大时是数量级的。
5.5 MyBatis 缓存使用和避坑
MyBatis 二级缓存在短流量分析系统里是典型的“看似好用、实则隐患”。二级缓存的作用范围是 namespace,也就是一个 Mapper XML 内所有语句共享缓存。如果flow_event的 Mapper 开了<cache/>,那么第一次查询出的 PV 数字会被缓存,后面再查也拿不到最新值,看板数字就停在那一刻了。
我的原则很简单:原始事件表和聚合统计表全部不开二级缓存,只有配置类表如dim_activity、dim_channel可以开。配置表数据量小、更新频率低,开缓存收益明显;统计查询永远查数据库,保证数据新鲜度。至于一级缓存,Spring 管理的 SqlSession 默认每次请求创建新会话,问题不大,但要注意如果同一个 Service 事务里先插入再查询,一级缓存可能让你读不到刚提交的其他会话数据,必要时在查询 statement 上加flushCache="true"。
6. Vue前端与可视化看板:从路由权限到ECharts封装
6.1 工程初始化和环境配置
前端我用的技术栈是 Vue 3 + Vite + Pinia + Vue Router + ECharts。Node 版本建议直接用 18 LTS,Npm 源用国内镜像,安装依赖基本不会出幺蛾子。
Vite 开发环境需要配代理,否则前端请求/api会直接打到 Vite 自己的端口,进不了 SpringBoot 服务。vite.config.js里这样配:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })6.2 动态路由与菜单权限
后台菜单是根据用户身份动态生成的。后端登录接口返回用户角色和菜单列表,前端在路由守卫里把菜单转成路由注册:
const menuList = await getMenus() const routes = menuList.map(menu => ({ path: menu.path, component: () => import(`../views/${menu.component}.vue`), meta: { title: menu.title, icon: menu.icon } })) router.addRoute(routes)需要生产环境稳定的话,动态 import 不能把所有组件目录都暴露出来。我一般会把组件路径先映射成一个白名单对象,再执行import,避免菜单返回值里被塞进非法路径。
6.3 ECharts 可视化封装
看板页面大量用到图表,不能每个页面都从初始化写一遍。我封装了一个通用ChartBox组件,只做一件事:接收option对象,负责图表初始化、更新、尺寸变化和销毁。
<template> <div ref="chartRef" :style="{ height: height + 'px' }"></div> </template> <script setup> import * as echarts from 'echarts' import { ref, onMounted, onBeforeUnmount, watch } from 'vue' const props = defineProps({ option: { type: Object, required: true }, height: { type: Number, default: 360 } }) const chartRef = ref(null) let chart = null function render() { if (!chart) return chart.setOption(props.option, true) } function resize() { chart && chart.resize() } onMounted(() => { chart = echarts.init(chartRef.value) render() window.addEventListener('resize', resize) }) watch(() => props.option, render, { deep: true }) onBeforeUnmount(() => { window.removeEventListener('resize', resize) chart && chart.dispose() }) </script>这个组件看上去简单,但解决了一个很隐性的问题:页面在keep-alive或者 Tab 切来切去时,图表宽度会计算错误。统一监听resize并在onBeforeUnmount里销毁实例,就能避免内存泄漏和图表错乱。调用方只需要根据接口数据组装option,比如把 PV 和 UV 放进折线图的 series,剩下的交给组件处理。
6.4 看板数据刷新
短流量看板需要准实时刷新。我的实现方式是轮询,30 秒一次,统一封装在useDashboardPolling组合式函数里。页面卸载时一定要清理定时器,否则切了十几个页面,后台线程还在猛刷接口。
另外一个细节是不要让每个图表组件都单独拉数据。我会在父页面一次性拉回所有看板需要的指标,拆分成不同的option传给各个图表子组件,这样既减少请求数,也让数据更新保持一致。活动期间大屏展示时,再额外做时间取整对齐,避免多个图表因为请求延迟出现时间轴错位。
7. 部署联调与高发期排障:这些坑我劝你提前踩
7.1 本地跨域:优先用代理,别开 CORS 走天下
开发阶段最常见的坑是跨域。很多人的第一反应是给 SpringBoot 加@CrossOrigin或者配置 CORS Filter,但这样生产环境如果不注意,等于把接口开放给所有域名调用。我的做法是开发环境用 Vite 的 server.proxy,生产环境用 Nginx 反向代理同源转发,项目代码里基本不出现 CORS 配置。这样部署时域名怎么变都不用改后端代码。
7.2 Vue 打包放进 SpringBoot 的细节
如果只部署单台服务器,可以把前端打包产物丢到 SpringBoot 的static目录下。操作很简单:
npm run build cp -r dist/* ../abo-admin/src/main/resources/static/ mvn clean package但要注意 Vue Router 如果用的是 history 模式,直接访问/dashboard会 404,因为 SpringBoot 找不到对应的静态文件。解决办法是让所有非静态资源路径都转发到index.html:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("forward:/index.html"); registry.addViewController("/{path:[^\\.]*}").setViewName("forward:/index.html"); } }这段配置的意思是:不含.的路径统一走前端路由,带.js、.css、图片后缀的请求正常走静态资源。如果你遇到Whitelabel Error Page,大概率就是少了这一步。
7.3 MySQL 连接 SSL 和时区报错
这个坑在连接 MySQL 8 时基本都会遇到一次。报错大概是:
java.sql.SQLException: Access denied for user ... Public Key Retrieval is not allowed或者:
Unable to load authentication plugin 'caching_sha2_password'原因是 MySQL 8 默认的认证插件是caching_sha2_password,如果客户端连的是非 SSL 连接,驱动需要服务器允许公钥检索。所以在 JDBC URL 里要显式加两个参数:
useSSL=false&allowPublicKeyRetrieval=true再有不带时区会报错,要加serverTimezone=Asia/Shanghai。这三个参数我建议直接固定成一串标准 URL,避免每个开发环境的连接串都不一样。
7.4 生产环境用 systemd 管理 SpringBoot 进程
生产环境我不推荐 nohup java -jar 一把梭,日志收不到、进程管不住、重启麻烦。写一个 service 文件最省心:
[Unit] Description=ABO Admin Service After=network.target [Service] User=app ExecStart=/usr/bin/java -Xms1g -Xmx2g -jar /opt/abo/abo-admin.jar --spring.profiles.active=prod Restart=always RestartSec=10 [Install] WantedBy=multi-user.target然后systemctl daemon-reload && systemctl enable --now abo-admin就可以了。Nginx 反向代理把/api转到 8080 端口,前端页面直接由 Nginx 托管,动静分离。这套部署结构简单稳定,后续想加实例只要在 Nginx upstream 里多写一行。
8. 后续扩展:当这套系统的MySQL底座到达天花板
8.1 实时计算层的演进方向
MySQL 底座在千万级日事件量下足够稳,但继续增长会碰到两个瓶颈:一是明细写入和查询争抢资源,二是分钟级聚合在超高并发下不太够用。这时候可以沿着现有接口层平滑扩展,把接入服务里的消息直接打到 Kafka,由 Flink 消费做实时聚合,结果写入 ClickHouse 或者 Redis。SpringBoot 整合 Flink 并不复杂,关键是事件格式和指标体系要保持不变,这样上层看板几乎零改动。
我的建议是:不要一开始就上流计算,但要在接入层把事件模型定义好。只要event_id、activity_id、channel_id、event_type、event_time这些字段从一开始就是规范化的,后面接 Flink 只是加一个 Consumer 的事。
8.2 文本分析扩展看板维度
运营还会问一个高频问题:用户到底在页面上聊什么、关注什么。针对短流量场景,可以对接入的页面标题、评论、搜索词做分词和词频统计。比如用 HanLP 把page_url对应的页面标题分词后,生成词云,配合流量趋势就能看出来什么内容最吸引人。这个功能不需要复杂算法,一个分词接口加上一个词频表就能跑起来,但对运营尤其投放人员很有价值。
8.3 告警和自动化
短流量活动最怕的是流量突然掉零或者接口超时。做一套简单的告警规则,指标环比下降超过 30% 就往钉钉或企业微信推送一条消息,比任何复杂的监控平台都实用。我从这套项目里学到的经验是:告警不要做太多,核心三四个指标就够了,告警规则多了全是噪音,早晚被运营关掉。
最后再分享一个实际操作中的小习惯:每次大促之前,我会把 MySQL 慢查询日志打开,同时给看板接口做一次压测,把超过 500ms 的接口单独列出来优化。不要等运营截图说看板卡了,才去看数据库慢在哪。短流量系统能不能在关键时刻顶上,靠的不是运气,是把这些不起眼的细节提前磨到位。