简介:这是一套面向Java后端与大数据方向学习者的手机APP信息统计分析系统源码,围绕用户行为数据的采集、存储、分析与可视化展开,适合作为课程设计、毕业设计或大数据入门项目的参考实现。资源包共57个文件,约56.75MB,以24个Java类文件与14个XML配置为主,构成核心业务与工程配置;另有2个JSP页面、2个Properties配置、1个HTML与1个JS文件承担前端展示与参数管理,并附带MaxMind GeoIP数据库用于地域解析,整体按采集、公共模块、Hive处理、可视化等模块划分,目录结构清晰。目前已有338人学习下载。通过研读源码,读者可掌握Flume日志采集、Hive离线统计与Web可视化展示的完整链路,理解用户行为指标的计算方式与工程组织思路,并借助配套文档快速梳理项目架构,为二次开发或同类系统设计提供可复用的参考。
1. 从埋点到看板:一套 Java 手机 APP 信息统计分析系统到底在统计什么
很多团队做移动端数据统计,第一反应是接第三方 SDK,埋点、上报、看板全托管。但真到了要按业务口径自定义指标、要把原始事件落到自己的库里做二次计算、要跟后端订单数据做关联分析的时候,第三方方案就开始别扭了:字段不够、口径改不动、明细拿不到。这套「基于 Java 的手机 APP 信息统计分析系统」解决的正是这个问题——它把「采集 → 上报 → 落库 → 聚合 → 展示」整条链路握在自己手里,用 Java 技术栈实现服务端,客户端只负责按约定格式把事件发上来。
它适合两类人:一类是中小团队的后端或全栈工程师,想给自己家 APP 搭一套可控的统计后台;另一类是正在做课程设计或练手项目的开发者,需要一个覆盖 HTTP 接口、数据库设计、定时聚合、可视化接口的完整闭环。读完你能拿到的是:数据模型怎么定、上报接口怎么防脏数据、聚合任务怎么跑、看板接口怎么查得快,以及几个我踩过的坑。
2. 数据模型与上报链路:先把「事件」这件事定义清楚
统计系统的地基不是代码,是数据模型。模型定歪了,后面聚合逻辑怎么写都别扭。我一般会把整条链路拆成三层:原始事件层、会话/用户层、聚合指标层。三层各管各的,互不越界。
2.1 三层数据模型怎么划分
原始事件层存的是「谁在什么时间做了什么」,一条事件一行,不做任何预聚合。这是唯一的事实来源,后面所有指标都能从它重算出来,相当于后悔药。典型字段包括事件 ID、设备标识、用户标识(可空,未登录时为空)、事件类型、事件发生时间(客户端时间)、上报时间(服务端时间)、页面/模块、附加参数(JSON)。
会话层解决的是「一次使用」的边界问题。移动端有个经典难题:用户切到后台再切回来,算一次还是两次?常见做法是设一个静默阈值,比如 30 分钟,两次事件间隔超过阈值就切一个新会话。会话表存会话 ID、用户/设备、开始时间、结束时间、时长、首末页面。
聚合指标层是按天/小时预计算好的结果表,比如日活、新增、留存、页面 PV/UV、事件次数。看板查询只打这张表,不打原始事件表,这是性能的关键分界线。
| 层级 | 表名示例 | 写入频率 | 查询场景 |
|---|---|---|---|
| 原始事件层 | event_log | 高,每次上报 | 明细排查、重算 |
| 会话层 | user_session | 中,会话结束时 | 时长、路径分析 |
| 聚合指标层 | metric_daily | 低,定时任务 | 看板、报表 |
2.2 上报接口的字段约定与校验
客户端上报走一个 HTTP POST 接口,请求体是 JSON 数组,支持批量。下面是一个最小可用的接口实现,用 Spring Boot 风格写,核心是把校验和落库分开。
@RestController @RequestMapping("/api/collect") public class CollectController { @Autowired private EventLogService eventLogService; // 单次最多接收 50 条,防止单请求过大 private static final int MAX_BATCH = 50; @PostMapping("/events") public Result<?> report(@RequestBody @Valid EventBatchDTO batch) { List<EventDTO> events = batch.getEvents(); if (events == null || events.isEmpty()) { return Result.fail("empty batch"); } if (events.size() > MAX_BATCH) { return Result.fail("batch too large"); } // 逐条做字段补全和清洗,再批量入库 List<EventLog> logs = new ArrayList<>(events.size()); for (EventDTO e : events) { // 事件类型必填,缺失直接丢弃该条,不影响整批 if (e.getEventType() == null || e.getEventType().trim().isEmpty()) { continue; } EventLog log = new EventLog(); log.setDeviceId(e.getDeviceId()); log.setUserId(e.getUserId()); log.setEventType(e.getEventType()); // 客户端时间可能被篡改,服务端时间以当前为准 log.setClientTime(e.getClientTime()); log.setServerTime(System.currentTimeMillis()); log.setPage(e.getPage()); log.setExtra(e.getExtra()); logs.add(log); } eventLogService.saveBatch(logs); return Result.ok(logs.size()); } }这段代码有几个刻意的设计。第一,批量上限 50 条,是防止客户端一次把攒了几天的数据全塞进来,单请求体过大拖慢接口。第二,单条事件类型缺失时是continue跳过而不是整批失败,因为移动端网络不稳定,一条脏数据不该让整批上报作废。第三,客户端时间和服务端时间都存,客户端时间用于还原用户真实行为顺序,服务端时间用于排查上报延迟和防篡改。
参数上,deviceId是设备唯一标识,userId登录后才有,eventType是约定好的枚举字符串比如page_view、button_click、app_launch。extra用 JSON 字符串存,不要为了几个自定义字段频繁改表结构。
2.3 客户端上报的时机与重试
客户端不能每产生一个事件就发一次请求,那样耗电又费流量。常见做法是本地攒一批,满足任一条件就上报:攒够 20 条、距上次上报超过 30 秒、APP 切到后台。上报失败时把数据留在本地队列,下次一起发。
public class EventReporter { private final Queue<EventDTO> queue = new LinkedList<>(); private static final int FLUSH_SIZE = 20; private static final long FLUSH_INTERVAL = 30_000L; private long lastFlush = System.currentTimeMillis(); public synchronized void record(EventDTO event) { queue.offer(event); // 攒够一批或超时,触发上报 if (queue.size() >= FLUSH_SIZE || System.currentTimeMillis() - lastFlush > FLUSH_INTERVAL) { flush(); } } private void flush() { if (queue.isEmpty()) return; List<EventDTO> batch = new ArrayList<>(queue); queue.clear(); lastFlush = System.currentTimeMillis(); // 上报失败则把数据放回队列头部,等下次重试 httpPostAsync(batch, success -> { if (!success) { synchronized (this) { queue.addAll(batch); } } }); } }这里的关键是失败回填:上报失败的数据要放回队列,而不是丢掉。但要注意回填后队列可能无限增长,实际项目里我会加一个上限,比如超过 500 条就丢弃最老的,避免内存被撑爆。这个取舍要想清楚:宁可丢老数据,也不能让 APP 崩。
3. 从原始事件到指标:聚合任务怎么写才不出错
原始事件表一天能涨几十万上百万行,看板不可能直接查它。聚合任务的作用就是每天(或每小时)把原始事件算成指标,写进结果表。这一步做得好不好,直接决定看板是秒开还是转圈。
3.1 日活、新增、留存的口径定义
口径必须先写死在文档里,再写代码,否则不同人对「日活」的理解能吵一天。我一般这样定:日活是当天有任意事件的去重设备数(或用户数,看业务);新增是首次出现时间在当天的设备;次日留存是当天新增的设备中,第二天仍有事件的占比。
-- 日活:按天去重设备数 INSERT INTO metric_daily (stat_date, metric_key, metric_value) SELECT DATE(server_time) AS stat_date, 'dau' AS metric_key, COUNT(DISTINCT device_id) AS metric_value FROM event_log WHERE server_time >= ? AND server_time < ? GROUP BY DATE(server_time); -- 新增:首次出现时间落在当天的设备 INSERT INTO metric_daily (stat_date, metric_key, metric_value) SELECT first_day AS stat_date, 'new_device' AS metric_key, COUNT(*) AS metric_value FROM ( SELECT device_id, MIN(DATE(server_time)) AS first_day FROM event_log GROUP BY device_id ) t WHERE first_day = ? GROUP BY first_day;第一条 SQL 是标准的按天去重统计,注意WHERE用的是服务端时间且是左闭右开区间,避免边界重复。第二条 SQL 用子查询先算出每个设备的首次出现日期,再筛出目标日期,这就是新增。留存则是在新增的基础上,关联第二天的活跃集合,用JOIN或IN都能做,数据量大时优先用JOIN。
3.2 定时任务的幂等设计
聚合任务最怕的是重复跑。比如任务失败重试、或者手动补跑,如果直接INSERT就会产生重复行。解决办法是幂等:跑之前先删掉目标日期的旧数据,再插入新数据,整个过程放在一个事务里。
@Scheduled(cron = "0 30 1 * * ?") // 每天凌晨 1:30 跑前一天的数据 public void aggregateDaily() { LocalDate target = LocalDate.now().minusDays(1); transactionTemplate.execute(status -> { // 先删后插,保证重复执行结果一致 metricDailyMapper.deleteByDate(target); metricDailyMapper.insertDau(target); metricDailyMapper.insertNewDevice(target); metricDailyMapper.insertRetention(target); return null; }); }@Scheduled的 cron 表达式0 30 1 * * ?表示每天 1:30 执行。选这个时间是因为凌晨业务量低,聚合任务对数据库的压力小。事务包住「删 + 插」,任何一步失败都回滚,不会出现删了没插的中间状态。补跑历史数据时,只要改target日期重新调用同一段逻辑即可,天然幂等。
3.3 大表聚合的性能处理
当event_log到千万级,上面那种全表GROUP BY会越来越慢。几个实用手段:一是给server_time和device_id建联合索引,让范围扫描走索引;二是按天分区,聚合时只扫目标分区;三是如果实时性要求不高,把聚合拆成「小时级预聚合 + 天级汇总」两级,天级任务只读小时结果表,数据量小一个量级。
注意:分区键和索引不是越多越好。每加一个索引,写入就慢一分。事件表是写多读少,索引要克制,通常
server_time单列索引加一个device_id索引就够了。
4. 看板接口与查询优化:让图表秒开
看板是给运营和产品看的,他们对「转圈三秒」零容忍。看板接口的原则只有一条:只查聚合表,绝不碰原始事件表。原始表只在做明细排查时才查,而且要带强过滤条件。
4.1 看板接口的返回结构设计
一个看板页面通常要同时展示多个指标,如果每个指标一个接口,前端要发七八个请求,慢且难维护。更好的做法是一个接口返回一组指标。下面是一个趋势图接口的返回结构。
@GetMapping("/dashboard/trend") public Result<TrendVO> trend(@RequestParam String metricKey, @RequestParam String startDate, @RequestParam String endDate) { // 一次查出区间内所有数据点,按日期排序 List<MetricDaily> list = metricDailyMapper.selectRange(metricKey, startDate, endDate); TrendVO vo = new TrendVO(); vo.setMetricKey(metricKey); vo.setDates(list.stream().map(m -> m.getStatDate().toString()).collect(Collectors.toList())); vo.setValues(list.stream().map(MetricDaily::getMetricValue).collect(Collectors.toList())); return Result.ok(vo); }返回结构拆成dates和values两个平行数组,前端图表库(比如 ECharts)直接就能用,不用再转换。metricKey由前端传,同一个接口能服务日活、新增、留存等多个指标,减少接口数量。
4.2 缓存与预计算的选择
聚合表本身已经很小了,一天一行,查一年也就 365 行,通常不需要再加缓存。但如果看板要查「实时今日数据」,而今日数据还没被定时任务聚合,就得现场算,这时候缓存就有意义了。常见做法是给今日数据加一个 5 分钟的缓存,用 Redis 或本地缓存都行。
@Cacheable(value = "todayMetric", key = "#metricKey", unless = "#result == null") public Long getTodayMetric(String metricKey) { // 现场聚合今日数据,结果缓存 5 分钟 return eventLogMapper.countToday(metricKey); }@Cacheable的key用指标名区分,unless保证空结果不缓存(避免缓存穿透)。缓存时间设 5 分钟是个折中:运营看到的不是绝对实时,但也不会因为每次刷新都打数据库。
4.3 多维度下钻的查询写法
看板经常要下钻,比如从「日活」下钻到「各渠道的日活」。这时候聚合表如果只按天存,就不够用了。解决办法是聚合表加维度列,比如channel,聚合时按日期 + 渠道分组。
SELECT stat_date, channel, SUM(metric_value) AS value FROM metric_daily WHERE metric_key = 'dau' AND stat_date BETWEEN ? AND ? GROUP BY stat_date, channel ORDER BY stat_date;维度列不宜过多,两三个就够,每多一个维度,聚合表的行数就翻几倍。渠道、版本、地区是常见的三个维度,再多就该考虑上专门的 OLAP 引擎了,但那超出这套系统的范围。
5. 避坑与排查:那些让我加班到深夜的问题
这一章全是血泪经验,每条都按「现象 → 原因 → 解决」写,能帮你少走弯路。
5.1 日活数据忽高忽低
现象:某天日活突然比前一天高 30%,第二天又掉回去,反复横跳。原因:客户端deviceId生成逻辑有问题,部分机型每次启动都重新生成一个 ID,导致同一台设备被算成多个。解决:deviceId必须在首次安装时生成并持久化到本地存储,卸载重装才允许变。排查时按deviceId分组看事件数分布,如果大量设备只有一两条事件,基本就是这个问题。
5.2 聚合任务跑完数据对不上
现象:手动重跑一次聚合任务,指标值变了。原因:聚合逻辑里用了NOW()之类的当前时间函数,或者统计范围没写死,重跑时时间窗口漂移了。解决:聚合任务的所有时间边界必须由参数传入并固定,禁止在 SQL 里用当前时间。重跑必须幂等,先删后插。
5.3 上报接口偶发超时
现象:高峰期上报接口 P99 延迟飙到 2 秒以上。原因:每条事件单独INSERT,一批 50 条就是 50 次数据库往返。解决:改成批量插入,用INSERT INTO ... VALUES (...), (...), ...或 MyBatis 的foreach批量。另外把落库改成异步,接口收到数据先丢进内存队列就返回,后台线程慢慢写。
5.4 看板查询越来越慢
现象:上线三个月后,看板接口从 200ms 涨到 3 秒。原因:聚合表没建索引,或者查询条件没走索引,全表扫描。解决:给metric_daily的(metric_key, stat_date)建联合索引,查询时metric_key放前面。用EXPLAIN确认执行计划走的是索引而不是全表。
5.5 客户端时间被篡改导致留存算错
现象:留存率异常高,接近 100%。原因:部分用户手动改了手机时间,客户端时间超前,导致「次日留存」把当天也算进去了。解决:所有统计口径统一用服务端时间,客户端时间只作为辅助字段存着,不参与任何指标计算。这是最容易被忽略的一条,但影响极大。
6. 进阶技巧:把统计系统用出花来
基础链路跑通之后,有几个进阶方向能让这套系统价值翻倍。第一个是漏斗分析。漏斗的本质是「按顺序发生的事件序列」,比如「启动 → 浏览商品 → 加购 → 下单」。实现上不用改数据模型,只要在查询时按用户分组、按时间排序,判断事件序列是否匹配即可。SQL 里可以用窗口函数ROW_NUMBER()给每个用户的事件编号,再筛出匹配序列的用户。
第二个是实时大屏。定时任务最快也是分钟级,如果要做秒级更新的实时大屏,就得引入流处理。轻量做法是用内存队列加滑动窗口,重一点就上专门的流计算框架。我的建议是:除非业务真的需要秒级,否则别上,维护成本远高于收益。
第三个是数据导出与对账。运营经常要把指标导成 Excel 跟其他系统对账。导出接口要注意分页和流式写出,别一次性把几十万行读进内存。用StreamingResponseBody边查边写,内存占用恒定。
@GetMapping("/export") public StreamingResponseBody export(@RequestParam String startDate, @RequestParam String endDate) { return outputStream -> { // 边查边写,避免全量加载到内存 try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { writer.write("date,metric,value\n"); metricDailyMapper.streamRange(startDate, endDate, row -> { writer.write(row.getStatDate() + "," + row.getMetricKey() + "," + row.getMetricValue() + "\n"); }); } }; }这段代码用StreamingResponseBody把结果直接写进响应流,配合 MyBatis 的游标查询,内存里始终只有一行数据。导出大文件时这是标配,别用List一次性接。
最后一个技巧是给聚合任务加监控。任务跑没跑、跑了多久、处理了多少行,这些都要有日志和告警。我一般会在任务开始和结束各打一条日志,记录耗时和影响行数,超过阈值就告警。别等运营来问「今天数据怎么没更新」才发现任务挂了。
我自己做这类系统最大的教训是:一开始总想着把功能做全,结果数据模型改来改去,历史数据全废。后来学乖了,先把事件模型和口径定死,哪怕功能少一点,也要保证数据能重算。原始事件表就是你的后悔药,只要它在,什么指标都能补出来。希望帮到你。
本文还有配套的精品资源,点击获取