不用 Cookie,怎么数今天来了几个人
用 Cloudflare Workers 和 D1 做一个每日访客计数:不写 Cookie,不存 IP,还能告诉访客「你是今天第几位」。从表结构到本地测试,一步步搭起来。
这个网站的主页上,每位今天的访客会在城里点亮一扇窗,你自己那扇标着 window#you,旁边写着你是今天第几位。
要做到这一点,服务器得回答两个问题:今天来了几个人,你是第几个。这篇讲怎么在 Cloudflare Workers 和 D1 上实现它,并且满足两条约束:
- 不在访客的浏览器里写 Cookie,也不用 localStorage 之类的本地存储;
- 数据库里不保存 IP。
读完你可以在自己的站点上搭出同样的接口。需要的基础:会写一点 JavaScript,装好了 Node.js,有一个 Cloudflare 账号。
思路:只在当天有效的摘要
不写 Cookie,服务器就没法给访客发一个「身份证」。能用来区分访客的,只有每个请求自己带来的信息。最常用的两项是:
- IP 地址:Cloudflare 会放在请求头
cf-connecting-ip里; - 浏览器标识:请求头
user-agent,包含浏览器和系统版本。
把这两项和日期、一串盐拼在一起,算一个 SHA-256 摘要:
摘要 = SHA-256(盐 | 日期 | IP | User-Agent)
每一项都有它的作用:
| 组成 | 作用 |
|---|---|
| IP + User-Agent | 区分不同的访客 |
| 日期 | 同一个人第二天再来,摘要就变了,两天的记录关联不起来 |
| 盐 | 只有服务器知道。没有它,别人拿到数据库也没法用常见的 IP 反推摘要 |
数据库里只存摘要。同一天里同一个人再来,算出来的摘要一样,就不重复计数。
准备:建库和配置
先建一个 D1 数据库:
npx wrangler d1 create visits
命令会输出一个 database_id。把它填进 wrangler.jsonc:
{
"name": "my-site",
"main": "worker/index.js",
"compatibility_date": "2026-10-01",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
// 只有接口走 Worker,其余静态文件直接由 Cloudflare 返回
"run_worker_first": ["/api/*"]
},
"d1_databases": [
{ "binding": "DB", "database_name": "visits", "database_id": "<上一步输出的 ID>" }
]
}
run_worker_first 很关键:不写它,所有请求都会先进 Worker,CSS、字体、图片也算调用次数。只把 /api/* 交给 Worker,免费额度能撑得久很多。
然后生成一串盐,存成机密:
openssl rand -hex 32
npx wrangler secret put HASH_SALT
本地开发时,把同一个变量写进项目根目录的 .dev.vars(记得加进 .gitignore):
HASH_SALT=本地随便填一串
第一步:表结构
需要两张表。visitors 每位访客每天一行;daystat 每天只有一行,记今天来了几位。
-- worker/schema.sql CREATE TABLE IF NOT EXISTS visitors ( day TEXT NOT NULL, -- 2026-10-15 hash TEXT NOT NULL, -- 当天的摘要 n INTEGER NOT NULL, -- 这位访客是今天第几位 ts INTEGER NOT NULL, -- 第一次来的时间 PRIMARY KEY (day, hash) ); CREATE TABLE IF NOT EXISTS daystat ( day TEXT PRIMARY KEY, n INTEGER NOT NULL -- 今天一共几位 );
为什么要单独一张 daystat?最直接的写法是每次用 SELECT COUNT(*) FROM visitors WHERE day = ? 现数。但 D1 的免费额度按「读过的行数」计算,当天来了一千人,第一千位访客的那次请求就要读一千行,一天下来的总读取量随访客数的平方增长。daystat 把计数压成一行,每次只读写这一行。
在本地建表:
npx wrangler d1 execute visits --local --file=worker/schema.sql
上线前把 --local 换成 --remote 再跑一次。
第二步:计算摘要
Workers 里可以直接用 Web Crypto:
// worker/index.js
// 「今天」按哪个时区算,决定了一天在什么时候切换。这里用 UTC+8
const TZ = 8 * 3600000;
const today = (t = Date.now()) => new Date(t + TZ).toISOString().slice(0, 10);
async function sha256(text) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
async function visitorHash(req, env, day) {
const ip = req.headers.get('cf-connecting-ip') || '';
const ua = req.headers.get('user-agent') || '';
return (await sha256(`${env.HASH_SALT}|${day}|${ip}|${ua}`)).slice(0, 32);
}
时区要按你的读者来定。这个网站的读者大多在新加坡和马来西亚,所以用 UTC+8;如果直接用 UTC,「今天」会在当地早上八点才切换。
摘要截到前 32 个十六进制字符,也就是 128 位,用来去重足够了,还能少占一点存储。
第三步:计数接口
一次访问分三步:
- 尝试插入一条
(日期, 摘要)记录。主键冲突说明今天来过,什么都不做。 - 插入成功,说明是今天的新访客:把
daystat的计数加一,加完的值就是这位访客的序号,写回刚才那条记录。 - 插入没成功:直接查出之前的序号和今天的总数。
async function visit(req, env) {
const day = today();
const hash = await visitorHash(req, env, day);
const ins = await env.DB
.prepare('INSERT INTO visitors (day, hash, n, ts) VALUES (?1, ?2, 0, ?3) ON CONFLICT (day, hash) DO NOTHING')
.bind(day, hash, Date.now())
.run();
if (ins.meta.changes === 1) {
// 今天第一次来:计数加一,并且在同一条语句里拿到加完的值
const c = await env.DB
.prepare('INSERT INTO daystat (day, n) VALUES (?1, 1) ON CONFLICT (day) DO UPDATE SET n = n + 1 RETURNING n')
.bind(day)
.first();
await env.DB
.prepare('UPDATE visitors SET n = ?3 WHERE day = ?1 AND hash = ?2')
.bind(day, hash, c.n)
.run();
return Response.json({ n: c.n, today: c.n });
}
const row = await env.DB
.prepare('SELECT v.n AS n, s.n AS today FROM visitors v JOIN daystat s ON s.day = v.day WHERE v.day = ?1 AND v.hash = ?2')
.bind(day, hash)
.first();
return Response.json({ n: row?.n ?? 0, today: row?.today ?? 0 });
}
几个细节:
- 判断是不是新访客,用的是
meta.changes。ON CONFLICT DO NOTHING在冲突时不会报错,只是影响行数为 0。比先SELECT再INSERT少一次查询,也不会出现「两个请求同时查到不存在、然后都插入」的问题。 - 序号用
RETURNING取。 「加一」和「读出加完的值」是同一条语句,两位访客同时进来,也不会拿到同一个序号。 - 序号可能暂时是 0。 同一位访客几乎同时发出两个请求时,第二个请求可能在第一个写回序号之前读到
n = 0。前端把 0 当作「没数到」处理就行,下一次请求会拿到正确的值。
最后是入口。写接口只接受本站页面发来的请求,挡掉别的网站伪造的调用:
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (req.method === 'POST' && url.pathname === '/api/visit') {
const origin = req.headers.get('origin');
if (origin && new URL(origin).hostname !== url.hostname) {
return Response.json({ error: 'forbidden' }, { status: 403 });
}
return visit(req, env);
}
return env.ASSETS.fetch(req);
},
};
第四步:前端调用
let visitP;
export function visit() {
visitP ??= fetch('/api/visit', { method: 'POST', keepalive: true })
.then((r) => (r.ok ? r.json() : null))
.then((d) => (d && d.n > 0 ? d : null))
.catch(() => null);
return visitP;
}
visit().then((v) => {
if (!v) return; // 接口不可用,或者还没数到:什么都不显示
console.log(`你是今天第 ${v.n} 位访客,今天一共 ${v.today} 位`);
});
三个要点:
- 同一个页面里只请求一次,结果存成一个 Promise,各处共用;
keepalive: true让访客很快关掉页面时,请求也能发完;- 接口失败时返回
null,界面上就不显示这部分。统计出错,不能影响页面本身。
本地测试
npx wrangler dev
本地所有请求的 IP 都一样,换 User-Agent 就能模拟不同的访客:
curl -X POST -A "visitor-a" http://localhost:8787/api/visit
curl -X POST -A "visitor-b" http://localhost:8787/api/visit
预期结果:visitor-a 无论请求几次,n 都是 1;visitor-b 第一次请求得到 n: 2, today: 2。再查一下数据库,确认里面没有 IP:
npx wrangler d1 execute visits --local --command "SELECT * FROM visitors"
同一个摘要的另一个用处:频率限制
有了「当天的匿名访客标识」,频率限制几乎不用额外的代码。这个网站的留言本和评论,同一位访客每天最多提交 8 次:
CREATE TABLE IF NOT EXISTS ratelimit ( day TEXT NOT NULL, hash TEXT NOT NULL, kind TEXT NOT NULL, -- 'guestbook'、'contact'…… n INTEGER NOT NULL, PRIMARY KEY (day, hash, kind) );
async function limited(req, env, kind, max) {
const day = today();
const hash = await visitorHash(req, env, day);
const row = await env.DB
.prepare('INSERT INTO ratelimit (day, hash, kind, n) VALUES (?1, ?2, ?3, 1) ON CONFLICT (day, hash, kind) DO UPDATE SET n = n + 1 RETURNING n')
.bind(day, hash, kind)
.first();
return row.n > max;
}
// 用法
if (await limited(req, env, 'guestbook', 8)) {
return Response.json({ error: 'too many' }, { status: 429 });
}
还是同一个模式:一条 upsert 加 RETURNING,只读写一行。
这种做法的边界
它是估计,不是精确计数。
- 同一个公司、同一个 Wi-Fi 下用同一种浏览器的几个人,IP 和 User-Agent 都一样,会被算成一位;
- 同一个人换了网络,或者换了浏览器,会被算成两位;
- 手机在 Wi-Fi 和移动网络之间切换,IP 会变,同一天里也可能被算两次。
用来显示「今天大概来了多少人」够用;如果要做精确的转化分析,这个方法不合适。
它不是严格意义上的匿名。 盐在服务器手里。如果你已经知道某人的 IP 和 User-Agent,可以用盐算一遍摘要去对。它能保证的是:数据库里没有 IP,数据库泄露时别人很难反推;浏览器里也没有任何标记。
再进一步:每天换一次盐
如果想连自己也无法事后核对,可以把固定的盐换成每天随机生成的盐,过了当天就删掉:
CREATE TABLE IF NOT EXISTS salts ( day TEXT PRIMARY KEY, salt TEXT NOT NULL );
async function daySalt(env, day) {
await env.DB
.prepare('INSERT INTO salts (day, salt) VALUES (?1, ?2) ON CONFLICT (day) DO NOTHING')
.bind(day, crypto.randomUUID())
.run();
const row = await env.DB.prepare('SELECT salt FROM salts WHERE day = ?1').bind(day).first();
return row.salt;
}
再用一个 Cron Trigger 每天删掉前一天的盐。盐一删,前一天的摘要就再也算不出来了,你自己也一样。
代价是每个请求多一到两次查询。这个网站目前用的是固定的盐加日期,没有做这一步。
小结
- 识别访客:
SHA-256(盐 | 日期 | IP | User-Agent),只存摘要; - 计数:
ON CONFLICT DO NOTHING判断新访客,daystat加RETURNING发序号; - 省额度:每次只读写一两行,静态文件不经过 Worker;
- 摘要还能直接拿来做频率限制;
- 想要更强的隐私,就每天换盐,过期就删。
Comments
- Loading…