使用 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 + Gin | 1.21+ |
| 缓存 | Redis | 7.0+ |
| 数据库 | MySQL | 8.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 作用:
- 异步备份:Redis 数据异步写入 MySQL(不影响性能)
- 历史查询:查询历史统计数据和趋势
- 恢复源: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
}
}
使用场景:
- Redis 数据完全丢失
- 迁移服务器
- 数据校验和修复
- 灾难恢复
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条/秒 |
测试要点
功能测试
- 单页统计:访问文章,PV +1,返回正确
- UV 去重:同一用户多次访问,UV 不增加
- 批量查询:20个页面,< 50ms
- 域名归一化:www.ddkk.com 和 ddkk.com 数据一致
- 异步落库:MySQL 写入失败不影响统计
- 在线人数:5分钟无访问后清零
- 热门排行:
- 访问文章后,各周期排行榜正确更新
- TOP10、TOP20、TOP50 参数正确返回
- 不同周期(today/week/month/all)数据隔离
- 自动过期机制正常(today 2天,week 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
数据一致性测试
- Redis 重启后数据不丢失
- MySQL 宕机不影响统计
- 队列满时优雅降级
- 并发写入数据正确
关键注意事项
- IP 获取优先级:X-Real-IP > X-Forwarded-For > RemoteAddr
- 跨域设置:允许前端跨域请求
- 错误处理:Redis 失败返回默认值,不报错
- 日志记录:记录异常情况(队列满、MySQL 写入失败)
- 内存控制:Redis maxmemory + LRU 策略
- 数据保留策略:
- 永久保留:整站PV/UV、文章阅读量(page_pv)、历史最热排行
- 定期清理:90天前的按日期统计数据(已归档到 MySQL)
- 自动过期:在线心跳(5分钟)、UV去重集合(30天)、周期热门(2-32天)
- 批量限制:单次批量查询最多 100 个页面
- 并发安全:使用 Redis 原子操作(INCR、SADD)
- 优雅关闭:收到 SIGTERM 信号时,等待队列处理完再退出
- 热重载:支持配置热更新,无需重启服务
- 热门排行更新时机:每次 page_pv 计数时同步更新所有周期排行榜
- 热门排行限制:单次查询最多返回 100 条,防止数据量过大
- 数据恢复安全:
- 恢复接口只允许本地访问(127.0.0.1)
- 恢复时不清空 Redis,采用覆盖模式
- 大数据量恢复使用批量操作(Pipeline)
- 恢复过程记录详细日志
- 数据一致性:恢复时使用 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: 统计数据不显示?
检查:
- 检查 JS 是否加载成功:
curl http://your-domain/ddkk-stats.js - 查看浏览器控制台是否有错误
- 检查 Redis 是否正常:
redis-cli ping - 检查服务是否启动:
systemctl status ddkk-stats
Q2: UV 统计不准确?
原因:
- 用户清除 Cookie
- 使用隐私模式
- 更换设备/浏览器
解决: UV 基于 IP+UA+日期,相对准确但非绝对
Q3: 批量查询很慢?
检查:
- Redis 是否配置正确
- 是否使用了 MGET 批量查询
- 网络延迟
- 批量大小(建议不超过100)
Q4: MySQL 写入失败?
检查:
- 查看队列是否满:监控队列长度
- MySQL 连接是否正常
- 磁盘空间是否充足
- 查看日志:
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