Skip to content

网站流量统计与分析方案

流量指标体系

在分析网站或应用的活跃度时,有几个核心指标需要掌握:

核心指标定义

mermaid
graph TD
    A["流量统计指标"] --> B["PV - 页面浏览量"]
    A --> C["UV - 独立访客数"]
    A --> D["VV - 访问次数"]
    A --> E["IP - 独立IP数"]
    
    B --> B1["每次页面加载计数一次"]
    C --> C1["同一访客一天只计一次"]
    D --> D1["一次完整访问计数一次"]
    E --> E1["不同IP地址计数"]
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style B fill:#E74C3C,color:#fff,rx:10,ry:10
    style C fill:#27AE60,color:#fff,rx:10,ry:10
    style D fill:#9B59B6,color:#fff,rx:10,ry:10
    style E fill:#E67E22,color:#fff,rx:10,ry:10

举例说明

假设用户通过家庭宽带上网,早上访问了电商平台的2个商品页面,下午又访问了3个商品页面:

指标计算方式结果
PV早上2页 + 下午3页5
UV同一访客全天只计1次1
VV早上1次 + 下午1次访问2
IP宽带可能换IP1-2

指标的业务价值

  • PV:反映网站内容的吸引力和用户浏览深度
  • UV:反映真实的用户覆盖面
  • PV/UV比值:反映用户粘性,比值越高说明单用户浏览越多
  • IP:用于判断用户地域分布和识别异常流量

UV统计技术方案

方案一:Set集合精确统计

最直观的实现方式是为每个页面维护一个用户ID集合:

mermaid
graph TD
    A["用户访问页面"] --> B["获取用户标识"]
    B --> C{"Set中是否存在"}
    C -->|是| D["不做处理"]
    C -->|否| E["添加到Set"]
    
    F["统计UV"] --> G["计算Set大小"]
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style E fill:#27AE60,color:#fff,rx:10,ry:10
    style G fill:#9B59B6,color:#fff,rx:10,ry:10

Redis Set实现

java
@Service
public class UvStatisticsService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 记录用户访问
     */
    public void recordVisit(String pageId, String visitorId) {
        String key = buildKey(pageId, LocalDate.now());
        redisTemplate.opsForSet().add(key, visitorId);
        
        // 设置过期时间,避免数据堆积
        redisTemplate.expire(key, 7, TimeUnit.DAYS);
    }
    
    /**
     * 获取UV数量
     */
    public Long getUv(String pageId, LocalDate date) {
        String key = buildKey(pageId, date);
        return redisTemplate.opsForSet().size(key);
    }
    
    private String buildKey(String pageId, LocalDate date) {
        return String.format("uv:%s:%s", pageId, date.toString());
    }
}

适用场景

  • 访问量不大的网站
  • 需要精确统计的场景
  • 需要知道具体访问用户的场景

局限性

  • 当日访问用户达到百万级时,单个Set占用内存巨大
  • 多页面场景下内存成本更高

方案二:HyperLogLog概率统计

HyperLogLog是一种基数估计算法,能够用极小的空间统计海量数据的基数。

mermaid
graph LR
    A["HyperLogLog特点"] --> B["空间效率极高"]
    A --> C["存在一定误差"]
    A --> D["支持合并操作"]
    
    B --> B1["12KB存储2^64个元素"]
    C --> C1["标准误差0.81%"]
    D --> D1["可跨时段统计"]
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style B fill:#27AE60,color:#fff,rx:10,ry:10
    style C fill:#E67E22,color:#fff,rx:10,ry:10
    style D fill:#9B59B6,color:#fff,rx:10,ry:10

Redis HyperLogLog实现

