准备搜索...
分类筛选
全部分类

输入关键词开始搜索

基于 Go + Redis + MySQL 实现类不蒜子统计服务

使用 Go + Redis + MySQL 自建高性能网站统计服务,支持整站/单页 PV/UV 统计、实时在线人数、热门文章排行(支持 TOP10/20/50 参数化)、批量查询(列表页优化)、异步落库 MySQL、数据恢复等核心功能。采用按需加载设计,性能极致优化,单次统计响应 <5ms,批量查询 20 个页面 <50ms,支持 10000+ QPS。

包含完整的 API 设计、Redis Key 规范、MySQL 表结构、前端 JS 实现、部署配置、数据备份与恢复方案。特别适合个人博客、技术网站、中小型网站自建统计服务,摆脱第三方依赖,数据完全自主可控。文档面向 AI 开发优化,代码示例完整,开箱即用。

项目概述

基于 Go + Redis + MySQL 实现类不蒜子统计服务

核心功能:

  • 页面访问统计(PV/UV)
  • 批量查询(列表页)
  • 异步落库 MySQL
  • 按需返回字段

技术栈

组件技术版本
后端Go + Gin1.21+
缓存Redis7.0+
数据库MySQL8.0+
队列Go Channel-
部署Systemd-

统计参数(7个)

site_pv          - 整站总PV
site_uv          - 整站总UV
site_pv_today    - 今日整站PV
site_uv_today    - 今日整站UV
page_pv          - 页面PV
site_online      - 当前在线人数
hot_pages_week   - 本周热门文章(支持参数化:TOP10/TOP20/TOP50等)

Redis Key 设计

# 整站统计(永久保留)
ddkk:site:pv:{site}                      → 整站总PV (String, 永久保留)
ddkk:site:uv:{site}                      → 整站总UV (String, 永久保留)
ddkk:site:uv:set:{site}                  → UV去重集合 (Set, TTL 30天后清理)
# 今日统计
ddkk:site:pv:{site}:{YYYYMMDD}          → 今日PV (String, TTL 90天)
ddkk:site:uv:{site}:{YYYYMMDD}          → 今日UV (String, TTL 90天)
ddkk:site:uv:set:{site}:{YYYYMMDD}      → 今日UV去重 (Set, TTL 90天)
# 页面统计(永久保留,不删除)
ddkk:page:pv:{site}:{page}              → 页面PV (String, 永久保留)
# 在线人数
ddkk:online:{site}:{user_hash}          → 在线心跳 (String, TTL 5分钟)
# 热门文章(Sorted Set,score=PV)
ddkk:hot:{site}:today                   → 今日热门 (Sorted Set, TTL 2天)
ddkk:hot:{site}:week                    → 本周热门 (Sorted Set, TTL 8天)
ddkk:hot:{site}:month                   → 本月热门 (Sorted Set, TTL 32天)
ddkk:hot:{site}:all                     → 历史最热 (Sorted Set, 永久)

MySQL 表结构

MySQL 作用:

  1. 异步备份:Redis 数据异步写入 MySQL(不影响性能)
  2. 历史查询:查询历史统计数据和趋势
  3. 恢复源:Redis 丢失时,从 MySQL 恢复数据 ⭐️
