UV统计看起来简单,实际上从"用户打开小程序"到"后台UV数字+1",中间要经过:埋点采集、数据上报、去重计算、存储查询,每一步都有坑。
这篇文章,我会把整个链路拆开讲清楚。
一、整体架构
UV统计系统的4层架构:
code复制
┌─────────────────────────────────────────────────┐
│ 展示层 │
│ 数据看板 / API查询 │
├─────────────────────────────────────────────────┤
│ 计算层 │
│ 去重计算 / 聚合统计 / 离线批处理 │
├─────────────────────────────────────────────────┤
│ 存储层 │
│ 时序数据库 / Redis / 数据湖 │
├─────────────────────────────────────────────────┤
│ 采集层 │
│ 前端埋点 → 上报网关 → 消息队列 │
└─────────────────────────────────────────────────┘
数据流向:
code复制
用户行为 → 前端埋点 → 上报网关 → 消息队列 → 消费者 → 去重计算 → 存储 → 查询展示
二、采集层:前端埋点
2.1 埋点事件设计
UV统计需要的核心事件:
| 事件名 | 触发时机 | 核心参数 | 用途 |
|---|---|---|---|
app_launch | 小程序启动 | scene, path, referrerInfo | 统计UV来源 |
page_view | 页面显示 | pagePath, duration | 统计页面PV/UV |
app_show | 小程序切前台 | scene | 区分冷热启动 |
app_hide | 小程序切后台 | duration | 计算使用时长 |
事件参数设计:
javascript复制
// 埋点事件参数结构
const trackEvent = {
event_name: 'app_launch', // 事件名
event_time: 1718601600000, // 事件时间(毫秒时间戳)
session_id: 'sess_abc123', // 会话ID
user_id: 'uid_xyz789', // 用户ID(openid或匿名ID)
device_id: 'did_def456', // 设备ID
properties: { // 事件属性
scene: 1001, // 场景值
path: '/pages/index/index', // 页面路径
referrer_info: {}, // 来源信息
platform: 'android', // 平台
version: '1.0.0', // 小程序版本
network_type: 'wifi', // 网络类型
screen_width: 375, // 屏幕宽度
screen_height: 812, // 屏幕高度
}
};
2.2 会话ID生成规则
会话ID(session_id)是UV去重的基础。
生成规则:
| 规则 | 说明 |
|---|---|
| 冷启动 | 生成新session_id |
| 热启动(切前台) | 沿用旧session_id |
| 超时30分钟 | 生成新session_id |
| 小程序销毁 | session_id失效 |
javascript复制
// 会话管理器
class SessionManager {
constructor() {
this.sessionId = '';
this.lastActiveTime = 0;
this.SESSION_TIMEOUT = 30 * 60 * 1000; // 30分钟超时
}
// 获取当前session_id
getSessionId() {
const now = Date.now();
// 判断是否需要新建会话
if (!this.sessionId ||
(now - this.lastActiveTime) > this.SESSION_TIMEOUT) {
this.sessionId = this.generateSessionId();
}
this.lastActiveTime = now;
return this.sessionId;
}
// 生成session_id
generateSessionId() {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `sess_${timestamp}_${random}`;
}
// 重置会话(冷启动时调用)
resetSession() {
this.sessionId = '';
this.lastActiveTime = 0;
}
}
const sessionManager = new SessionManager();
2.3 用户ID生成策略
UV去重的核心是"识别同一个用户"。
三层ID体系:
| 层级 | ID类型 | 生成方式 | 稳定性 | 用途 |
|---|---|---|---|---|
| L1 | openid | 微信授权 | 高 | 精确UV统计 |
| L2 | unionid | 微信开放平台 | 高 | 跨小程序UV统计 |
| L3 | 匿名设备ID | 本地生成 | 中 | 未授权用户UV统计 |
匿名设备ID生成:
javascript复制
// 匿名设备ID管理
class DeviceIdManager {
constructor() {
this.STORAGE_KEY = '__device_id';
}
// 获取设备ID
getDeviceId() {
let deviceId = wx.getStorageSync(this.STORAGE_KEY);
if (!deviceId) {
deviceId = this.generateDeviceId();
wx.setStorageSync(this.STORAGE_KEY, deviceId);
}
return deviceId;
}
// 生成设备ID
generateDeviceId() {
const systemInfo = wx.getSystemInfoSync();
const components = [
systemInfo.brand || '', // 手机品牌
systemInfo.model || '', // 手机型号
systemInfo.system || '', // 操作系统
systemInfo.platform || '', // 平台
systemInfo.SDKVersion || '', // 基础库版本
Date.now().toString(36), // 时间戳
Math.random().toString(36).substring(2, 8) // 随机数
];
// 拼接后做哈希
const raw = components.join('|');
return 'did_' + this.simpleHash(raw);
}
// 简单哈希函数(生产环境建议用更安全的哈希)
simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}
}
const deviceIdManager = new DeviceIdManager();
2.4 埋点SDK封装
javascript复制
// 埋点SDK
class Tracker {
constructor(options = {}) {
this.appId = options.appId || '';
this.reportUrl = options.reportUrl || '';
this.sessionManager = new SessionManager();
this.deviceIdManager = new DeviceIdManager();
this.eventQueue = []; // 事件队列
this.MAX_QUEUE_SIZE = 10; // 队列最大长度
this.FLUSH_INTERVAL = 5000; // 上报间隔(毫秒)
this.timer = null;
}
// 初始化
init() {
// 监听小程序生命周期
this.setupLifecycleHooks();
// 启动定时上报
this.startFlushTimer();
}
// 监听小程序生命周期
setupLifecycleHooks() {
const self = this;
// 冷启动
const originalOnLaunch = App.prototype.onLaunch;
App.prototype.onLaunch = function(options) {
self.sessionManager.resetSession();
self.track('app_launch', {
scene: options.scene,
path: options.path,
referrer_info: options.referrerInfo,
launch_type: 'cold'
});
if (originalOnLaunch) {
originalOnLaunch.call(this, options);
}
};
// 热启动
const originalOnShow = App.prototype.onShow;
App.prototype.onShow = function(options) {
self.track('app_show', {
scene: options.scene,
path: options.path,
launch_type: 'hot'
});
if (originalOnShow) {
originalOnShow.call(this, options);
}
};
// 切后台
const originalOnHide = App.prototype.onHide;
App.prototype.onHide = function() {
self.track('app_hide', {});
self.flush(); // 切后台时立即上报
if (originalOnHide) {
originalOnHide.call(this);
}
};
}
// 埋点上报
track(eventName, properties = {}) {
const event = {
event_name: eventName,
event_time: Date.now(),
session_id: this.sessionManager.getSessionId(),
user_id: this.getUserId(),
device_id: this.deviceIdManager.getDeviceId(),
properties: {
...properties,
platform: wx.getSystemInfoSync().platform,
version: wx.getAccountInfoSync().miniProgram.version || '0.0.0',
network_type: this.getNetworkType(),
}
};
this.eventQueue.push(event);
// 队列满时立即上报
if (this.eventQueue.length >= this.MAX_QUEUE_SIZE) {
this.flush();
}
}
// 获取用户ID
getUserId() {
// 优先使用openid
const openid = wx.getStorageSync('__openid');
if (openid) return openid;
// 其次使用unionid
const unionid = wx.getStorageSync('__unionid');
if (unionid) return unionid;
// 最后使用匿名设备ID
return this.deviceIdManager.getDeviceId();
}
// 获取网络类型
getNetworkType() {
try {
const networkInfo = wx.getNetworkTypeSync();
return networkInfo.networkType || 'unknown';
} catch (e) {
return 'unknown';
}
}
// 启动定时上报
startFlushTimer() {
this.timer = setInterval(() => {
if (this.eventQueue.length > 0) {
this.flush();
}
}, this.FLUSH_INTERVAL);
}
// 批量上报
flush() {
if (this.eventQueue.length === 0) return;
const events = [...this.eventQueue];
this.eventQueue = [];
wx.request({
url: this.reportUrl,
method: 'POST',
data: {
app_id: this.appId,
events: events,
send_time: Date.now()
},
success: () => {
// 上报成功
},
fail: (err) => {
// 上报失败,重新放回队列(限制重试次数)
console.error('埋点上报失败:', err);
if (events.length + this.eventQueue.length <= this.MAX_QUEUE_SIZE * 2) {
this.eventQueue = [...events, ...this.eventQueue];
}
}
});
}
}
// 使用方式
const tracker = new Tracker({
appId: 'your_app_id',
reportUrl: 'https://your-api.com/track'
});
tracker.init();
三、传输层:上报网关
3.1 上报网关架构
code复制
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 小程序 │───→│ 接入层 │───→│ 消息队列 │───→│ 消费者 │
│ 前端SDK │ │ Nginx │ │ Kafka │ │ Flink │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
接入层职责:
- 接收前端埋点数据
- 数据校验和清洗
- 写入消息队列
- 返回响应
3.2 数据校验
javascript复制
// Node.js 上报网关
const express = require('express');
const { Kafka } = require('kafkajs');
const app = express();
app.use(express.json({ limit: '1mb' }));
const kafka = new Kafka({
clientId: 'track-gateway',
brokers: ['kafka-1:9092', 'kafka-2:9092', 'kafka-3:9092']
});
const producer = kafka.producer();
// 数据校验规则
function validateEvent(event) {
const errors = [];
// 必填字段校验
if (!event.event_name) errors.push('event_name is required');
if (!event.event_time) errors.push('event_time is required');
if (!event.session_id) errors.push('session_id is required');
// event_time范围校验(不超过当前时间1小时,不早于24小时前)
const now = Date.now();
if (event.event_time > now + 3600000 || event.event_time < now - 86400000) {
errors.push('event_time out of range');
}
// event_name格式校验
if (event.event_name && !/^[a-z_][a-z0-9_]{0,49}$/.test(event.event_name)) {
errors.push('event_name format invalid');
}
return errors;
}
// 上报接口
app.post('/track', async (req, res) => {
const { app_id, events, send_time } = req.body;
// 批量校验
const validEvents = [];
const invalidEvents = [];
for (const event of events) {
const errors = validateEvent(event);
if (errors.length === 0) {
validEvents.push({
...event,
app_id,
receive_time: Date.now(),
send_time
});
} else {
invalidEvents.push({ event, errors });
}
}
// 写入Kafka
if (validEvents.length > 0) {
try {
await producer.send({
topic: 'track-events',
messages: validEvents.map(event => ({
key: event.session_id,
value: JSON.stringify(event)
}))
});
} catch (err) {
console.error('Kafka写入失败:', err);
return res.status(500).json({ code: 500, message: 'internal error' });
}
}
// 返回结果
res.json({
code: 0,
message: 'ok',
received: events.length,
valid: validEvents.length,
invalid: invalidEvents.length
});
});
// 启动服务
async function start() {
await producer.connect();
app.listen(3000, () => {
console.log('Track gateway running on port 3000');
});
}
start();
3.3 消息队列设计
Kafka Topic设计:
| Topic | 分区数 | 副本数 | 保留时间 | 用途 |
|---|---|---|---|---|
track-events | 12 | 3 | 7天 | 原始埋点事件 |
track-events-dlq | 3 | 3 | 30天 | 死信队列(处理失败的事件) |
分区策略:
code复制
分区键 = session_id
同一个session_id的事件会被路由到同一个分区,保证同一会话内的事件有序。
四、计算层:去重与聚合
4.1 UV去重的3种方案
| 方案 | 原理 | 精度 | 内存占用 | 适用场景 |
|---|---|---|---|---|
| 精确去重(Set) | 存储所有user_id,用Set去重 | 100% | 高 | 日UV < 100万 |
| HyperLogLog | 概率算法,估算基数 | 99% | 低 | 日UV > 100万 |
| Bitmap | 位图,每个bit代表一个用户 | 100% | 中 | 日UV < 1亿 |
4.2 精确去重方案
javascript复制
// Redis精确去重
const Redis = require('ioredis');
const redis = new Redis({
host: 'redis-cluster',
port: 6379,
});
// 记录UV(使用Redis Set)
async function recordUV(date, userId) {
const key = `uv:${date}`; // 例如 uv:2026-06-17
await redis.sadd(key, userId);
await redis.expire(key, 90 * 24 * 3600); // 保留90天
}
// 查询日UV
async function getDailyUV(date) {
const key = `uv:${date}`;
return await redis.scard(key);
}
// 查询周UV(7天并集)
async function getWeeklyUV(endDate) {
const keys = [];
for (let i = 0; i < 7; i++) {
const d = new Date(endDate);
d.setDate(d.getDate() - i);
keys.push(`uv:${formatDate(d)}`);
}
// 使用SUNIONSTORE计算并集
const tempKey = `uv:temp:weekly:${endDate}`;
await redis.sunionstore(tempKey, ...keys);
const count = await redis.scard(tempKey);
await redis.del(tempKey);
return count;
}
function formatDate(date) {
return date.toISOString().split('T')[0];
}
精确去重的问题:
- 100万UV → Set内存约50MB
- 1000万UV → Set内存约500MB
- 内存占用随UV线性增长
4.3 HyperLogLog方案
javascript复制
// Redis HyperLogLog去重
async function recordUV_HLL(date, userId) {
const key = `uv:hll:${date}`;
await redis.pfadd(key, userId);
await redis.expire(key, 90 * 24 * 3600);
}
// 查询日UV(估算值)
async function getDailyUV_HLL(date) {
const key = `uv:hll:${date}`;
return await redis.pfcount(key);
}
// 查询周UV(7天合并)
async function getWeeklyUV_HLL(endDate) {
const keys = [];
for (let i = 0; i < 7; i++) {
const d = new Date(endDate);
d.setDate(d.getDate() - i);
keys.push(`uv:hll:${formatDate(d)}`);
}
return await redis.pfcount(...keys);
}
HyperLogLog的优势:
- 无论多少UV,每个key固定12KB内存
- 1000万UV → 只需12KB(vs 精确去重500MB)
- 标准误差约0.81%
4.4 Bitmap方案
javascript复制
// Redis Bitmap去重(需要用户ID是数字)
async function recordUV_Bitmap(date, userIdNum) {
const key = `uv:bitmap:${date}`;
await redis.setbit(key, userIdNum, 1);
await redis.expire(key, 90 * 24 * 3600);
}
// 查询日UV
async function getDailyUV_Bitmap(date) {
const key = `uv:bitmap:${date}`;
return await redis.bitcount(key);
}
// 查询周UV(7天OR运算)
async function getWeeklyUV_Bitmap(endDate) {
const keys = [];
for (let i = 0; i < 7; i++) {
const d = new Date(endDate);
d.setDate(d.getDate() - i);
keys.push(`uv:bitmap:${formatDate(d)}`);
}
const tempKey = `uv:bitmap:temp:weekly:${endDate}`;
await redis.bitop('OR', tempKey, ...keys);
const count = await redis.bitcount(tempKey);
await redis.del(tempKey);
return count;
}
Bitmap的优势:
- 100万UV → 约125KB
- 1亿UV → 约12MB
- 精确去重,且支持位运算(AND/OR/XOR)
Bitmap的局限:
- 用户ID必须是数字(0-N连续编号)
- 需要维护用户ID映射表
4.5 方案选型建议
| 日UV规模 | 推荐方案 | 理由 |
|---|---|---|
| < 10万 | 精确去重(Set) | 实现简单,内存可接受 |
| 10万-100万 | 精确去重(Set) | 内存约5-50MB,可接受 |
| 100万-1000万 | HyperLogLog | 内存固定12KB,误差<1% |
| > 1000万 | Bitmap + HyperLogLog | Bitmap精确统计 + HLL快速估算 |
五、存储层:时序数据存储
5.1 ClickHouse时序表设计
sql复制
-- 原始事件表(Append-Only)
CREATE TABLE track_events (
event_date Date,
event_time DateTime64(3),
event_name String,
session_id String,
user_id String,
device_id String,
app_id String,
properties String, -- JSON字符串
receive_time DateTime64(3)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (app_id, event_date, event_name, session_id)
TTL event_date + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
-- UV聚合表(物化视图)
CREATE MATERIALIZED VIEW uv_daily_mv
TO uv_daily
AS SELECT
event_date,
app_id,
uniqState(user_id) AS uv,
count() AS pv,
uniqState(session_id) AS session_count
FROM track_events
WHERE event_name = 'app_launch'
GROUP BY event_date, app_id;
-- UV查询表
CREATE TABLE uv_daily (
event_date Date,
app_id String,
uv AggregateFunction(uniq, String),
pv UInt64,
session_count AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (app_id, event_date);
5.2 UV查询
sql复制
-- 查询日UV
SELECT
event_date,
app_id,
uniqMerge(uv) AS uv,
pv,
uniqMerge(session_count) AS session_count
FROM uv_daily
WHERE app_id = 'your_app_id'
AND event_date >= '2026-06-01'
AND event_date <= '2026-06-17'
GROUP BY event_date, app_id
ORDER BY event_date;
-- 查询周UV(7天聚合)
SELECT
'weekly' AS period,
uniqMerge(uv) AS uv,
sum(pv) AS pv
FROM uv_daily
WHERE app_id = 'your_app_id'
AND event_date >= '2026-06-11'
AND event_date <= '2026-06-17';
-- 查询页面UV
SELECT
event_date,
JSONExtractString(properties, 'pagePath') AS page_path,
uniq(user_id) AS uv,
count() AS pv
FROM track_events
WHERE app_id = 'your_app_id'
AND event_name = 'page_view'
AND event_date = '2026-06-17'
GROUP BY event_date, page_path
ORDER BY uv DESC
LIMIT 20;
5.3 实时UV统计(Flink)
java复制
// Flink实时UV统计
public class RealTimeUVJob {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// Kafka Source
KafkaSource<String> source = KafkaSource.<String>builder()
.setBootstrapServers("kafka-1:9092,kafka-2:9092,kafka-3:9092")
.setTopics("track-events")
.setGroupId("flink-uv-job")
.setStartingOffsets(OffsetsInitializer.latest())
.setValueOnlyDeserializer(new SimpleStringSchema())
.build();
DataStream<String> stream = env.fromSource(
source, WatermarkStrategy.noWatermarks(), "kafka-source"
);
// 解析事件
DataStream<TrackEvent> events = stream
.map(json -> JSON.parseObject(json, TrackEvent.class))
.filter(e -> "app_launch".equals(e.getEventName()));
// 实时UV统计(1分钟窗口)
events
.keyBy(e -> e.getAppId())
.window(TumblingProcessingTimeWindows.of(Time.minutes(1)))
.aggregate(new UVAggFunction())
.addSink(new RedisSink<>());
env.execute("RealTime UV Job");
}
// UV聚合函数
public static class UVAggFunction
implements AggregateFunction<TrackEvent, Set<String>, UVResult> {
@Override
public Set<String> createAccumulator() {
return new HashSet<>();
}
@Override
public Set<String> add(TrackEvent event, Set<String> acc) {
acc.add(event.getUserId());
return acc;
}
@Override
public UVResult getResult(Set<String> acc) {
UVResult result = new UVResult();
result.setUv(acc.size());
result.setTimestamp(System.currentTimeMillis());
return result;
}
@Override
public Set<String> merge(Set<String> a, Set<String> b) {
a.addAll(b);
return a;
}
}
}
六、完整方案对比
| 维度 | 轻量方案 | 标准方案 | 重量方案 |
|---|---|---|---|
| 日UV | < 10万 | 10万-100万 | > 100万 |
| 采集 | wx.report + 自定义上报 | 自定义SDK + 批量上报 | 自定义SDK + 压缩上报 |
| 传输 | HTTP直连后端 | Nginx + Kafka | Nginx + Kafka + Flink |
| 去重 | Redis Set | Redis HLL | Redis HLL + Bitmap |
| 存储 | MySQL | ClickHouse | ClickHouse + 数据湖 |
| 计算 | 定时任务 | 物化视图 | Flink实时 + 离线批处理 |
| 展示 | 自建看板 | Grafana | 自建数据平台 |
| 成本 | 低 | 中 | 高 |
| 延迟 | 分钟级 | 秒级 | 毫秒级 |
七、避坑指南
坑1:前端上报丢失
问题: 用户切后台、网络断开时,事件可能丢失。
解决方案:
javascript复制
// 本地缓存 + 重试机制
class TrackerWithRetry extends Tracker {
constructor(options) {
super(options);
this.STORAGE_KEY = '__track_cache';
this.MAX_RETRY = 3;
}
// 重写flush方法,增加本地缓存
async flush() {
if (this.eventQueue.length === 0) return;
const events = [...this.eventQueue];
this.eventQueue = [];
// 先写入本地缓存
this.saveToLocal(events);
try {
await this.report(events);
// 上报成功,清除本地缓存
this.clearLocal(events);
} catch (err) {
console.error('上报失败,等待下次重试');
// 上报失败,事件保留在本地缓存中
}
}
// 上报时检查本地缓存
async report(events) {
// 先上报本地缓存的事件
const cachedEvents = this.loadFromLocal();
const allEvents = [...cachedEvents, ...events];
return new Promise((resolve, reject) => {
wx.request({
url: this.reportUrl,
method: 'POST',
data: {
app_id: this.appId,
events: allEvents,
send_time: Date.now()
},
success: () => resolve(),
fail: (err) => reject(err)
});
});
}
saveToLocal(events) {
const cached = wx.getStorageSync(this.STORAGE_KEY) || [];
const merged = [...cached, ...events].slice(-100); // 最多缓存100条
wx.setStorageSync(this.STORAGE_KEY, merged);
}
clearLocal(events) {
wx.setStorageSync(this.STORAGE_KEY, []);
}
loadFromLocal() {
return wx.getStorageSync(this.STORAGE_KEY) || [];
}
}
坑2:时区问题
问题: 服务端和客户端时区不一致,导致UV统计日期错误。
解决方案:
javascript复制
// 前端上报时带上时区信息
track(eventName, properties = {}) {
const event = {
// ...
event_time: Date.now(),
timezone_offset: -(new Date().getTimezoneOffset()), // 时区偏移(分钟)
// ...
};
}
// 后端计算时按客户端时区对齐
function getLocalDate(eventTime, timezoneOffset) {
// timezoneOffset 是客户端时区偏移(分钟)
// 例如:东八区 timezoneOffset = 480
const utcTime = eventTime + timezoneOffset * 60 * 1000;
return new Date(utcTime).toISOString().split('T')[0];
}
坑3:UV和PV的边界问题
问题: 同一个用户短时间内多次打开小程序,怎么算?
解决方案:
| 场景 | UV | PV | 说明 |
|---|---|---|---|
| 冷启动 | +1 | +1 | 新会话 |
| 热启动(30分钟内) | 0 | +1 | 同一会话 |
| 热启动(超过30分钟) | +1 | +1 | 新会话 |
| 同一天多次冷启动 | 只算1次UV | 每次算1次PV | UV按天去重 |

1130

被折叠的 条评论
为什么被折叠?