java
@Service
public class HllUvStatisticsService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 记录用户访问
     */
    public void recordVisit(String pageId, String visitorId) {
        String key = buildHllKey(pageId, LocalDate.now());
        redisTemplate.opsForHyperLogLog().add(key, visitorId);
    }
    
    /**
     * 获取单日UV
     */
    public Long getDailyUv(String pageId, LocalDate date) {
        String key = buildHllKey(pageId, date);
        return redisTemplate.opsForHyperLogLog().size(key);
    }
    
    /**
     * 获取日期范围内的UV(自动去重)
     */
    public Long getRangeUv(String pageId, LocalDate startDate, LocalDate endDate) {
        List<String> keys = new ArrayList<>();
        LocalDate current = startDate;
        while (!current.isAfter(endDate)) {
            keys.add(buildHllKey(pageId, current));
            current = current.plusDays(1);
        }
        
        // 合并多天的HyperLogLog
        String mergedKey = "uv:merged:" + UUID.randomUUID();
        redisTemplate.opsForHyperLogLog().union(
            mergedKey, 
            keys.toArray(new String[0]));
        
        Long result = redisTemplate.opsForHyperLogLog().size(mergedKey);
        redisTemplate.delete(mergedKey);
        
        return result;
    }
    
    private String buildHllKey(String pageId, LocalDate date) {
        return String.format("hll:uv:%s:%s", pageId, date.toString());
    }
}

方案对比测试

下面通过实际测试对比两种方案:

java
public class UvStatisticsTest {
    
    private static final long USER_COUNT = 100000L;
    
    @Test
    void compareAccuracyAndMemory() {
        Jedis jedis = new Jedis("localhost", 6379);
        
        String setKey = "test:set:uv";
        String hllKey = "test:hll:uv";
        
        // 添加10万用户
        for (int i = 0; i < USER_COUNT; i++) {
            String userId = "user_" + i;
            jedis.sadd(setKey, userId);
            jedis.pfadd(hllKey, userId);
        }
        
        // 精确度对比
        long setCount = jedis.scard(setKey);
        long hllCount = jedis.pfcount(hllKey);
        
        System.out.println("Set精确计数: " + setCount);
        System.out.println("HLL估算计数: " + hllCount);
        System.out.println("误差率: " + 
            String.format("%.2f%%", 
                Math.abs(setCount - hllCount) * 100.0 / setCount));
        
        // 内存占用对比(序列化后大小)
        // Set: 约965KB
        // HLL: 约10KB
    }
}

测试结果

  • Set精确度:100%
  • HLL精确度:约99.27%(误差0.73%)
  • Set内存占用:约965KB(10万用户)
  • HLL内存占用:约10KB(固定)

PV统计实现

PV统计相对简单,不需要去重:

java
@Service
public class PvStatisticsService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 记录PV(每次访问都计数)
     */
    public void recordPv(String pageId) {
        String key = buildPvKey(pageId, LocalDate.now());
        redisTemplate.opsForValue().increment(key);
    }
    
    /**
     * 记录PV(带小时维度)
     */
    public void recordPvWithHour(String pageId) {
        LocalDateTime now = LocalDateTime.now();
        String dayKey = buildPvKey(pageId, now.toLocalDate());
        String hourKey = buildPvHourKey(pageId, now);
        
        redisTemplate.opsForValue().increment(dayKey);
        redisTemplate.opsForValue().increment(hourKey);
    }
    
    /**
     * 获取指定日期PV
     */
    public Long getDailyPv(String pageId, LocalDate date) {
        String key = buildPvKey(pageId, date);
        String value = redisTemplate.opsForValue().get(key);
        return value != null ? Long.parseLong(value) : 0L;
    }
    
    /**
     * 获取小时级PV分布
     */
    public Map<Integer, Long> getHourlyPvDistribution(String pageId, LocalDate date) {
        Map<Integer, Long> distribution = new HashMap<>();
        for (int hour = 0; hour < 24; hour++) {
            String key = buildPvHourKey(pageId, 
                date.atTime(hour, 0));
            String value = redisTemplate.opsForValue().get(key);
            distribution.put(hour, value != null ? Long.parseLong(value) : 0L);
        }
        return distribution;
    }
    
    private String buildPvKey(String pageId, LocalDate date) {
        return String.format("pv:%s:%s", pageId, date.toString());
    }
    
    private String buildPvHourKey(String pageId, LocalDateTime time) {
        return String.format("pv:%s:%s:%02d", 
            pageId, 
            time.toLocalDate().toString(),
            time.getHour());
    }
}

数据持久化与分析

定时归档策略

Redis中的统计数据需要定期归档到数据库:

