前言

不蒜子(busuanzi) 是一个广受欢迎的免费网页计数服务,能够轻松实现网站的页面浏览量(PV)和独立访客数(UV)统计。许多基于Hexo的网站都在使用busuanzi来实现访问统计功能,我的博客当前使用的主题是Butterfly,这个主题也是通过集成busuanzi计数器来实现网页计数。

然而,随着使用人数的不断增多,这个由作者为爱发电的免费服务开始出现稳定性问题,访问速度也变得起伏不定。

因此,我决定自己开发一个服务来代替busuanzi,为我的网站提供稳定的计数服务。

技术栈选择

我简单分析了一下,发现busuanzi这个网页计数服务实现起来并不复杂,主要是通过获取访问请求的来源地址来实现PV和UV的计数增长,因此服务的核心就是一个统计接口。

基于这个分析,技术栈的选择也相对明确:

  1. 接口开发:由于我最熟悉Java,所以选择使用SpringBoot3和Java17来实现这个接口。(只实现一个接口的话,用SpringBoot感觉有些重,后续有时间可以考虑用Go重写一遍)

  2. 数据存储:数据库选择很多,但考虑到服务需要记录的数据主要是网站的PV和UV,对于请求IP等信息并非必须存储,所以我选择使用Redis。Redis对于这类频繁操作的数据处理速度非常快,而且配置好Redis的持久化后,也不怎么需要担心数据丢失问题。如果还是不太放心,可以在增加一个定时任务,定时从Redis中读取数据,存储到关系型数据库当中去,但是我感觉没有必要。

服务分析

前端脚本

为了避免修改Butterfly主题中集成busuanzi的代码(以免在后续主题升级时进行频繁修改),需要让自建服务的接口访问方式和数据显示方式与原版busuanzi完全一致。因此,首先需要分析当前busuanzi的具体工作流程。

访问官网,可以看到官网提示的信息,只需要两行代码即可搞定计数。

1
2
<script async src="//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script>
<span id="busuanzi_container_site_pv">本站总访问量<span id="busuanzi_value_site_pv"></span></span>

从这两行代码可以看出,busuanzi通过引入busuanzi.pure.mini.js脚本来修改busuanzi_container_site_pv标签的内容,从而实现计数显示。数据获取流程的核心就在这个脚本中。由于该脚本经过了代码压缩,我使用claude code对其进行了脚本还原,以提高代码可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
/**
* Busuanzi 不蒜子统计脚本
* 用于网站PV/UV统计显示
*/

var bszCaller, bszTag;
// DOM Ready 功能实现
(function() {
var interval,
domReadyCallback,
executeCallbacks,
isReady = false,
readyCallbacks = [];

// 添加ready回调函数
ready = function(callback) {
if (isReady || document.readyState === "interactive" || document.readyState === "complete") {
return callback.call(document);
} else {
readyCallbacks.push(function() {
return callback.call(this);
});
}
return this;
};

// 执行所有等待的回调函数
executeCallbacks = function() {
for (var i = 0, length = readyCallbacks.length; i < length; i++) {
readyCallbacks[i].apply(document);
}
readyCallbacks = [];
};

// DOM准备完成后的处理
domReadyCallback = function() {
if (!isReady) {
isReady = true;
executeCallbacks.call(window);

// 移除事件监听器
if (document.removeEventListener) {
document.removeEventListener("DOMContentLoaded", domReadyCallback, false);
} else if (document.attachEvent) {
document.detachEvent("onreadystatechange", domReadyCallback);
if (window == window.top) {
clearInterval(interval);
interval = null;
}
}
}
};

// 绑定DOM Ready事件
if (document.addEventListener) {
// 现代浏览器
document.addEventListener("DOMContentLoaded", domReadyCallback, false);
} else if (document.attachEvent) {
// IE浏览器兼容
document.attachEvent("onreadystatechange", function() {
if (/loaded|complete/.test(document.readyState)) {
domReadyCallback();
}
});

// IE浏览器特殊处理
if (window == window.top) {
interval = setInterval(function() {
try {
if (!isReady) {
document.documentElement.doScroll("left");
}
} catch (error) {
return;
}
domReadyCallback();
}, 5);
}
}
})();

