简介:基于Java的手机APP信息统计分析系统设计源码,面向移动端开发者与大数据分析学习者,用于完成APP日志数据的采集、清洗、存储与可视化展示,支持用户行为分析,帮助优化产品体验。压缩包内按客户端采集、收集Web端、Flume通道、公共模块、Hive离线仓库、可视化Web等子模块划分,覆盖从客户端埋点到日志传输、离线分析再到前端展示的完整处理链路。资源共包含五十七个文件,其中二十四个Java类文件承载核心逻辑,十四个XML文件负责配置管理,还有JSP页面、属性配置文件、GeoLite地理库及说明文档,整体压缩包约56.75MB,目录结构清晰,便于按模块逐一研读。目前已有三百三十七人学习浏览,读者可据此快速搭建手机APP统计系统原型,理解各模块协作方式并开展二次开发,尤其适合需要掌握大数据处理全流程的开发者。
1. 手机 APP 行为数据后端:Java 统计分析系统到底在解决什么问题
这套基于 Java 的手机 APP 信息统计分析系统设计源码,拆开看就是一个再普通不过的 Spring Boot 后端:APP 端把启动、注册、点击这些事件上报到服务端,服务端落库、跑批、出报表。它对应的是很多高校课设里那个“企业客户信息数据统计分析系统的设计与实现”题目,只是把数据源从企业客户换成了 APP 用户行为,难度和完整度都在同一水平线上。
真正让这类系统在课程设计和面试里值钱的地方,不是报表 SQL 写得多花哨,而是统计口径怎么定义、重复数据怎么去重、凌晨跑批为什么不能漏。我见过太多人把接口写完就以为结束了,结果一跑真实数据,日活翻倍、留存率超过 100%,全是踩在时间处理和幂等这两个坑上。
下面这套方案按 Spring Boot + MyBatis + MySQL 的常规结构走,从埋点协议讲到表结构、跑批 SQL 和排查手段,适合三类人:正在做 Java 课程设计案例源码的学生、想给自有 APP 搭一套轻量统计后台的移动端开发、以及准备把“批量任务 + 数据统计”写进简历的初级 Java 工程师。
2. 数据链路与存储选型:先定上报格式,再决定用几张表
2.1 埋点协议:一次上报事件该带什么字段
做信息统计分析系统,第一个要定的是客户端上报什么数据,而不是急着建表。APP 端埋点字段定好了,后面所有统计逻辑都围绕它展开。我一般会让客户端每次上报这样一个 JSON:
{ "eventId": "uuid-20250101-abc123", "deviceId": "a1b2c3d4e5", "userId": 10086, "sessionId": "sess-20250101-001", "eventName": "launch", "eventTime": "2025-01-01 10:00:00", "appVersion": "v1.0.0", "channel": "huawei", "properties": { "page": "/home" } }先解释这些字段为什么一个都不能少。eventId是客户端生成的全局唯一 ID,用来做幂等去重,后面对应数据库唯一索引,是防重复上报的关键。deviceId是设备唯一标识,用户没登录时也能独立统计新增设备和启动次数,很多匿名行为统计靠它。userId在登录后才有值,用于留存、活跃用户这类跟人走的指标。sessionId标识一次启动会话,启动次数、会话时长、页面路径都要靠它归并。
eventTime这里我刻意要求客户端传字符串格式的本地时间,不传时间戳,因为客户端时区经常被用户改乱,时间戳到服务端换算日期会产生偏移。服务端收到后统一按Asia/Shanghai转成LocalDateTime,再从里面拆出event_date作为统计天。properties是预留的 JSON 字段,存页面名称、按钮 ID、支付金额这些业务参数,后续做页面分析、漏斗分析不用改表结构。
channel字段最容易被人忽略,它代表 APP 分发渠道,华为、小米、应用宝、地推二维码各算一个渠道。没有这个维度,运营想对比渠道投放效果时做不了聚合,后面第 4 章的日报表就少了一个核心筛选条件。
2.2 存储选型:明细表与汇总表为什么分家
存储层面的选择直接影响项目能不能落地。我见过有人一上来就引入 ClickHouse、Elasticsearch,想法很前瞻,但对一个课程设计级别的系统和单机部署环境来说,运维成本完全失控。数据量在单日几百万条以内、总数据量几千万这个量级,MySQL 完全扛得住,这也是最常见、最可靠的选择,源码跑起来不需要额外组件。
这个系统里我用两张核心表:event_log存原始事件明细,daily_stats存按日和渠道、版本维度聚合后的结果。两张表分家的原因很直接:报表接口每天要被后端管理和运营后台查很多次,每次都去扫千万行的event_log做COUNT(DISTINCT device_id),再好的索引也扛不住频繁聚合。汇总表把结果提前算好冗余存储,报表查询退化成一次简单范围查询,速度能差两个数量级。
关于实时性,这里补充一点我的取舍。很多人纠结要不要用 Kafka 或者 Redis 做实时统计,让数字秒级跳动。对信息统计分析系统来说,日级报表的实时性没那么重要,凌晨集中跑批是成熟且简单的方案。跑批过程在凌晨低峰期执行,对在线写入接口影响最小,代码逻辑也好验证和回溯。真要实时看板,可以在汇总表基础上做增量合并,那是后话,不影响初版落地。
2.3 代码分层与请求链路:Controller 不写 SQL
编码结构上,我遵循 Java 后端最常规的三层划分,也顺便对应了面向对象编程 Java 里强调的职责分离。Controller 只做参数接收和响应封装,Service 处理业务逻辑和事务边界,Mapper 层管 SQL 和数据库交互。包结构大概是这样:
com.example.appstat ├── controller │ └── EventCollectController.java ├── service │ └── EventCollectService.java ├── mapper │ ├── EventLogMapper.java │ └── StatMapper.java ├── entity │ ├── EventLog.java │ └── DailyStats.java ├── job │ └── StatJob.java └── common └── Result.java这种分层的好处是请求链路清晰:APP 端 POST 到/v1/events/batch,Controller 校验基础参数后交给 Service,Service 做数据格式转换和幂等过滤,再批量插入明细表。统计报表的查询接口走另一条链路,Controller 接收筛选条件,Service 直接查询汇总表返回分页结果。两层链路互不干扰,这是后期排错和扩展的基础。
这里不要为了省代码把 SQL 写在 Controller 或 Service 里。MyBatis 的 Mapper XML 单独管理 SQL,修改统计口径时只改 XML,不动 Java 代码,这个习惯在项目维护期能省非常多的时间。
3. 搭建后台工程:Spring Boot、建表 DDL 与事件入库
3.1 环境与骨架:JAVA_HOME、Maven 与目录结构
动手前把 Java 环境确认一遍,这是很多源码跑不起来的第一道坎。JDK 8 和 JDK 17 都能用,但要注意 Spring Boot 版本对应关系:Spring Boot 2.7.x 系列基于javax.*包名,Spring Boot 3.x 切到了jakarta.*,代码里import javax.servlet还是import jakarta.servlet差别很大。我现在的习惯是统一 JDK 17 + Spring Boot 3.x,但如果你的机器上只有 JDK 8,就老老实实配 Spring Boot 2.7,先把 Java 环境变量配置好,java -version和mvn -version都能正常输出再继续。
新建工程后,主要依赖就四个:spring-boot-starter-web提供 HTTP 接口能力,mybatis-spring-boot-starter负责数据库访问,mysql-connector-j是 MySQL 驱动,再带上 Lombok 简化实体代码。数据库连接配置写在application.yml里,端口和连接参数建议用环境变量覆盖,避免源码传出去后被硬编码连接信息卡住:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/app_stat?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: appstat password: ${DB_PASSWORD:appstat123} jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true stat: run-time: "0 15 1 * * ?"${DB_PASSWORD:appstat123}的写法是先从环境变量取密码,取不到再用默认值appstat123,这样源码可以公开,真实密码留在部署环境里。数据库 URL 里的serverTimezone=Asia/Shanghai是个必须项,MySQL 驱动连接时会按这个时区解析DATETIME,漏掉它容易出现时间偏移。map-underscore-to-camel-case开启后,event_id能自动映射到 Java 实体里的eventId字段,省去一堆@Column注解。
3.2 建表 DDL:事件表、汇总表与关键索引
建表是整个系统最不能省心的一步。event_log表的核心是唯一索引和查询索引,少建一个索引,后面跑批 SQL 和报表查询都会成倍放大数据库压力:
CREATE TABLE event_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, event_id VARCHAR(64) NOT NULL COMMENT '客户端生成的全局唯一事件ID', device_id VARCHAR(64) NOT NULL COMMENT '设备标识,匿名用户也用这个统计', user_id BIGINT DEFAULT NULL COMMENT '登录用户ID,未登录为NULL', session_id VARCHAR(64) NOT NULL COMMENT '一次启动会话ID', event_name VARCHAR(32) NOT NULL COMMENT '事件名:launch/register/click/pay', event_date DATE NOT NULL COMMENT '事件发生的自然日,服务端按东八区计算', event_time DATETIME NOT NULL COMMENT '事件发生时间', app_version VARCHAR(16) NOT NULL COMMENT 'APP版本号', channel VARCHAR(32) NOT NULL COMMENT '渠道标识', properties JSON DEFAULT NULL COMMENT '附加业务参数', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_event_id (event_id), KEY idx_event_date_device (event_date, device_id), KEY idx_event_date_user (event_date, user_id), KEY idx_event_date (event_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;event_id的唯一索引对应埋点幂等,这是防重复上报的兜底手段。idx_event_date_device和idx_event_date_user是跑批 SQL 的两个核心索引,日活统计按event_date + device_id去重,留存统计按event_date + user_id关联,没有这两个联合索引,所有聚合都会退化成全表扫描。
properties用 JSON 类型存储,是为了应对埋点参数不固定,比如点击事件要带商品 ID,支付事件要带金额,用固定字段会逼着每次改表。MySQL 5.7 以上版本原生支持 JSON 类型,可以直接在 JSON 里做条件查询,够用。
日汇总表daily_stats的设计更讲究,它要承担所有报表查询压力:
CREATE TABLE daily_stats ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, stat_date DATE NOT NULL COMMENT '统计日期', channel VARCHAR(32) NOT NULL DEFAULT 'all' COMMENT '渠道,all表示全渠道', app_version VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '版本,all表示全版本', new_users INT NOT NULL DEFAULT 0 COMMENT '新增用户数', active_users INT NOT NULL DEFAULT 0 COMMENT '活跃用户数', launch_count INT NOT NULL DEFAULT 0 COMMENT '启动次数', next_day_retention_rate DECIMAL(6,4) DEFAULT NULL COMMENT '次日留存率', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_date_channel_ver (stat_date, channel, app_version) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;stat_date + channel + app_version唯一索引防止同一组合重复写入,配合INSERT ... ON DUPLICATE KEY UPDATE实现跑批的幂等更新。汇总表同时存活跃用户数、新增用户数、启动次数这些相互独立的指标,少建几张窄表换来的是报表接口一次查询全部返回,省去了多次 JOIN。
3.3 上报接口与批量入库:能直接编译的 Java 代码
事件上报接口要解决的核心问题是高并发下的写入压力和数据可靠性。APP 端的埋点上报频率比普通业务请求高得多,如果一条事件一次 INSERT,数据库连接会被瞬间打满。我的做法是客户端批量上报,服务端批量入库,单批次控制在 500 条以内。
Controller 层只做参数接收和结果包装:
@RestController @RequestMapping("/v1/events") public class EventCollectController { private final EventCollectService eventCollectService; public EventCollectController(EventCollectService eventCollectService) { this.eventCollectService = eventCollectService; } @PostMapping("/batch") public Map<String, Object> batch(@RequestBody List<EventReport> events) { if (events == null || events.isEmpty()) { return Map.of("code", 400, "msg", "events is empty"); } eventCollectService.saveBatch(events); return Map.of("code", 0, "msg", "ok"); } }Service 层是核心,做三件事:过滤掉缺少eventId的脏数据、把字符串时间转成LocalDateTime并拆出event_date、分批执行插入:
@Service public class EventCollectService { private static final int BATCH_SIZE = 500; private final EventLogMapper eventLogMapper; public EventCollectService(EventLogMapper eventLogMapper) { this.eventLogMapper = eventLogMapper; } @Transactional(rollbackFor = Exception.class) public void saveBatch(List<EventReport> events) { List<EventLog> logs = events.stream() .filter(e -> e.getEventId() != null && !e.getEventId().isBlank()) .map(this::convert) .toList(); for (int i = 0; i < logs.size(); i += BATCH_SIZE) { int end = Math.min(i + BATCH_SIZE, logs.size()); eventLogMapper.batchInsert(logs.subList(i, end)); } } private EventLog convert(EventReport e) { EventLog log = new EventLog(); log.setEventId(e.getEventId()); log.setDeviceId(e.getDeviceId()); log.setUserId(e.getUserId()); log.setSessionId(e.getSessionId()); log.setEventName(e.getEventName()); LocalDateTime time = LocalDateTime.parse(e.getEventTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); log.setEventTime(time); log.setEventDate(time.toLocalDate()); log.setAppVersion(e.getAppVersion()); log.setChannel(e.getChannel()); return log; } }@Transactional保证一批请求要么全部入库要么全部回滚,避免报表统计到一半数据。批次控制在 500 条的理由是,单事务执行时间过长会占用连接和行锁,影响同一时间进来的正常业务请求。event_date在转换时用time.toLocalDate()直接取日期,这里没有做时区转换,因为前置约定客户端传的是东八区时间,如果客户端实现不守规矩,就在这一层补ZoneId.of("Asia/Shanghai")转换。
Mapper XML 里的批量插入用foreach拼接,注意 MySQL 的max_allowed_packet默认值通常在 64MB,500 条事件完全在安全范围内:
<insert id="batchInsert" parameterType="list"> INSERT INTO event_log (event_id, device_id, user_id, session_id, event_name, event_date, event_time, app_version, channel, properties) VALUES <foreach collection="list" item="e" separator=","> (#{e.eventId}, #{e.deviceId}, #{e.userId}, #{e.sessionId}, #{e.eventName}, #{e.eventDate}, #{e.eventTime}, #{e.appVersion}, #{e.channel}, #{e.properties}) </foreach> </insert>4. 统计口径与跑批 SQL:日活、新增、留存和渠道报表一次写清
4.1 定时任务与统计边界:为什么凌晨跑批不用实时
统计系统最忌讳的是口径混乱:日活到底是按设备去重还是按用户去重?新增用户是注册当天算还是首次启动当天算?留存率的基准是活跃用户还是新增用户?这些不写死,报表前后对不上,运营和技术互相扯皮。
我采用的约定是:按自然日统计,event_date作为唯一时间维度;活跃用户按device_id去重,称为设备活跃;留存率只统计有user_id的登录用户,避免设备重置导致留存被低估。跑批时间通过@Scheduled配置,每天凌晨低峰期执行:
@Component public class StatJob { private final StatService statService; public StatJob(StatService statService) { this.statService = statService; } @Scheduled(cron = "${stat.run-time:0 15 1 * * ?}") public void runDailyStat() { // 默认统计昨天,参数化后也支持手动补数 LocalDate statDate = LocalDate.now().minusDays(1); statService.aggregate(statDate); } }cron 表达式0 15 1 * * ?表示每天凌晨 1 点 15 分执行。选 1 点 15 分而不是 0 点整,是因为 0 点前后是用户活跃尾巴期,还有大量跨天事件在途,跑早了数据会漏。statDate用LocalDate.now().minusDays(1)固定取昨天,保证跑批永远在完整的数据集上操作。
4.2 日活与新增用户:合并写入日汇总表的 SQL
日活统计的核心是按device_id去重。把活跃数和启动次数合进一条 SQL,避免两次扫描大表:
INSERT INTO daily_stats (stat_date, channel, app_version, active_users, launch_count) SELECT event_date, COALESCE(channel, 'all'), COALESCE(app_version, 'all'), COUNT(DISTINCT device_id), SUM(CASE WHEN event_name = 'launch' THEN 1 ELSE 0 END) FROM event_log WHERE event_date = #{statDate} GROUP BY event_date, channel, app_version ON DUPLICATE KEY UPDATE active_users = VALUES(active_users), launch_count = VALUES(launch_count), updated_at = CURRENT_TIMESTAMP;COUNT(DISTINCT device_id)对一个用户在不同设备上登录的场景会偏大,但对于设备维度的日活口径这是标准算法。launch_count统计的是launch事件总数,一个用户一天启动三次就记三次。COALESCE把 NULL 的渠道和版本归到all,这样报表查询按渠道筛的时候,接入诡异渠道遗漏的杂数据不会凭空消失。
新增用户的口径我按“第一次出现在系统里”计算,也就是当天之前这个设备从未出现过:
INSERT INTO daily_stats (stat_date, channel, app_version, new_users) SELECT e.event_date, COALESCE(e.channel, 'all'), COALESCE(e.app_version, 'all'), COUNT(DISTINCT e.device_id) FROM event_log e WHERE e.event_date = #{statDate} AND NOT EXISTS ( SELECT 1 FROM event_log old WHERE old.device_id = e.device_id AND old.event_date < #{statDate} ) GROUP BY e.event_date, e.channel, e.app_version ON DUPLICATE KEY UPDATE new_users = VALUES(new_users), updated_at = CURRENT_TIMESTAMP;NOT EXISTS子查询判断“这个设备在更早的日期有没有出现”,没有就计入当天新增。这个写法比MIN(event_date)分组再 JOIN 的方案更直白,配合idx_event_date_device索引,当天数据量和历史数据量差别不大时性能可观。注意新增用户的跑批依赖全量历史数据,所以它的执行顺序必须在日活写入之后。
4.3 次日留存率:JOIN 写法与分母边界
留存率是统计系统里最容易算错的一个指标。它要回答的问题是:某天活跃的用户里,有多少人在第二天又来了。我用两段子查询分别取当天活跃用户和次日活跃用户,再按user_id对齐:
SELECT d0.event_date AS stat_date, d0.channel, d0.app_version, COUNT(DISTINCT d0.user_id) AS active_users, COUNT(DISTINCT d1.user_id) AS retention_users FROM ( SELECT DISTINCT event_date, channel, app_version, user_id FROM event_log WHERE event_date = #{statDate} AND user_id IS NOT NULL ) d0 LEFT JOIN ( SELECT DISTINCT event_date, channel, app_version, user_id FROM event_log WHERE event_date = #{nextDate} AND user_id IS NOT NULL ) d1 ON d0.user_id = d1.user_id AND d0.channel = d1.channel AND d0.app_version = d1.app_version GROUP BY d0.event_date, d0.channel, d0.app_version;COUNT(DISTINCT d0.user_id)是分母,某天登录过的活跃用户数;COUNT(DISTINCT d1.user_id)是分子,这批人里第二天又出现的数量。分母只统计有user_id的事件,匿名设备不算,避免一台手机两个账号登录导致留存虚高。这里有个边界坑我会提醒你:JOIN 条件里带上了渠道和版本,如果一个用户第二天换了个渠道的包打开 APP,就会被漏记。更精确的做法是只按user_idJOIN,然后按第一天的渠道分组,我写这段是为了代码结构清晰,你实际落地时建议把 JOIN 条件只保留d0.user_id = d1.user_id。
算出留存用户数后,更新汇总表的留存率字段时,记得防止除零:
UPDATE daily_stats SET next_day_retention_rate = retention_users / NULLIF(active_users, 0) WHERE stat_date = #{statDate};4.4 报表查询接口:按渠道、版本、日期组合筛选
报表接口是给后台页面消费的,走汇总表,条件就是时间和维度组合。我封装一个分页查询接口,让前端可以按渠道和版本交叉筛选:
@GetMapping("/report/daily") public PageResult<DailyStatsVO> dailyReport( @RequestParam LocalDate startDate, @RequestParam LocalDate endDate, @RequestParam(required = false) String channel, @RequestParam(required = false) String appVersion, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { return statService.queryDaily(startDate, endDate, channel, appVersion, page, size); }Service 层组装查询条件,Mapper XML 里用动态 SQL 拼接:
<select id="queryDaily" resultType="com.example.appstat.entity.DailyStats"> SELECT stat_date, channel, app_version, new_users, active_users, launch_count, next_day_retention_rate FROM daily_stats <where> <if test="startDate != null"> AND stat_date >= #{startDate} </if> <if test="endDate != null"> AND stat_date <= #{endDate} </if> <if test="channel != null and channel != ''"> AND channel = #{channel} </if> <if test="appVersion != null and appVersion != ''"> AND app_version = #{appVersion} </if> </where> ORDER BY stat_date DESC, channel, app_version LIMIT #{offset}, #{size} </select><where>标签会自动处理第一个条件前面的AND,避免拼接 SQL 时出现语法错误。LIMIT #{offset}, #{size}做物理分页,对于汇总表的数据量完全足够。到这里,从数据上报到报表查询的完整链路已经打通,项目可以跑出正确的数字了。
5. 避坑:时区、幂等、索引与 JDK 环境导致的翻车
5.1 统计“少一天”:客户端与服务端时区不一致
现象:日活报表比客户端本地计数少了一块,比如用户在晚上 11 点 50 分产生的行为,第二天报表里看不到,第三天出现。
原因:客户端上报的eventTime是字符串,服务端直接LocalDateTime.parse后取toLocalDate(),如果用户手机时区改成非东八区,事件时间会被认为晚一天或早一天入库。更隐蔽的是,有些客户端会传 UTC 时间戳,服务端没转时区就直接格式化,统计天完全错乱。
解决:在convert方法里强制指定时区,客户端传原始时间戳或带时区信息的字符串,服务端用Instant转东八区ZoneId.of("Asia/Shanghai")再拆event_date。同时约定客户端事件时间一律传服务器标准时间,不在客户端做展示性时间转换。这条规则要在埋点文档里写死,不然每次发版都会被不同工程师的理解带偏。
5.2 重复上报导致日活虚高:幂等键与唯一索引
现象:某天日活突然飙到平时 10 倍,排查发现 APP 在弱网下会重试上报,同一个事件被 POST 了 5 次。
原因:客户端对失败请求做了指数退避重试,但服务端接口不支持幂等,每条重试数据都被当成新事件插入,COUNT(DISTINCT device_id)当然被放大。
解决:两层防线。第一层是event_log表加UNIQUE KEY uk_event_id (event_id),重复插入会报Duplicate entry异常;第二层是 Service 里用INSERT IGNORE或者捕获唯一键冲突异常后直接忽略。注意INSERT IGNORE会吞掉其他字段错误,我的做法是单独捕获DuplicateKeyException,日志里记录冲突的eventId用于监控埋点重复率。重复率超过 5% 就是客户端逻辑有问题,要回头查上报时机。
5.3 跑批 SQL 慢到锁库:索引缺失的代价
现象:凌晨 1 点 15 分定时任务启动后,数据库 CPU 飙到 100%,上午的业务查询也跟着卡顿,接口超时告警刷屏。
原因:跑批 SQL 在千万行的event_log上做COUNT(DISTINCT)和NOT EXISTS,WHERE event_date = #{statDate}没走索引,MySQL 只能全表扫描。更糟的是NOT EXISTS子查询每扫一行都要再查一次历史表,复杂度成倍放大。
解决:建表时给event_date加单列索引,给event_date + device_id和event_date + user_id加联合索引,覆盖跑批用到的所有查询条件。跑批前先EXPLAIN SELECT ...,看到type是ALL就说明索引没生效,至少要达到range或ref级别。还要注意event_date的隐式类型转换,#{statDate}是LocalDate,如果event_date列定义成VARCHAR,索引会失效,所以列类型必须一致用DATE。
5.4 凌晨跑批挂了:如何补昨天的数据
现象:某天早上运营反馈日报表数字是空的,查看日志发现凌晨跑批在连接数据库阶段抛异常,任务失败退出,没有自动重试。
原因:定时任务默认只跑“昨天”,今天发现昨天没跑成功,任务已经错过,不会自己回头补。如果跑批逻辑里用的是SELECT MAX(stat_date) FROM daily_stats这种自动追数逻辑,还能自愈,但很多初版源码图省事写死日期,一旦失败就要人工介入。
解决:把跑批的统计日期从配置参数改成方法入参,提供一个手动补数的 HTTP 接口,比如POST /admin/stat/run?statDate=2025-01-01,校验statDate不能是未来日期后执行同一套aggregate逻辑。跑批任务本身要加失败重试,我用@Retryable注解或者自己写 catch 块记录重试日志,连续失败三次发告警。补数接口要加访问权限,不能裸奔在公网。
5.5 编译失败或启动闪退:JDK 版本与编码的坑
现象:源码导入 IDEA 后编译报错,一堆找不到符号 javax.servlet.*,或者运行起来页面中文乱码。
原因:大概率是 Spring Boot 版本和 JDK 版本不匹配。Spring Boot 3.x 用了jakarta.*包名,代码里还写着老版本的javax.servlet,自然编译不过。中文乱码多是 MySQL 连接串漏了characterEncoding=utf8,或者 Java 文件保存编码不是 UTF-8。
解决:确认 JDK 版本前先看 Spring Boot 版本,JDK 8 配 Spring Boot 2.7.x,JDK 17 配 Spring Boot 3.x,别混。IDE 里统一设置文件编码为 UTF-8,MySQL 连接 URL 带characterEncoding=utf8,建表语句DEFAULT CHARSET=utf8mb4,三处一致乱码才有救。头条问“java 环境变量配置详细教程”的很多问题,根因就是同时装了几个 JDK 导致JAVA_HOME指向混乱,mvn -version输出和java -version不一致,先对齐这个再谈跑源码。
6. 用造数脚本验证统计结果,并把源码吃透的进阶路径
6.1 一条命令造 200 条测试数据
跑批逻辑写完,最怕的就是拿真实数据一跑发现口径错了。我习惯先用脚本造一批固定日期的测试数据,把统计结果手工算一遍再和报表接口对比。比如造 50 个设备、每个人启动两次、模拟昨天的数据,直接循环调用上报接口:
for i in $(seq 1 200); do curl -s -X POST http://localhost:8080/v1/events/batch \ -H "Content-Type: application/json" \ -d "{\"eventId\":\"evt_$i_$(date +%s)\",\"deviceId\":\"dev_$((i % 50))\",\"userId\":$((i % 50 + 1)),\"sessionId\":\"sess_$i\",\"eventName\":\"launch\",\"eventTime\":\"2025-01-01 10:00:00\",\"appVersion\":\"v1.0.0\",\"channel\":\"huawei\"}" done执行后检查当天active_users,200 条事件里只有 50 个不同deviceId,正确结果应该是 50。如果接口返回 200 但库里没有数据,优先看 Service 的convert异常是否被吞掉,或者 Mapper XML 的foreach语法是否正确。造数脚本的日期固定写死,方便跑批后对照汇总表验证数字,别用$(date)动态时间,不然第二天跑批时统计天就错位了。
6.2 验证跑批结果与接口返回值
跑完造数后,手动触发一次统计任务:POST /admin/stat/run?statDate=2025-01-01,然后查询汇总表。预期new_users是 50,active_users是 50,launch_count是 200,next_day_retention_rate在隔壁没有造次日数据时为 0。这里顺手验证了留存率的空值处理,NULLIF(active_users, 0)没有把分母变成 NULL 时报错。报表接口返回的行数和汇总表一致,再检查一下渠道筛选,把造数脚本里的 channel 换成两个不同值,验证分组是否正确。
进阶一点的做法,是在StatService上补几个单元测试,用@SpringBootTest加载真实数据库,跑完聚合后断言daily_stats的各个字段。这一步做得好,回头面试聊“java 面试八股文”里的索引、事务、时间处理时,你都能从自己项目里捞出具体例子,而不是背理论。
6.3 从“课设源码”进阶到能写进简历的项目
如果你拿这套系统当 Java 课程设计案例源码交上去,能覆盖到 Spring Boot 分层、MyBatis 动态 SQL、定时任务、MySQL 索引和事务,已经比大部分同学只写个 CRUD 强很多。想再往深走,我会建议你做三个明确的小改造:第一,把日活异步化,上报接口接到数据后放内存队列,消费者批量落库,处理埋点高峰不丢数据;第二,给留存率增加 3 日和 7 日维度,底层逻辑不变,补两条 SQL 就能跑;第三,把daily_stats的查询接口接上缓存,热点报表从 Redis 读,减轻 MySQL 压力。
这三个方向对应的是真实企业里每天要处理的问题,比盲目引入微服务和新框架更能体现你对边界和性能的理解。记住,这套源码能带给你的最大价值是“口径清晰 + 数据可回溯”,面试时把这个项目的难点讲成:怎么用唯一索引保幂等、怎么用汇总表做读写分离、怎么处理时区对统计天的影响,一套下来比背十道 java 面试题都有说服力。
我做这类统计系统的习惯是:每次改统计口径,先在造数环境算一遍预期值,再放生产跑,对不上就先查时间再查幂等。这个习惯帮我挡掉了很多凌晨的告警电话。希望帮到你。
本文还有配套的精品资源,点击获取