mermaid
sequenceDiagram
    participant Redis as Redis缓存
    participant Task as 定时任务
    participant DB as 数据库
    participant BI as 分析系统
    
    Task->>Redis: 获取昨日统计数据
    Redis-->>Task: 返回PV/UV数据
    Task->>DB: 写入统计表
    Task->>Redis: 删除已归档数据
    
    BI->>DB: 查询历史数据
    DB-->>BI: 返回趋势数据

归档任务实现

java
@Scheduled(cron = "0 30 0 * * ?")  // 每天0:30执行
public void archiveYesterdayStats() {
    LocalDate yesterday = LocalDate.now().minusDays(1);
    
    // 获取所有页面ID
    List<String> pageIds = pageService.getAllPageIds();
    
    for (String pageId : pageIds) {
        // 获取UV
        Long uv = uvService.getDailyUv(pageId, yesterday);
        
        // 获取PV
        Long pv = pvService.getDailyPv(pageId, yesterday);
        
        // 写入数据库
        PageStats stats = new PageStats();
        stats.setPageId(pageId);
        stats.setStatDate(yesterday);
        stats.setPv(pv);
        stats.setUv(uv);
        stats.setCreateTime(LocalDateTime.now());
        
        statsMapper.insert(stats);
    }
    
    log.info("完成{}的统计数据归档", yesterday);
}

统计表设计

sql
CREATE TABLE page_statistics (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    page_id VARCHAR(64) NOT NULL COMMENT '页面标识',
    stat_date DATE NOT NULL COMMENT '统计日期',
    pv BIGINT DEFAULT 0 COMMENT '页面浏览量',
    uv BIGINT DEFAULT 0 COMMENT '独立访客数',
    avg_duration INT DEFAULT 0 COMMENT '平均停留时长(秒)',
    bounce_rate DECIMAL(5,2) COMMENT '跳出率',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE INDEX uk_page_date(page_id, stat_date),
    INDEX idx_stat_date(stat_date)
) COMMENT='页面统计表';

高级分析场景

用户行为漏斗分析

mermaid
graph TD
    A["首页访问<br/>100,000 UV"] --> B["商品详情页<br/>45,000 UV"]
    B --> C["加入购物车<br/>12,000 UV"]
    C --> D["提交订单<br/>5,000 UV"]
    D --> E["支付成功<br/>4,200 UV"]
    
    A -->|转化率 45%| B
    B -->|转化率 26.7%| C
    C -->|转化率 41.7%| D
    D -->|转化率 84%| E
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style B fill:#3498DB,color:#fff,rx:10,ry:10
    style C fill:#9B59B6,color:#fff,rx:10,ry:10
    style D fill:#E67E22,color:#fff,rx:10,ry:10
    style E fill:#27AE60,color:#fff,rx:10,ry:10

来源渠道分析

java
@Service
public class TrafficSourceAnalyzer {
    
    /**
     * 记录访问来源
     */
    public void recordSource(String visitorId, String source) {
        String dateKey = "source:" + LocalDate.now();
        redisTemplate.opsForHash().increment(dateKey, source, 1);
    }
    
    /**
     * 获取来源分布
     */
    public Map<String, Long> getSourceDistribution(LocalDate date) {
        String key = "source:" + date;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
        
        return entries.entrySet().stream()
            .collect(Collectors.toMap(
                e -> e.getKey().toString(),
                e -> Long.parseLong(e.getValue().toString())
            ));
    }
}

企业级方案选型

对于大规模数据分析场景,可以考虑专业的OLAP数据库:

方案特点适用场景
ClickHouse列式存储,查询极快实时分析,Ad-hoc查询
Doris兼容MySQL协议多维分析,报表系统
Elasticsearch全文检索+聚合分析日志分析,实时监控
Flink + Kafka流式计算实时指标计算

技术栈选型建议

  • 日活百万以下:Redis + MySQL足够
  • 日活百万到千万:考虑ClickHouse或Doris
  • 需要实时分析:Flink流式计算 + Kafka

更新: 2025-12-06 17:31:47
原文: https://www.yuque.com/u22210564/zoxfmt/lxzgassuogflideb

Java 后端面试知识库