// JSONP 调用器对象
bszCaller = {
/**
* 发起JSONP请求获取统计数据
* @param {string} url - 请求URL
* @param {function} callback - 回调函数
*/
fetch: function(url, callback) {
// 生成随机回调函数名
var callbackName = "BusuanziCallback_" + Math.floor(Math.random() * 1099511627776);

// 在全局作用域中注册回调函数
window[callbackName] = this.evalCall(callback);

// 替换URL中的回调函数名
url = url.replace("=BusuanziCallback", "=" + callbackName);

// 创建script标签进行JSONP请求
var scriptTag = document.createElement("SCRIPT");
scriptTag.type = "text/javascript";
scriptTag.defer = true;
scriptTag.src = url;
scriptTag.referrerPolicy = "no-referrer-when-downgrade";

// 将script标签添加到head中
document.getElementsByTagName("HEAD")[0].appendChild(scriptTag);
},

/**
* 包装回调函数,添加错误处理
* @param {function} callback - 原始回调函数
* @returns {function} 包装后的回调函数
*/
evalCall: function(callback) {
return function(data) {
ready(function() {
try {
// 执行回调并清理script标签
callback(data);
if (scriptTag && scriptTag.parentElement) {
scriptTag.parentElement.removeChild(scriptTag);
}
} catch (error) {
// 出错时隐藏统计显示
bszTag.hides();
}
});
};
}
};

// 发起统计数据请求
bszCaller.fetch("//busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback", function(data) {
// 更新页面统计数据并显示
bszTag.texts(data);
bszTag.shows();
});

// 页面标签操作对象
bszTag = {
// 支持的统计类型
bszs: ["site_pv", "page_pv", "site_uv"],

/**
* 更新页面中的统计数字
* @param {object} data - 包含统计数据的对象
*/
texts: function(data) {
this.bszs.map(function(type) {
var element = document.getElementById("busuanzi_value_" + type);
if (element) {
element.innerHTML = data[type];
}
});
},

/**
* 隐藏所有统计容器
*/
hides: function() {
this.bszs.map(function(type) {
var container = document.getElementById("busuanzi_container_" + type);
if (container) {
container.style.display = "none";
}
});
},

/**
* 显示所有统计容器
*/
shows: function() {
this.bszs.map(function(type) {
var container = document.getElementById("busuanzi_container_" + type);
if (container) {
container.style.display = "inline";
}
});
}
};

虽然对JavaScript不是很熟悉,但这个脚本逻辑相对清晰,不难读懂。以下是该脚本的核心工作流程:

  1. 等待浏览器DOM准备
  2. DOM准备完成后,发起JSONP请求
  3. 解析请求返回的标签数据,如果请求错误,数据异常就隐藏所有统计容器
  4. 获取到计数的数据后,更新携带有ID为busuanzi_value_busuanzi_container_统计标签的数据,然后修改容器的display属性,从而实现容器和内容的显示

JSONP其实就是GET请求,主要是利用<script>标签可以跨域加载资源的特性,将数据请求伪装成脚本加载,服务器返回的也不是纯数据,而是一个函数调用,从而绕过浏览器的同源策略限制。

前端操作通过这个简单的脚本即可实现,接下来分析后端接口的实现方式。

后端接口

网页计数服务的核心功能是记录URL的UV和PV数据。这些指标又分为站点级别和页面级别,理论上应该有四个数据(但busuanzi不计算页面UV,因此只有三个),这里按四个指标数据计算。下面逐一分析这些数据的计算方法。

PV

PV,即浏览量,根据定义也不难知道计算的方法。当前端访问接口的时候,站点浏览量进行加1,页面浏览量进行加1既可以获得这两个PV数据量。

那么后端接口如何获知是哪个页面发起的请求呢?回头查看busuanzi脚本并未发现页面的相关信息,因此需要观察busuanzi脚本发送的具体HTTP请求信息。

