代理服务的数据层:SQLite 用户认证、流量累计与小时趋势

这是 Relay Observatory 代理项目实现系列的第三篇。完整代码放在 GitHub

前两篇完成了 HTTP/HTTPS 正向代理SOCKS5 三种命令。当用户、密码和流量都只存在内存时,进程一重启,账号和统计就全部丢失。

这一篇解决数据层问题:如何用 SQLite 持久化用户、认证、分协议流量总量和 24 小时趋势。

为什么单机代理适合 SQLite

这个场景具有典型的 SQLite 特征:

  • 单实例服务;
  • 写入来自连接结束事件,频率有限;
  • 后台查询以聚合为主;
  • 希望只部署一个可执行文件和一个数据文件;
  • 不需要多机共享写数据库。

如果为了几十个用户和每秒少量统计写入部署 PostgreSQL,运维复杂度会远大于收益。

SQLite 的边界也很明确:它适合单机单写,不适合把数据库文件放在 NFS 上让多台代理同时写。

数据模型:总量和趋势分开

用户表:

CREATE TABLE users (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    username      TEXT NOT NULL UNIQUE COLLATE NOCASE,
    password_hash BLOB NOT NULL,
    role          TEXT NOT NULL DEFAULT 'user'
                  CHECK (role IN ('admin', 'user')),
    enabled       INTEGER NOT NULL DEFAULT 1
                  CHECK (enabled IN (0, 1)),
    created_at    TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

设计点:

  • 用户名使用 COLLATE NOCASE,避免 Alicealice 成为两个账号;
  • 密码只保存 bcrypt hash;
  • enabled 用于立即阻止新连接;
  • 管理员和代理用户复用同一张表。

流量总量表:

CREATE TABLE traffic_totals (
    user_id          INTEGER NOT NULL,
    protocol         TEXT NOT NULL,
    uploaded_bytes   INTEGER NOT NULL DEFAULT 0,
    downloaded_bytes INTEGER NOT NULL DEFAULT 0,
    requests         INTEGER NOT NULL DEFAULT 0,
    updated_at       TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id, protocol),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

小时趋势表:

CREATE TABLE traffic_hourly (
    user_id          INTEGER NOT NULL,
    protocol         TEXT NOT NULL,
    bucket           TEXT NOT NULL,
    uploaded_bytes   INTEGER NOT NULL DEFAULT 0,
    downloaded_bytes INTEGER NOT NULL DEFAULT 0,
    requests         INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (user_id, protocol, bucket),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

为什么不只保留明细日志?

如果每个连接都插入明细,后台每次打开都要扫描大量历史记录做 GROUP BY。当前需求只关心累计值和小时曲线,直接维护聚合表更便宜。

如果未来需要审计单次连接,再增加 append-only 明细表,而不是让当前查询承担不需要的成本。

SQLite 连接参数

启动时执行:

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;

作用:

PRAGMA作用
journal_mode=WAL读查询不必等待写事务完全结束
foreign_keys=ON删除用户时级联删除流量,避免孤儿数据
busy_timeout=5000遇到短暂写锁时等待,而不是立即报 database is locked

项目使用 modernc.org/sqlite,它是纯 Go 驱动,不要求部署环境安装 CGO 工具链。

当前规模下设置单连接:

db.SetMaxOpenConns(1)

SQLite 只有一个写者。单连接能让行为更容易推导,也避免 :memory: 测试因为连接池创建出多个独立数据库。

当读压力明显增加时,可以使用多个连接,但要确保每个连接都正确设置连接级 PRAGMA。

用 bcrypt 存储密码

创建用户:

hash, err := bcrypt.GenerateFromPassword(
    []byte(password),
    bcrypt.DefaultCost,
)

_, err = db.Exec(`
    INSERT INTO users(username, password_hash, role)
    VALUES(?, ?, ?)
`, username, hash, role)

认证时只查询启用用户:

err := db.QueryRow(`
    SELECT id, username, role, password_hash
    FROM users
    WHERE username = ? COLLATE NOCASE
      AND enabled = 1
`, username).Scan(&id, &name, &role, &hash)

if err != nil ||
   bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil {
    return Identity{}, false
}

这里故意把“用户不存在”和“密码错误”统一成认证失败,避免向外部泄露用户名是否存在。

统一 Authenticator,网络层不依赖 SQLite

HTTP 和 SOCKS5 不应该导入 database/sql。它们只依赖接口:

type Authenticator interface {
    Authenticate(username, password string) (Identity, bool)
}

type Identity struct {
    ID       int64
    Username string
    Role     string
}

SQLite 数据库实现这个接口,测试中的内存凭据也实现同一个接口:

HTTP Proxy ----\
SOCKS5 Proxy ----> Authenticator <---- SQLite
Admin Login ----/

这个边界带来两个直接收益:

  1. 网络协议测试不需要真实数据库;
  2. 以后换 PostgreSQL、LDAP 或远程认证服务,不需要修改代理协议代码。

每次流量写入同时维护两张表

流量写入必须保证总量和小时趋势一致,因此使用同一个事务:

tx, err := db.Begin()
if err != nil {
    return err
}
defer tx.Rollback()

// 更新 traffic_totals
// 更新 traffic_hourly

return tx.Commit()

总量使用 UPSERT:

INSERT INTO traffic_totals(
    user_id, protocol,
    uploaded_bytes, downloaded_bytes,
    requests, updated_at
)
VALUES(?, ?, ?, ?, 1, ?)
ON CONFLICT(user_id, protocol) DO UPDATE SET
    uploaded_bytes =
        uploaded_bytes + excluded.uploaded_bytes,
    downloaded_bytes =
        downloaded_bytes + excluded.downloaded_bytes,
    requests = requests + 1,
    updated_at = excluded.updated_at;

这里的 excluded.uploaded_bytes 表示本次准备插入的值。

同一个用户第一次产生 HTTP 流量时创建行,后续请求只做原子累加,不需要先 SELECTUPDATE,也避免读改写竞争。

小时 bucket 如何生成

将 UTC 时间截断到整点:

bucket := time.Now().
    UTC().
    Truncate(time.Hour).
    Format(time.RFC3339)

例如:

2026-08-24T16:37:42Z
=> 2026-08-24T16:00:00Z

小时表主键为:

(user_id, protocol, bucket)

同一用户、同一协议、同一小时的流量会累计到同一行。后台查询最近 24 小时只需要扫描有限行数。

为什么要区分协议

项目记录:

const (
    ProtocolHTTP      = "http"
    ProtocolHTTPS     = "https_connect"
    ProtocolSOCKS     = "socks_connect"
    ProtocolSOCKSBind = "socks_bind"
    ProtocolSOCKSUDP  = "socks_udp"
)

总量仍然可以跨协议求和,但保留 protocol 维度后,后台能够回答:

  • HTTP 与 SOCKS5 谁占用更多流量;
  • UDP 中继是否突然增长;
  • BIND 是否真的有人使用;
  • 某个协议的请求数与字节数是否异常。

如果一开始就只存用户总量,这些信息以后无法恢复。

后台聚合查询

总量:

SELECT
    COALESCE(SUM(uploaded_bytes), 0),
    COALESCE(SUM(downloaded_bytes), 0),
    COALESCE(SUM(requests), 0)
FROM traffic_totals;

协议占比:

SELECT
    protocol,
    SUM(uploaded_bytes),
    SUM(downloaded_bytes),
    SUM(requests)
FROM traffic_totals
GROUP BY protocol;

最近 24 小时趋势:

SELECT
    bucket,
    SUM(uploaded_bytes),
    SUM(downloaded_bytes),
    SUM(requests)
FROM traffic_hourly
WHERE bucket >= ?
GROUP BY bucket
ORDER BY bucket;

用户排行则把 userstraffic_totals 左连接。使用 LEFT JOIN 是为了让还没有流量的新用户也能出现在管理列表中。

旧 .env 用户如何迁移

原始版本使用:

PROXY_USERS=alice:strong-secret,bob:strong-password

升级后启动程序会:

  1. 解析旧用户列表;
  2. 查询 SQLite 是否已有同名用户;
  3. 不存在则 bcrypt 后插入;
  4. 已存在则跳过,不覆盖后台修改过的密码。

这类迁移应该是幂等的。服务每次启动都执行也不会改变已有账户。

完成迁移后,PROXY_USERS 只承担首次导入,用户生命周期全部由后台管理。

同步写入的边界

当前实现在线程结束时同步写 SQLite,优点是简单、统计不易丢失。

但如果代理发展到每秒数千个短连接,单写者会成为瓶颈。届时可以改成:

代理连接结束

写入有界 channel

后台协程按 100 条或 1 秒批量事务

需要同时处理:

  • channel 满时是阻塞还是丢统计;
  • 进程退出前如何 flush;
  • 写失败如何重试;
  • 避免重复入账的幂等 ID。

不要在当前负载还很低时提前引入这套复杂度。

小结

代理数据层的关键不是“把 map 换成 SQLite”,而是提前确定:

  • 密码只存 bcrypt hash;
  • 网络层依赖认证接口,不依赖数据库;
  • 总量与趋势在同一事务中更新;
  • 保留协议维度;
  • 用 UPSERT 原子累加;
  • 旧配置迁移必须幂等。

下一篇完成最后一层:把 Vue 3 管理后台编译后嵌入 Go 可执行文件