~/wen
Hire me

← All notes

不用 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 位,用来去重足够了,还能少占一点存储。

第三步:计数接口

一次访问分三步:

  1. 尝试插入一条 (日期, 摘要) 记录。主键冲突说明今天来过,什么都不做。
  2. 插入成功,说明是今天的新访客:把 daystat 的计数加一,加完的值就是这位访客的序号,写回刚才那条记录。
  3. 插入没成功:直接查出之前的序号和今天的总数。
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;
  • 摘要还能直接拿来做频率限制;
  • 想要更强的隐私,就每天换盐,过期就删。

— Wen