1
2
3
4
5
6
7
8
9
10
11
12
curl ^"https://busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback_12592539439^" ^
-H ^"accept: */*^" ^
-H ^"accept-language: zh-CN,zh;q=0.9^" ^
-H ^"referer: https://yww52.com/^" ^
-H ^"sec-ch-ua: ^\^"Not;A=Brand^\^";v=^\^"99^\^", ^\^"Google Chrome^\^";v=^\^"139^\^", ^\^"Chromium^\^";v=^\^"139^\^"^" ^
-H ^"sec-ch-ua-mobile: ?0^" ^
-H ^"sec-ch-ua-platform: ^\^"Windows^\^"^" ^
-H ^"sec-fetch-dest: script^" ^
-H ^"sec-fetch-mode: no-cors^" ^
-H ^"sec-fetch-site: cross-site^" ^
-H ^"sec-fetch-storage-access: active^" ^
-H ^"user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36^"

经过仔细观察,容易发现请求头中的referer字段记录了当前页面的信息。切换到不同页面时,该字段也会相应变为相应页面的URL。基于这个信息,就可以计算出所需的两个PV数据:

  1. site_pv(站点浏览量):从referer请求头中提取站点Host地址,在Redis中存储一个键值对,并对该值进行递增操作
  2. page_pv(页面浏览量):直接使用referer请求头信息作为键,在Redis中存储键值对,并对该值进行递增操作

UV

UV(独立访客数)的统计重点在于两个关键概念:每日独立访客

对于每日统计,除了存储UV值外,还需要维护一个当日访客集合。当新请求到达时,通过查询该集合可以判断当前访客今日是否已经访问过。若已访问,则UV数据保持不变;若为首次访问,则UV数据递增。

独立访客的识别相对复杂,因为Hexo博客是纯静态页面,难以直接区分不同访客。前面的请求脚本中也未发现访客标识相关数据,于是我推测访客信息应该记录在HTTP请求中。为了对标busuanzi的实现机制,需要进一步分析具体请求。最好重新打开浏览器或清理缓存,以观察UV增长时的请求响应信息。

1
2
3
4
5
content-length 109
content-type application/json
date Sun, 10 Aug 2025 07:55:37 GMT
server nginx/1.14.1
set-cookie busuanziId=8CBB7C5BBCF54C86B90DAD230C7B5275; Path=/; httponly; secure; SameSite=None; Domain=busuanzi.ibruce.info; Secure

从响应请求头可以看出,busuanzi采用了浏览器Cookie机制来识别独立访客。服务端在响应中设置Cookie,浏览器会在后续请求中自动携带该Cookie。通过检测请求中是否包含Cookie,服务端可以判断是否为首次访问。基于这个机制,可以得出UV数据的计算方法:

  1. site_uv(站点每日独立访客数):从referer请求头获取站点Host地址。若请求不包含Cookie,则生成新Cookie并添加至当日站点访客集合,同时返回Cookie并将UV数据递增。若请求已携带Cookie,则不进行任何增长操作
  2. page_uv(页面每日独立访客数):使用referer请求头信息作为页面标识。若请求不包含Cookie,则生成新Cookie并添加至当日页面访客集合,同时返回Cookie并将UV数据递增。若请求已携带Cookie,则不进行任何增长操作

服务实现

接口实现

这里我先展示所有使用到的Redis的键和类型,方便后续查找修改。

host表示站点地址,url表示资源地址,date表示日期。

数据名称Redis键名Redis类型
site_pv(站点浏览量)pv:{host}string
page_pv(页面浏览量)pv:page:{url}string
site_uv(站点每日独立访客数)uv:{host}string
每日站点访客数(缓存7天)uvtime:{date}:{host}set
page_uv(页面每日独立访客数)uv:page:{url}string
每日页面访客数(缓存7天)uvtime:page:{date}:{url}set

对于Redis数据结构的选择,个人认为使用string和set这两种数据结构完全足够了,大部分博客不是什么高流量的网站,string结构的数据很好用而且也很准确,没有多大必要使用hyperloglog或者zset,特别是数据量少的情况。