-- 每日统计汇总(用于恢复整站统计)
CREATE TABLE daily_stats (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    date DATE NOT NULL,
    site VARCHAR(100) NOT NULL,
    pv BIGINT DEFAULT 0,
    uv BIGINT DEFAULT 0,
    pv_pc BIGINT DEFAULT 0,
    pv_mobile BIGINT DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_date_site (date, site),
    INDEX idx_date (date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 文章访问记录(用于恢复文章阅读量和热门排行)
CREATE TABLE page_stats (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    date DATE NOT NULL,
    site VARCHAR(100) NOT NULL,
    page VARCHAR(500) NOT NULL,
    pv BIGINT DEFAULT 0,
    uv BIGINT DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_date_site_page (date, site, page),
    INDEX idx_page (page(100)),
    INDEX idx_date (date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 访问日志(异步队列写入)
CREATE TABLE access_logs (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    site VARCHAR(100) NOT NULL,
    page VARCHAR(500) NOT NULL,
    user_hash VARCHAR(32) NOT NULL,
    ip VARCHAR(45),
    user_agent TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_created (created_at),
    INDEX idx_site_page (site, page(100))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

API 设计

1. 统一统计接口(POST)

Endpoint:POST /api/stats

Request:

{
  "site": "www.ddkk.com",
  "page": "/article/123.html",
  "fields": ["site_pv", "site_uv", "page_pv", "site_online"],
  "action": "count",
  "batch_pages": []
}

Response:

{
  "site": "ddkk.com",
  "site_pv": 123456,
  "site_uv": 45678,
  "page_pv": 156,
  "site_online": 23
}

2. 批量查询接口(POST)

Endpoint:POST /api/batch

Request:

{
  "site": "www.ddkk.com",
  "pages": ["/article/1.html", "/article/2.html"],
  "batch_size": 20
}

Response:

{
  "site": "ddkk.com",
  "pages": {
    "/article/1.html": {"pv": 123},
    "/article/2.html": {"pv": 456}
  }
}

3. 热门文章接口(GET)

Endpoint:GET /api/hot

Request:

GET /api/hot?site=ddkk.com&period=week&limit=20

参数说明:

  • site: 站点(必填)
  • period: 时间周期(可选,默认 week)
    • today - 今日热门
    • week - 本周热门
    • month - 本月热门
    • all - 历史最热
  • limit: 返回条数(可选,默认 10,最大 100)

Response:

{
  "site": "ddkk.com",
  "period": "week",
  "hot_pages": [
    {"page": "/article/123.html", "pv": 5678},
    {"page": "/article/456.html", "pv": 3456},
    ...
  ]
}

4. 数据恢复接口(POST)⭐️

Endpoint:POST /api/restore

Request:

{
  "source": "mysql",
  "site": "ddkk.com",
  "options": {
    "restore_site_stats": true,
    "restore_page_stats": true,
    "restore_hot_pages": true,
    "days_back": 30
  }
}

参数说明:

  • source: 恢复源(固定 mysql
  • site: 要恢复的站点
  • options.restore_site_stats: 是否恢复整站统计(PV/UV)
  • options.restore_page_stats: 是否恢复所有文章阅读量
  • options.restore_hot_pages: 是否恢复热门排行
  • options.days_back: 恢复最近多少天的日期统计(默认30天)

Response:

{
  "success": true,
  "message": "数据恢复完成",
  "stats": {
    "site_pv": 123456,
    "site_uv": 45678,
    "pages_restored": 8532,
    "hot_pages_restored": 100,
    "daily_stats_restored": 30,
    "duration_seconds": 15
  }
}

使用场景:

  1. Redis 数据完全丢失
  2. 迁移服务器
  3. 数据校验和修复
  4. 灾难恢复

5. 前端脚本接口(GET)

Endpoint:GET /ddkk-stats.js

Response: JavaScript 文件(自动扫描DOM + 按需请求)


核心实现要求

0. 命令行工具(main.go)

支持多种运行模式:

func main() {
    if len(os.Args) < 2 {
        fmt.Println("Usage: ddkk-stats [server|restore-from-mysql]")
        os.Exit(1)
    }
    command := os.Args[1]
    switch command {
    case "server":
        // 启动 HTTP 服务
        startServer()
    case "restore-from-mysql":
        // 数据恢复模式
        site := flag.String("site", "ddkk.com", "站点名称")
        onlyPages := flag.Bool("only-pages", false, "只恢复文章阅读量")
        days := flag.Int("days", 30, "恢复最近N天的数据")
        flag.Parse()
        opts := RestoreOptions{
            Site:             *site,
            RestoreSiteStats: !*onlyPages,
            RestorePageStats: true,
            RestoreHotPages:  !*onlyPages,
            DaysBack:         *days,
        }
        result, err := RestoreFromMySQL(opts)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("✓ 恢复完成!\n")
        fmt.Printf("  整站PV: %d\n", result.SitePV)
        fmt.Printf("  文章数: %d\n", result.PagesRestored)
        fmt.Printf("  耗时: %v\n", result.Duration)
    default:
        fmt.Printf("Unknown command: %s\n", command)
        os.Exit(1)
    }
}

使用示例:

# 启动服务
./ddkk-stats server
# 数据恢复(完整)
./ddkk-stats restore-from-mysql --site=ddkk.com
# 只恢复文章阅读量
./ddkk-stats restore-from-mysql --site=ddkk.com --only-pages
# 恢复最近7天
./ddkk-stats restore-from-mysql --site=ddkk.com --days=7

1. 域名归一化

// www.ddkk.com → ddkk.com
// https://ddkk.com → ddkk.com
func NormalizeSite(raw string) string {
    site := strings.TrimPrefix(raw, "https://")
    site = strings.TrimPrefix(site, "http://")
    site = strings.TrimPrefix(site, "www.")
    site = strings.Split(site, ":")[0]
    return site
}

2. 用户标识生成(UV去重)

func GetUserHash(ip, ua, site, page string, date string) string {
    data := fmt.Sprintf("%s:%s:%s:%s:%s", ip, ua, site, page, date)
    return fmt.Sprintf("%x", md5.Sum([]byte(data)))
}

3. 按需查询逻辑

// 根据 fields 按需执行 Redis 操作
for _, field := range req.Fields {
    switch field {
    case "site_pv":
        if req.Action == "count" {
            result[field] = RedisIncr(key)
        } else {
            result[field] = RedisGet(key)
        }
    case "site_uv":
        // UV 去重逻辑
    case "site_online":
        // 在线人数统计
    }
}

4. 批量查询优化(使用MGET)

// 构造所有 keys
keys := make([]string, len(pages))
for i, page := range pages {
    keys[i] = fmt.Sprintf("ddkk:page:pv:%s:%s", site, page)
}
// 一次性批量获取
values, err := rdb.MGet(ctx, keys...).Result()

5. 在线人数统计

// 写入心跳(TTL 5分钟)
key := fmt.Sprintf("ddkk:online:%s:%s", site, userHash)
rdb.Set(ctx, key, "1", 5*time.Minute)
// 统计在线人数
pattern := fmt.Sprintf("ddkk:online:%s:*", site)
keys, _ := rdb.Keys(ctx, pattern).Result()
return len(keys)

6. MySQL → Redis 数据恢复 ⭐️

完整恢复流程:

type RestoreOptions struct {
    Site              string
    RestoreSiteStats  bool
    RestorePageStats  bool
    RestoreHotPages   bool
    DaysBack          int
}
func RestoreFromMySQL(opts RestoreOptions) (*RestoreResult, error) {
    result := &RestoreResult{StartTime: time.Now()}
    // 1. 恢复整站统计
    if opts.RestoreSiteStats {
        totalPV, totalUV := getSiteStatsFromMySQL(opts.Site)
        rdb.Set(ctx, fmt.Sprintf("ddkk:site:pv:%s", opts.Site), totalPV, 0)
        rdb.Set(ctx, fmt.Sprintf("ddkk:site:uv:%s", opts.Site), totalUV, 0)
        result.SitePV = totalPV
        result.SiteUV = totalUV
    }
    // 2. 恢复文章阅读量(批量,性能优化)
    if opts.RestorePageStats {
        pageStats := getPageStatsFromMySQL(opts.Site)
        pipeline := rdb.Pipeline()
        for page, pv := range pageStats {
            key := fmt.Sprintf("ddkk:page:pv:%s:%s", opts.Site, page)
            pipeline.Set(ctx, key, pv, 0)
        }
        pipeline.Exec(ctx)
        result.PagesRestored = len(pageStats)
    }
    // 3. 恢复历史最热排行
    if opts.RestoreHotPages {
        hotPages := getTopPagesFromMySQL(opts.Site, 100)
        for _, item := range hotPages {
            rdb.ZAdd(ctx, fmt.Sprintf("ddkk:hot:%s:all", opts.Site), &redis.Z{
                Score:  float64(item.PV),
                Member: item.Page,
            })
        }
        result.HotPagesRestored = len(hotPages)
    }
    // 4. 恢复近期日期统计
    dailyStats := getDailyStatsFromMySQL(opts.Site, opts.DaysBack)
    for date, stats := range dailyStats {
        dateKey := strings.ReplaceAll(date, "-", "")
        rdb.Set(ctx, fmt.Sprintf("ddkk:site:pv:%s:%s", opts.Site, dateKey), stats.PV, 90*24*time.Hour)
        rdb.Set(ctx, fmt.Sprintf("ddkk:site:uv:%s:%s", opts.Site, dateKey), stats.UV, 90*24*time.Hour)
    }
    result.DailyStatsRestored = len(dailyStats)
    result.Duration = time.Since(result.StartTime)
    return result, nil
}
// MySQL 查询辅助函数
func getSiteStatsFromMySQL(site string) (int64, int64) {
    var totalPV, totalUV int64
    db.QueryRow(`
        SELECT SUM(pv), SUM(uv) 
        FROM daily_stats 
        WHERE site = ?
    `, site).Scan(&totalPV, &totalUV)
    return totalPV, totalUV
}
func getPageStatsFromMySQL(site string) map[string]int64 {
    rows, _ := db.Query(`
        SELECT page, SUM(pv) as total_pv 
        FROM page_stats 
        WHERE site = ? 
        GROUP BY page
    `, site)
    defer rows.Close()
    pageStats := make(map[string]int64)
    for rows.Next() {
        var page string
        var pv int64
        rows.Scan(&page, &pv)
        pageStats[page] = pv
    }
    return pageStats
}
func getTopPagesFromMySQL(site string, limit int) []HotPage {
    rows, _ := db.Query(`
        SELECT page, SUM(pv) as total_pv 
        FROM page_stats 
        WHERE site = ? 
        GROUP BY page 
        ORDER BY total_pv DESC 
        LIMIT ?
    `, site, limit)
    defer rows.Close()
    hotPages := make([]HotPage, 0)
    for rows.Next() {
        var page string
        var pv int64
        rows.Scan(&page, &pv)
        hotPages = append(hotPages, HotPage{Page: page, PV: pv})
    }
    return hotPages
}
func getDailyStatsFromMySQL(site string, days int) map[string]DailyStat {
    rows, _ := db.Query(`
        SELECT date, pv, uv 
        FROM daily_stats 
        WHERE site = ? AND date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
        ORDER BY date DESC
    `, site, days)
    defer rows.Close()
    dailyStats := make(map[string]DailyStat)
    for rows.Next() {
        var date string
        var pv, uv int64
        rows.Scan(&date, &pv, &uv)
        dailyStats[date] = DailyStat{PV: pv, UV: uv}
    }
    return dailyStats
}

7. 热门文章排行(Sorted Set)

// 计数时更新所有周期的排行
func UpdateHotPages(site, page string, increment int64) {
    periods := map[string]time.Duration{
        "today": 2 * 24 * time.Hour,
        "week":  8 * 24 * time.Hour,
        "month": 32 * 24 * time.Hour,
        "all":   0, // 不过期
    }
    for period, ttl := range periods {
        key := fmt.Sprintf("ddkk:hot:%s:%s", site, period)
        rdb.ZIncrBy(ctx, key, float64(increment), page)
        if ttl > 0 {
            rdb.Expire(ctx, key, ttl)
        }
    }
}
// 查询热门文章(支持参数化)
func GetHotPages(site, period string, limit int) []HotPage {
    if limit <= 0 || limit > 100 {
        limit = 10 // 默认 10 条
    }
    key := fmt.Sprintf("ddkk:hot:%s:%s", site, period)
    result, _ := rdb.ZRevRangeWithScores(ctx, key, 0, int64(limit-1)).Result()
    hotPages := make([]HotPage, 0, len(result))
    for _, item := range result {
        hotPages = append(hotPages, HotPage{
            Page: item.Member.(string),
            PV:   int64(item.Score),
        })
    }
    return hotPages
}

异步落库设计

队列结构

type AccessLog struct {
    Site      string
    Page      string
    UserHash  string
    IP        string
    UserAgent string
    Timestamp time.Time
}
var logQueue = make(chan AccessLog, 10000) // 缓冲队列

生产者(统计时)

// 每次统计时发送到队列(非阻塞)
select {
case logQueue <- AccessLog{...}:
default:
    // 队列满时丢弃(不阻塞用户请求)
}

消费者(独立 Goroutine)

func AsyncLogConsumer() {
    batch := make([]AccessLog, 0, 100)
    ticker := time.NewTicker(5 * time.Second)
    for {
        select {
        case log := <-logQueue:
            batch = append(batch, log)
            if len(batch) >= 100 {
                BatchInsertMySQL(batch)
                batch = batch[:0]
            }
        case <-ticker.C:
            if len(batch) > 0 {
                BatchInsertMySQL(batch)
                batch = batch[:0]
            }
        }
    }
}

批量写入 MySQL

func BatchInsertMySQL(logs []AccessLog) error {
    query := "INSERT INTO access_logs (site, page, user_hash, ip, user_agent, created_at) VALUES "
    values := []interface{}{}
    for i, log := range logs {
        if i > 0 {
            query += ","
        }
        query += "(?, ?, ?, ?, ?, ?)"
        values = append(values, log.Site, log.Page, log.UserHash, log.IP, log.UserAgent, log.Timestamp)
    }
    _, err := db.Exec(query, values...)
    return err
}

定时归档任务(每天凌晨3点)

func DailyArchive() {
    ticker := time.NewTicker(24 * time.Hour)
    for range ticker.C {
        if time.Now().Hour() == 3 {
            ArchiveYesterday()
        }
    }
}
func ArchiveYesterday() {
    yesterday := time.Now().AddDate(0, 0, -1).Format("20060102")
    // 1. 从 Redis 读取昨日数据
    sitePV := RedisGet(fmt.Sprintf("ddkk:site:pv:ddkk.com:%s", yesterday))
    siteUV := RedisGet(fmt.Sprintf("ddkk:site:uv:ddkk.com:%s", yesterday))
    // 2. 写入 MySQL(备份)
    db.Exec("INSERT INTO daily_stats (date, site, pv, uv) VALUES (?, ?, ?, ?)", yesterday, "ddkk.com", sitePV, siteUV)
    // 3. 删除 90 天前的按日期统计数据(只删除临时统计,不删除累计数据)
    date90DaysAgo := time.Now().AddDate(0, 0, -90).Format("20060102")
    // 删除:按日期的统计(临时数据)
    rdb.Del(ctx, fmt.Sprintf("ddkk:site:pv:ddkk.com:%s", date90DaysAgo))
    rdb.Del(ctx, fmt.Sprintf("ddkk:site:uv:ddkk.com:%s", date90DaysAgo))
    rdb.Del(ctx, fmt.Sprintf("ddkk:site:uv:set:ddkk.com:%s", date90DaysAgo))
    // 注意:以下数据永久保留,不删除
    // - ddkk:site:pv:ddkk.com(整站总PV)
    // - ddkk:site:uv:ddkk.com(整站总UV)
    // - ddkk:page:pv:ddkk.com:*(所有文章的阅读量)
    // - ddkk:hot:ddkk.com:all(历史最热排行)
    // 4. 每周一清理 week 热门排行(重新开始统计)
    if time.Now().Weekday() == time.Monday {
        rdb.Del(ctx, fmt.Sprintf("ddkk:hot:ddkk.com:week"))
    }
    // 5. 每月1号清理 month 热门排行
    if time.Now().Day() == 1 {
        rdb.Del(ctx, fmt.Sprintf("ddkk:hot:ddkk.com:month"))
    }
}

前端 JS 实现

核心逻辑

(function() {
    function collectMetrics() {
        let fields = [];
        let batchPages = [];
        let hotPages = [];
        // 扫描所有 ddkk_value_* 元素
        document.querySelectorAll('[id^="ddkk_value_"], [class*="ddkk_value_"]').forEach(el => {
            let field = (el.id || el.className).replace('ddkk_value_', '').split(' ')[0];
            if (!fields.includes(field)) fields.push(field);
            // 批量查询
            if (el.dataset.pageId) {
                batchPages.push(el.dataset.pageId);
            }
        });
        // 扫描热门文章元素
        document.querySelectorAll('[id^="ddkk_hot_pages_"]').forEach(el => {
            let period = el.id.replace('ddkk_hot_pages_', '');
            let limit = parseInt(el.dataset.limit) || 10;
            hotPages.push({period: period, limit: limit, element: el});
        });
        return {
            site: location.host,
            page: location.pathname,
            fields: fields,
            action: batchPages.length > 0 ? 'query' : 'count',
            batch_pages: batchPages,
            hot_pages: hotPages
        };
    }
    function fetchStats() {
        let metrics = collectMetrics();
        // 1. 获取基础统计数据
        if (metrics.fields.length > 0 || metrics.batch_pages.length > 0) {
            fetch('/api/stats', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify(metrics)
            })
            .then(r => r.json())
            .then(updateDisplay);
        }
        // 2. 获取热门文章(独立请求)
        if (metrics.hot_pages.length > 0) {
            metrics.hot_pages.forEach(hot => {
                fetch(`/api/hot?site=${metrics.site}&period=${hot.period}&limit=${hot.limit}`)
                    .then(r => r.json())
                    .then(data => {
                        renderHotPages(hot.element, data.hot_pages);
                    });
            });
        }
    }
    function updateDisplay(data) {
        Object.keys(data).forEach(key => {
            if (key === 'pages') {
                // 批量更新
                Object.keys(data.pages).forEach(page => {
                    document.querySelectorAll(`[data-page-id="${page}"]`).forEach(el => {
                        el.textContent = data.pages[page].pv;
                    });
                });
            } else {
                // 单个更新
                document.querySelectorAll(`#ddkk_value_${key}, .ddkk_value_${key}`).forEach(el => {
                    el.textContent = data[key];
                });
            }
        });
    }
    function renderHotPages(container, hotPages) {
        if (!hotPages || hotPages.length === 0) {
            container.innerHTML = '<p>暂无数据</p>';
            return;
        }
        let html = '<ol class="hot-list">';
        hotPages.forEach((item, index) => {
            html += `
                <li>
                    <a href="${item.page}">
                        <span class="rank">${index + 1}</span>
                        <span class="title">${item.page}</span>
                        <span class="pv">${item.pv} 阅读</span>
                    </a>
                </li>
            `;
        });
        html += '</ol>';
        container.innerHTML = html;
    }
    // 分批加载(列表页优化)
    function batchLoad() {
        let elements = Array.from(document.querySelectorAll('[data-page-id]'));
        let batchSize = 20;
        for (let i = 0; i < elements.length; i += batchSize) {
            let batch = elements.slice(i, i + batchSize);
            let pages = batch.map(el => el.dataset.pageId);
            setTimeout(() => {
                fetch('/api/batch', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({site: location.host, pages: pages})
                })
                .then(r => r.json())
                .then(updateDisplay);
            }, i / batchSize * 100); // 每批延迟 100ms
        }
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', fetchStats);
    } else {
        fetchStats();
    }
})();

Redis 持久化配置

redis.conf:

appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
save 900 1
save 300 10
save 60 10000
maxmemory 2gb
maxmemory-policy allkeys-lru

项目结构

ddkk-stats/
├── main.go              # 入口(支持多命令:server, restore-from-mysql)
├── config/
│   └── config.go        # 配置(Redis/MySQL)
├── handler/
│   ├── stats.go         # 统计接口
│   ├── batch.go         # 批量查询
│   ├── hot.go           # 热门文章接口
│   └── restore.go       # MySQL → Redis 恢复逻辑 ⭐️
├── service/
│   ├── redis.go         # Redis 操作
│   ├── mysql.go         # MySQL 操作
│   ├── queue.go         # 异步队列
│   └── hot.go           # 热门排行逻辑
├── model/
│   └── types.go         # 数据结构(包含 RestoreOptions, RestoreResult 等)
├── utils/
│   └── helper.go        # 工具函数
├── static/
│   └── ddkk-stats.js    # 前端脚本
└── deploy/
    ├── ddkk-stats.service     # Systemd
    ├── nginx.conf             # Nginx 配置
    ├── backup.sh              # 备份脚本
    └── restore-from-mysql.sh  # 恢复脚本 ⭐️

Nginx 配置

location /api/stats {
    proxy_pass http://127.0.0.1:8360;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/batch {
    proxy_pass http://127.0.0.1:8360;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/hot {
    proxy_pass http://127.0.0.1:8360;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    # 热门文章可以缓存 1 分钟
    add_header Cache-Control "public, max-age=60";
}
location /api/restore {
    proxy_pass http://127.0.0.1:8360;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    # 数据恢复接口,仅允许本地访问(安全)
    allow 127.0.0.1;
    deny all;
}
location /ddkk-stats.js {
    proxy_pass http://127.0.0.1:8360;
    add_header Cache-Control "public, max-age=3600";
}

Systemd 服务配置

deploy/ddkk-stats.service:

[Unit]
Description=DDKK Stats Service
After=network.target redis.service mysql.service
[Service]
Type=simple
User=www
WorkingDirectory=/opt/ddkk-stats
ExecStart=/opt/ddkk-stats/ddkk-stats
Restart=always
RestartSec=5
Environment="REDIS_ADDR=localhost:6379"
Environment="MYSQL_DSN=root:password@tcp(localhost:3306)/ddkk_stats?charset=utf8mb4&parseTime=True"
Environment="PORT=8360"
[Install]
WantedBy=multi-user.target

部署命令:

sudo cp deploy/ddkk-stats.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable ddkk-stats
sudo systemctl start ddkk-stats

环境变量

# Redis 配置
REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0
# MySQL 配置
MYSQL_DSN=root:password@tcp(localhost:3306)/ddkk_stats?charset=utf8mb4&parseTime=True
# 服务配置
PORT=8360
LOG_LEVEL=info
# 队列配置
QUEUE_SIZE=10000
BATCH_SIZE=100
BATCH_INTERVAL=5s

性能指标

指标目标
响应时间(单次统计)< 5ms
响应时间(批量20个)< 50ms
并发能力10000+ QPS
Redis 内存< 2GB
MySQL 写入延迟< 5秒
队列处理速度> 1000条/秒

测试要点

功能测试

  1. 单页统计:访问文章,PV +1,返回正确
  2. UV 去重:同一用户多次访问,UV 不增加
  3. 批量查询:20个页面,< 50ms
  4. 域名归一化:www.ddkk.com 和 ddkk.com 数据一致
  5. 异步落库:MySQL 写入失败不影响统计
  6. 在线人数:5分钟无访问后清零
  7. 热门排行
    • 访问文章后,各周期排行榜正确更新
    • TOP10、TOP20、TOP50 参数正确返回
    • 不同周期(today/week/month/all)数据隔离
    • 自动过期机制正常(today 2天,week 8天)
  8. 数据恢复功能
    • 从 MySQL 恢复整站 PV/UV,与历史一致
    • 恢复所有文章阅读量,数据准确
    • 恢复历史最热排行,排序正确
    • 恢复过程不影响正常统计服务
    • 恢复后新增访问继续累加,不重复
    • 大数据量恢复(10万+文章)性能测试

性能测试

# 单接口压测
ab -n 10000 -c 100 http://localhost:8360/api/stats
# 批量查询压测
ab -n 1000 -c 50 -p batch.json http://localhost:8360/api/batch

数据一致性测试

  1. Redis 重启后数据不丢失
  2. MySQL 宕机不影响统计
  3. 队列满时优雅降级
  4. 并发写入数据正确

关键注意事项

  1. IP 获取优先级:X-Real-IP > X-Forwarded-For > RemoteAddr
  2. 跨域设置:允许前端跨域请求
  3. 错误处理:Redis 失败返回默认值,不报错
  4. 日志记录:记录异常情况(队列满、MySQL 写入失败)
  5. 内存控制:Redis maxmemory + LRU 策略
  6. 数据保留策略
    • 永久保留:整站PV/UV、文章阅读量(page_pv)、历史最热排行
    • 定期清理:90天前的按日期统计数据(已归档到 MySQL)
    • 自动过期:在线心跳(5分钟)、UV去重集合(30天)、周期热门(2-32天)
  7. 批量限制:单次批量查询最多 100 个页面
  8. 并发安全:使用 Redis 原子操作(INCR、SADD)
  9. 优雅关闭:收到 SIGTERM 信号时,等待队列处理完再退出
  10. 热重载:支持配置热更新,无需重启服务
  11. 热门排行更新时机:每次 page_pv 计数时同步更新所有周期排行榜
  12. 热门排行限制:单次查询最多返回 100 条,防止数据量过大
  13. 数据恢复安全
    • 恢复接口只允许本地访问(127.0.0.1)
    • 恢复时不清空 Redis,采用覆盖模式
    • 大数据量恢复使用批量操作(Pipeline)
    • 恢复过程记录详细日志
  14. 数据一致性:恢复时使用 MySQL 累计数据,确保与历史记录一致

监控告警

监控指标

# Redis
- 内存使用率
- 持久化状态
- 连接数
# MySQL
- 连接数
- 慢查询
- 写入延迟
# 服务
- QPS
- 响应时间
- 错误率
- 队列长度

告警规则

- Redis 内存 > 80% → 警告
- MySQL 写入延迟 > 10s → 警告
- 队列长度 > 5000 → 警告
- 错误率 > 1% → 紧急

开发优先级

P0(核心功能,1周)

  • [x] 基础统计(site_pv, page_pv)
  • [x] 批量查询
  • [x] 前端 JS 自动扫描
  • [x] Redis 持久化配置

P1(核心功能,1周)

  • [x] UV 去重
  • [x] 今日统计
  • [x] 在线人数
  • [x] 异步落库
  • [x] MySQL → Redis 恢复机制 ⭐️

P2(进阶功能,1周)

  • [x] 热门排行(含参数化)
  • [x] 定时归档
  • [x] 历史数据查询
  • [x] 命令行工具(含恢复功能)

P3(优化,持续)

  • [ ] 监控告警
  • [ ] 性能优化
  • [ ] 可视化面板

前端使用示例

1. 文章详情页

<!DOCTYPE html>
<html>
<head>
    <title>文章标题</title>
</head>
<body>
    <article>
        <h1>文章标题</h1>
        <div class="meta">
            阅读量:<span id="ddkk_value_page_pv">0</span>
        </div>
        <p>文章内容...</p>
    </article>
    <!-- 引入统计脚本 -->
    <script src="/ddkk-stats.js"></script>
</body>
</html>

2. 首页(整站统计)

<footer>
    <div class="stats">
        <p>本站总访问量:<span id="ddkk_value_site_pv">0</span></p>
        <p>今日访问量:<span id="ddkk_value_site_pv_today">0</span></p>
        <p>当前在线:<span id="ddkk_value_site_online">0</span></p>
    </div>
</footer>
<script src="/ddkk-stats.js"></script>

3. 文章列表页(批量查询)

<div class="article-list">
    <div class="item">
        <h3><a href="/article/1.html">文章标题1</a></h3>
        <span>阅读:<span class="ddkk_value_page_pv" data-page-id="/article/1.html">0</span></span>
    </div>
    <div class="item">
        <h3><a href="/article/2.html">文章标题2</a></h3>
        <span>阅读:<span class="ddkk_value_page_pv" data-page-id="/article/2.html">0</span></span>
    </div>
    <!-- 更多文章... -->
</div>
<script src="/ddkk-stats.js"></script>

4. 热门文章(侧边栏)

本周热门 TOP10:

<div class="sidebar">
    <h3>本周热门</h3>
    <div id="ddkk_hot_pages_week" data-limit="10">
        <!-- JS 自动填充 -->
    </div>
</div>

今日热门 TOP20:

<div class="sidebar">
    <h3>今日热门</h3>
    <div id="ddkk_hot_pages_today" data-limit="20">
        <!-- JS 自动填充 -->
    </div>
</div>

历史最热 TOP50:

<div class="sidebar">
    <h3>历史最热</h3>
    <div id="ddkk_hot_pages_all" data-limit="50">
        <!-- JS 自动填充 -->
    </div>
</div>

手动调用 API:

// 获取本周热门 TOP 20
fetch('/api/hot?site=ddkk.com&period=week&limit=20')
    .then(r => r.json())
    .then(data => {
        data.hot_pages.forEach((item, index) => {
            console.log(`${index + 1}. ${item.page} - ${item.pv} 次`);
        });
    });
<script src="/ddkk-stats.js"></script>

数据备份与恢复

数据分类与保留策略

永久保留数据(核心数据,不删除)

ddkk:site:pv:{site}           → 整站总PV
ddkk:site:uv:{site}           → 整站总UV
ddkk:page:pv:{site}:{page}    → 文章阅读量(重要!)
ddkk:hot:{site}:all           → 历史最热排行

说明:这些数据必须永久保留在 Redis,每天异步备份到 MySQL

临时统计数据(可定期清理)

ddkk:site:pv:{site}:{YYYYMMDD}     → 某天的PV(90天后删除)
ddkk:site:uv:set:{site}:{YYYYMMDD} → 某天UV去重(30天后删除)
ddkk:online:{site}:{hash}          → 在线心跳(5分钟过期)

说明:已归档到 MySQL 的历史数据,可以从 Redis 清理节省内存

周期数据(自动过期)

ddkk:hot:{site}:today   → 今日热门(2天过期)
ddkk:hot:{site}:week    → 本周热门(8天过期)
ddkk:hot:{site}:month   → 本月热门(32天过期)

自动备份脚本

deploy/backup.sh:

#!/bin/bash
BACKUP_DIR=/data/backup/ddkk-stats
DATE=$(date +%Y%m%d_%H%M%S)
# 备份 Redis
redis-cli --rdb $BACKUP_DIR/redis_$DATE.rdb
# 备份 MySQL
mysqldump -u root -p ddkk_stats > $BACKUP_DIR/mysql_$DATE.sql
# 清理 7 天前的备份
find $BACKUP_DIR -name "*.rdb" -mtime +7 -delete
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete

Crontab 配置:

# 每天凌晨 2 点备份
0 2 * * * /opt/ddkk-stats/deploy/backup.sh

恢复流程

场景1:Redis 正常重启(自动恢复)

# Redis 重启后自动加载 dump.rdb + appendonly.aof
systemctl restart redis

说明:数据完整,无需手动操作


场景2:Redis 文件损坏(从备份恢复)

Redis 恢复:

# 停止 Redis
systemctl stop redis
# 恢复数据
cp /data/backup/ddkk-stats/redis_20260113.rdb /var/lib/redis/dump.rdb
# 启动 Redis
systemctl start redis

说明:恢复到备份时间点,可能丢失几小时数据


场景3:Redis 完全丢失(从 MySQL 恢复)⭐️

这是核心场景!MySQL 作为恢复源重建 Redis

恢复脚本:deploy/restore-from-mysql.sh

#!/bin/bash
echo "开始从 MySQL 恢复 Redis 数据..."
# 1. 清空 Redis(谨慎操作)
redis-cli FLUSHALL
# 2. 调用 Go 程序恢复数据
/opt/ddkk-stats/ddkk-stats restore-from-mysql
echo "恢复完成!"

Go 实现(handler/restore.go):

func RestoreFromMySQL() {
    fmt.Println("从 MySQL 恢复数据到 Redis...")
    // 1. 恢复整站统计(从 daily_stats 表累加)
    var totalPV, totalUV int64
    db.QueryRow("SELECT SUM(pv), SUM(uv) FROM daily_stats WHERE site = 'ddkk.com'").Scan(&totalPV, &totalUV)
    rdb.Set(ctx, "ddkk:site:pv:ddkk.com", totalPV, 0)
    rdb.Set(ctx, "ddkk:site:uv:ddkk.com", totalUV, 0)
    fmt.Printf("✓ 整站统计恢复:PV=%d, UV=%d\n", totalPV, totalUV)
    // 2. 恢复文章阅读量(从 page_stats 表累加)
    rows, _ := db.Query(`
        SELECT page, SUM(pv) as total_pv 
        FROM page_stats 
        WHERE site = 'ddkk.com' 
        GROUP BY page
    `)
    defer rows.Close()
    count := 0
    for rows.Next() {
        var page string
        var pv int64
        rows.Scan(&page, &pv)
        key := fmt.Sprintf("ddkk:page:pv:ddkk.com:%s", page)
        rdb.Set(ctx, key, pv, 0)
        count++
    }
    fmt.Printf("✓ 文章阅读量恢复:%d 篇文章\n", count)
    // 3. 恢复历史最热排行(从 page_stats 累加)
    rows2, _ := db.Query(`
        SELECT page, SUM(pv) as total_pv 
        FROM page_stats 
        WHERE site = 'ddkk.com' 
        GROUP BY page 
        ORDER BY total_pv DESC 
        LIMIT 100
    `)
    defer rows2.Close()
    for rows2.Next() {
        var page string
        var pv int64
        rows2.Scan(&page, &pv)
        rdb.ZAdd(ctx, "ddkk:hot:ddkk.com:all", &redis.Z{
            Score:  float64(pv),
            Member: page,
        })
    }
    fmt.Printf("✓ 历史最热排行恢复完成\n")
    // 4. 恢复近期日期统计(最近 30 天)
    for i := 0; i < 30; i++ {
        date := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
        dateKey := strings.ReplaceAll(date, "-", "")
        var pv, uv int64
        db.QueryRow(`
            SELECT pv, uv FROM daily_stats 
            WHERE site = 'ddkk.com' AND date = ?
        `, date).Scan(&pv, &uv)
        if pv > 0 {
            rdb.Set(ctx, fmt.Sprintf("ddkk:site:pv:ddkk.com:%s", dateKey), pv, 90*24*time.Hour)
            rdb.Set(ctx, fmt.Sprintf("ddkk:site:uv:ddkk.com:%s", dateKey), uv, 90*24*time.Hour)
        }
    }
    fmt.Printf("✓ 近 30 天日期统计恢复完成\n")
    fmt.Println("\n所有数据恢复完成!")
}

使用方法:

# 手动执行恢复
/opt/ddkk-stats/ddkk-stats restore-from-mysql
# 或通过脚本
bash /opt/ddkk-stats/deploy/restore-from-mysql.sh

恢复时间估算:

  • 10万篇文章 → 约 2-5 分钟
  • 100万条日志 → 约 10-20 分钟

完整恢复示例:

# 1. 检查 MySQL 数据
mysql> SELECT COUNT(*) FROM page_stats WHERE site = 'ddkk.com';
# 输出:85320(8万多篇文章)
mysql> SELECT SUM(pv) FROM daily_stats WHERE site = 'ddkk.com';
# 输出:1234567(总PV)
# 2. 执行恢复
$ ./ddkk-stats restore-from-mysql --site=ddkk.com
# 输出:
# [2026-01-13 10:30:00] 开始从 MySQL 恢复数据...
# [2026-01-13 10:30:01] ✓ 整站统计恢复:PV=1234567, UV=456789
# [2026-01-13 10:30:15] ✓ 文章阅读量恢复:85320 篇
# [2026-01-13 10:30:18] ✓ 历史最热排行恢复:100 篇
# [2026-01-13 10:30:20] ✓ 近30天统计恢复:30 天
# [2026-01-13 10:30:20] 恢复完成!耗时:20秒
# 3. 验证恢复结果
$ redis-cli GET "ddkk:site:pv:ddkk.com"
# 输出:"1234567"
$ redis-cli GET "ddkk:page:pv:ddkk.com:/article/123.html"
# 输出:"156"
$ redis-cli ZREVRANGE "ddkk:hot:ddkk.com:all" 0 4 WITHSCORES
# 输出:TOP 5 热门文章及其PV
# 4. 测试新增访问
$ curl -X POST http://localhost:8360/api/stats \
  -d '{"site":"ddkk.com","page":"/article/123.html","fields":["page_pv"],"action":"count"}'
# 输出:{"page_pv":157}  ✓ 正确递增
# 5. 恢复成功!✓

场景4:部分数据丢失(增量恢复)

只恢复某篇文章的数据:

redis-cli SET "ddkk:page:pv:ddkk.com:/article/123.html" 156

批量恢复某个时间段:

-- 查询 MySQL
SELECT page, SUM(pv) FROM page_stats 
WHERE site = 'ddkk.com' AND date >= '2026-01-01' 
GROUP BY page;
-- 写回 Redis(通过脚本)

MySQL 恢复(较少用)

mysql -u root -p ddkk_stats < /data/backup/ddkk-stats/mysql_20260113.sql

说明:MySQL 主要用于恢复 Redis,自身备份用于容灾


常见问题

Q1: 统计数据不显示?

检查:

  1. 检查 JS 是否加载成功:curl http://your-domain/ddkk-stats.js
  2. 查看浏览器控制台是否有错误
  3. 检查 Redis 是否正常:redis-cli ping
  4. 检查服务是否启动:systemctl status ddkk-stats

Q2: UV 统计不准确?

原因:

  • 用户清除 Cookie
  • 使用隐私模式
  • 更换设备/浏览器

解决: UV 基于 IP+UA+日期,相对准确但非绝对

Q3: 批量查询很慢?

检查:

  1. Redis 是否配置正确
  2. 是否使用了 MGET 批量查询
  3. 网络延迟
  4. 批量大小(建议不超过100)

Q4: MySQL 写入失败?

检查:

  1. 查看队列是否满:监控队列长度
  2. MySQL 连接是否正常
  3. 磁盘空间是否充足
  4. 查看日志:journalctl -u ddkk-stats -f

Q5: 热门排行数据不准确?

原因:

  • today 排行:每天自动过期(保留2天)
  • week 排行:每周一重置
  • month 排行:每月1号重置

如何查看:

# 查看本周热门
redis-cli ZREVRANGE ddkk:hot:ddkk.com:week 0 9 WITHSCORES
# 查看历史最热
redis-cli ZREVRANGE ddkk:hot:ddkk.com:all 0 19 WITHSCORES

Q6: 如何自定义热门排行条数?

前端调用:

// 获取 TOP 30
fetch('/api/hot?site=ddkk.com&period=week&limit=30')
// HTML 使用
<div id="ddkk_hot_pages_week" data-limit="30"></div>

Q7: Redis 数据丢失了怎么办?

解决方案:从 MySQL 恢复

# 执行恢复脚本
/opt/ddkk-stats/ddkk-stats restore-from-mysql
# 恢复内容:
# ✓ 整站 PV/UV(从 daily_stats 累加)
# ✓ 所有文章阅读量(从 page_stats 累加)
# ✓ 历史最热排行(从 page_stats 重建)
# ✓ 近 30 天日期统计

Q8: 哪些数据会从 Redis 删除?

永久保留(不删除):

  • 整站总 PV/UV
  • 文章阅读量(page_pv)⭐️ 重点
  • 历史最热排行

定期清理(节省内存):

  • 90天前的按日期统计(已备份到 MySQL)
  • 30天前的 UV 去重集合(只用于去重,不影响展示)

自动过期:

  • 在线心跳(5分钟)
  • 周期热门排行(2-32天)

结论:用户看到的文章阅读量永远不会丢失!

Q9: 如何使用数据恢复功能?

方式1:命令行工具(推荐)

# 完整恢复
/opt/ddkk-stats/ddkk-stats restore-from-mysql --site=ddkk.com
# 只恢复文章阅读量
/opt/ddkk-stats/ddkk-stats restore-from-mysql --site=ddkk.com --only-pages
# 恢复最近7天的数据
/opt/ddkk-stats/ddkk-stats restore-from-mysql --site=ddkk.com --days=7

方式2:HTTP API 调用

curl -X POST http://localhost:8360/api/restore \
  -H "Content-Type: application/json" \
  -d '{
    "source": "mysql",
    "site": "ddkk.com",
    "options": {
      "restore_site_stats": true,
      "restore_page_stats": true,
      "restore_hot_pages": true,
      "days_back": 30
    }
  }'

方式3:Web 管理界面(可选)

访问:http://localhost:8360/admin/restore
输入站点:ddkk.com
选择恢复选项
点击"开始恢复"

Q10: 恢复数据需要多长时间?

性能预估: | 数据量 | 恢复时间 | 说明 | |--------|----------|------| | 1万篇文章 | 10-30秒 | 小型站点 | | 10万篇文章 | 2-5分钟 | 中型站点 | | 100万篇文章 | 10-30分钟 | 大型站点 |

优化措施:

  • 使用 Redis Pipeline 批量写入
  • 多协程并发处理
  • 恢复时不阻塞正常统计服务

最佳实践建议

1. 数据恢复最佳实践

定期演练恢复流程:

# 每月测试一次恢复流程(使用测试环境)
1. 备份当前 Redis 数据
2. 执行恢复命令
3. 验证数据一致性
4. 记录恢复时间

恢复场景优先级:

  • 紧急恢复:Redis 完全丢失 → 立即执行完整恢复
  • 增量恢复:部分数据异常 → 只恢复异常部分
  • 数据校验:定期对比 Redis 和 MySQL → 发现不一致及时修正

恢复时机选择:

  • 优先选择低峰期(凌晨 2-5 点)
  • 大数据量恢复建议分批执行
  • 恢复期间监控服务状态

2. 数据一致性保障

双写确认机制:

用户访问 → Redis +1(立即) → 队列记录 → MySQL 备份(异步)
                ↓
            立即返回给用户

定期校验任务(每周执行):

// 对比 Redis 和 MySQL 的整站统计
redisPV := RedisGet("ddkk:site:pv:ddkk.com")
mysqlPV := MySQLSum("SELECT SUM(pv) FROM daily_stats")
if abs(redisPV - mysqlPV) > 1000 {
    // 告警:数据不一致,需要检查
    SendAlert("数据不一致检测")
}

3. 性能优化建议

Redis 优化:

  • 使用 Pipeline 批量操作(恢复时)
  • 合理设置 maxmemory 和淘汰策略
  • 定期清理过期的临时数据

MySQL 优化:

  • page_stats 表按 site + page 建索引
  • 定期归档超过1年的数据到历史表
  • 使用批量插入提升写入性能

批量查询优化:

  • 前端分批请求(每批20个)
  • 后端使用 MGET 批量读取
  • 热门文章数据添加缓存(1分钟)

4. 监控告警配置

关键指标监控:

- Redis 内存使用率 > 80% → 警告
- MySQL 队列长度 > 5000 → 警告
- 数据恢复失败 → 紧急
- Redis 写入失败率 > 0.1% → 警告
- Redis 和 MySQL 数据差异 > 1% → 告警

附录:快速开始

完整部署流程(30分钟)

# 1. 安装依赖
sudo yum install redis mysql -y
systemctl start redis mysql
# 2. 创建数据库
mysql -u root -p << EOF
CREATE DATABASE ddkk_stats;
USE ddkk_stats;
-- 执行表结构 SQL(见上文)
EOF
# 3. 配置 Redis 持久化
cat >> /etc/redis/redis.conf << EOF
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
EOF
systemctl restart redis
# 4. 编译部署
cd ddkk-stats
go build -o ddkk-stats
sudo cp ddkk-stats /opt/ddkk-stats/
sudo cp deploy/ddkk-stats.service /etc/systemd/system/
# 5. 启动服务
sudo systemctl start ddkk-stats
sudo systemctl enable ddkk-stats
# 6. 配置 Nginx 反向代理(见上文)
# 7. 测试
curl http://localhost:8360/api/stats \
  -H "Content-Type: application/json" \
  -d '{"site":"ddkk.com","page":"/test","fields":["page_pv"],"action":"count"}'
# 8. 完成!✓

技术支持: 遇到问题查看日志 journalctl -u ddkk-stats -f