关于具体的实现,服务分析当中已经写的很明白了,这个服务其实就是一个核心的请求接口,逻辑和代码我就不一一贴出来了,我已经上传到Github`上面了,感兴趣可以自行研究,附带一下仓库地址。

https://github.com/jaslli/busuanzi

优化部分

服务代码除了适配原有的busuanzi的JSONP请求外,还自行进行了一些优化。以下是我进行优化的方向:

  1. 因为后端是自己编写的服务,所以不需要前端使用JSONP的方式进行跨域处理,所以直接使用fetch的GET请求即可。
  2. 原版busuanzi是使用了cookie进行用户管理,我改为了使用浏览器localStorage进行存储管理用户信息,这样数据更加可以精准。

如果需要进行修改,那么前端的请求脚本也需要修改,我根据原有的脚本进行了简单的优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
(function () {
'use strict';

// 配置项
const CONFIG = {
// 统计项名称列表
STATISTICS: ["site_pv", "page_pv", "site_uv", "page_uv"],
// API地址,请根据实际情况修改
API_URL: "https://xxxx.com/busuanzi/api",
// localStorage中存储身份标识的key
LOCALSTORAGE_KEY: "bsz-id",
// busuanzi的元素容器和元素值前缀
ITEM_VALUE_PREFIX: "busuanzi_value_",
ITEM_CONTAINER_PREFIX: "busuanzi_container_",
// 请求超时时间(毫秒)
REQUEST_TIMEOUT: 5000,
// 重试次数
RETRY_COUNT: 3
};

/**
* DOM Ready 检测函数
*
* @param {Function} callback - DOM准备完成后执行的回调函数
*/
function domReady(callback) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback, { once: true });
} else {
// DOM已经准备完成,立即执行
callback();
}
}

/**
* 创建带超时的fetch请求
*
* @param {string} url 请求URL
* @param {object} options fetch选项
* @param {number} timeout 超时时间
* @returns {Promise} fetch Promise
*/
function fetchWithTimeout(url, options, timeout) {
return Promise.race([
fetch(url, options),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), timeout)
)
]);
}

/**
* 保存用户身份标识
* @param {string} userId - 用户身份标识
*/
function saveUserId(userId) {
try {
if (userId && userId !== "") {
localStorage.setItem(CONFIG.LOCALSTORAGE_KEY, userId);
}
} catch (error) {
console.warn('无法保存到localStorage:', error);
}
}

/**
* 获取或生成用户身份标识
* @returns {string|null} 用户身份标识
*/
function getUserId() {
try {
return localStorage.getItem(CONFIG.LOCALSTORAGE_KEY);
} catch (error) {
console.warn('无法访问localStorage:', error);
return null;
}
}

/**
* 隐藏所有统计容器(出错时调用)
*/
function hideStatistics() {
CONFIG.STATISTICS.forEach(item => {
try {
const containerElement = document.getElementById(CONFIG.ITEM_CONTAINER_PREFIX + item);
if (containerElement) {
containerElement.style.display = "none";
}
} catch (error) {
console.warn(`隐藏统计项 ${item} 时出错:`, error);
}
});
}

/**
* 发送统计请求
*
* @param {number} retryCount - 剩余重试次数
*/
function fetchStatistics(retryCount = CONFIG.RETRY_COUNT) {
// 构建请求头
const headers = new Headers();
headers.append("X-Referer", window.location.href);

const userId = getUserId();
if (userId) {
headers.append("Authorization", userId);
}

// 发送超时请求
fetchWithTimeout(CONFIG.API_URL, {
method: "GET",
headers: headers
}, CONFIG.REQUEST_TIMEOUT)
// 请求成功处理
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

// 保存响应头中的身份标识
const bszId = response.headers.get("Authorization");
if (bszId) {
saveUserId(bszId);
}

return response.json();
})
// 成功数据处理
.then(data => {
// 遍历每项统计数据,然后进行更改
CONFIG.STATISTICS.forEach(item => {
try {
// 更新统计数值
const valueElement = document.getElementById(CONFIG.ITEM_VALUE_PREFIX + item);
if (valueElement) {
valueElement.innerHTML = data[item] || 0;
}

// 显示统计容器
const containerElement = document.getElementById(CONFIG.ITEM_CONTAINER_PREFIX + item);
if (containerElement) {
containerElement.style.display = "inline";
}
} catch (error) {
console.warn(`更新统计项 ${item} 时出错:`, error);
}
});
})
.catch(error => {
console.error('busuanzi统计请求失败:', error);

// 重试机制
if (retryCount > 0) {
console.log(`正在重试... 剩余重试次数: ${retryCount}`);
setTimeout(() => {
fetchStatistics(retryCount - 1);
}, 1000); // 1秒后重试
} else {
console.error('所有重试都失败,隐藏统计显示');
hideStatistics();
}
});
}

// 等待DOM准备完成后初始化
domReady(fetchStatistics);

})();

服务部署

这次涉及的服务有两个,一个是SpringBoot的Java服务,一个是Redis服务,如果是要部署的话,就需要分开部署。

这里我使用了docker-compose来进行部署,也可以更加方便,以下是三个所需的配置文件。

  1. 服务的Dockerfile

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    FROM eclipse-temurin:17-jre-alpine
    LABEL maintainer="yww@yww52.com"

    # 设置时区
    ENV TZ=Asia/Shanghai
    RUN apk add tzdata \
    && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
    && echo $TZ > /etc/timezone \
    && apk del tzdata \
    && rm -rf /var/cache/apk/*

    # 定义应用相关环境变量
    ENV JAVA_OPTS="-Xms512m -Xmx2048m -Xmn614m -Xss256k -XX:SurvivorRatio=6 -XX:+UseG1GC -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m"
    ENV APP_HOME=/app

    # 创建非root用户并设置权限
    RUN adduser -D appuser && \
    mkdir -p $APP_HOME && \
    chown -R appuser:appuser $APP_HOME
    USER appuser

    # 设置工作目录和应用目录
    WORKDIR $APP_HOME

    # 复制已打包的 JAR 文件
    COPY busuanzi-1.0.jar $APP_HOME/app.jar

    # 暴露应用端口
    EXPOSE 10010

    # 启动命令(使用环境变量配置 JVM 参数)
    ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
  2. redis的配置文件,这里的路径是容器内的路径,尽量不要修改

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    dir /data/redis
    bind 0.0.0.0
    port 6379

    # 日志文件存放
    logfile "/data/log/redis.log"

    # 启用AOF持久化
    appendonly yes
    appendfilename "appendonly.aof"
    appendfsync everysec

    # RDB持久化配置(可选)
    save 900 1
    save 300 10
    save 60 10000

    # 日志级别
    loglevel notice
  3. 服务编排文件docker-compose.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    version: '3.8'

    services:
    # 这个名字修改了,下面依赖服务名也要修改,springboot配置文件Redis连接服务名也要修改
    busuanzi-redis:
    image: redis:7-alpine
    container_name: busuanzi-redis
    # 可自行修改宿主机端口,同一个网络服务是联通的,不映射端口直接删除没有影响
    ports:
    - "16379:6379"
    volumes:
    # 持久化数据目录映射到宿主机
    - /data/docker/busuanzi/data:/data/redis
    # 日志映射到宿主机
    - /data/docker/busuanzi/log:/data/log
    # Redis配置文件映射到宿主机
    - /data/docker/busuanzi/redis.conf:/usr/local/etc/redis/redis.conf
    command: redis-server /usr/local/etc/redis/redis.conf --appendonly yes
    restart: unless-stopped
    networks:
    - busuanzi-network

    busuanzi:
    build:
    context: .
    dockerfile: Dockerfile
    container_name: busuanzi
    ports:
    - "10010:10010"
    depends_on:
    - busuanzi-redis
    restart: unless-stopped
    networks:
    - busuanzi-network

    networks:
    busuanzi-network:
    driver: bridge

将上述三个配置文件和springboot打包生成的jar包放在一个文件夹中,然后执行docker-compose up -d即可运行整个编排服务。

服务部署好后,自行使用nginx进行反向代理,推荐使用1panel,里面的网站部署使用了OpenResty,直接创建一个反向代理即可。如果安装了1panel,docker相关环境基本全部自动安装好,无需自己手动在安装环境,可以一步到位。

busuanzi数据迁移

不太想放弃原有的busuanzi数据,可以直接将原有的busuanzi数据迁移到服务部署的redis当中,我讲一下比较简单的一个流程。

  1. 获取当前站点的sitemap
  2. 然后根据sitemap获取到站点所有的页面URL
  3. 然后根据每个页面的URL去请求原有的busuanzi接口
  4. 将得到的数据设置到服务的Redis当中即可