2025-11-02 10:29:34

百万并发抽奖基础版本实现-rust+postgresql

今天是第三天实验这个百万并发抽奖实现。
纯postgresql+rust实现结果:

这次压测的吞吐量约为 3951 QPS(10000 次请求在 2.53s 内完成 = 3951.68 QPS)。延迟分位说明:P50≈63.5ms、P95≈71.3ms、P99≈78.0ms,平均≈
63.9ms,代表单次抽奖接口的响应时延分布。若要更接近真实上限,建议服务和压测都用 release 模式并关闭调试日志后再测。

这次是用 droid 写的。白嫖的,11 号就到期了。赶紧用掉。不知道的同志可以点击链接去白嫖。 https://app.factory.ai/droids

对应插件: https://marketplace.visualstudio.com/items?itemName=Factory.factory-vscode-extension

安装并在 CLI 使用

可以在终端中使用,这是各家 Code Agent 默认支持的模式。

通过一行命令安装 CLI 终端。

MacOS、Linux

curl -fsSL https://app.factory.ai/cli |sh
Windows

irm https://app.factory.ai/cli/windows | iex
安装完毕后,会提示你将 Droid 加入环境变量中。注意,一定要加入,不然后面大概率会很麻烦。

然后,进入你的项目所在目录,然后在终端中输入 droid,就可以开始玩耍了。

实现基本情况

⛬ 完成情况
• 已从当前分支创建并切换到分支 basic-v1,实现 v1
基础后端(Rust + Axum + PostgreSQL)。
• 新增功能:用户注册/登录(JWT)、用户信息与抽奖历史、奖品列表
、抽奖接口(权重法+频率限制+库存原子扣减)、全局中奖历史、管
理员登录与活动/奖品基础管理;提供 /healthz 健康检查。
• 数据库:新增 migrations/0001_init.sql(users/activities/priz
es/lottery_records 表与索引)。
• 配置:通过环境变量 DATABASE_URL, JWT_SECRET, ADMIN_USERNAME,
ADMIN_PASSWORD, SERVER_ADDR;代码已通过构建与格式化(cargo
build / cargo fmt)。

运行示例
• 先执行数据库迁移,再启动:DATABASE_URL=… JWT_SECRET=…
cargo run(默认监听 0.0.0.0:8080)。
• 管理员登录:POST /admin/api/login 获取 token;核心接口见
src/routes/*。

已提交到 basic-v1
分支(包含上述代码与迁移;同时包含之前标记删除的
specs/system-architecture.excalidraw 变更)。

image.png
800行 rust 代码实现这个基本功能 花了26w token.
看了下代码。以我的rust水平也就这个代码质量。虽然有些warning, 但是一次性build通过。代码目录结构也挺好。
image.png
赞的是 migrations都弄好了,我都没提。

sql review

接下来可以看看数据库设计了。

-- users CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, email TEXT NULL, last_lottery_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); -- activities CREATE TABLE IF NOT EXISTS activities ( id UUID PRIMARY KEY, name TEXT NOT NULL, description TEXT NULL, start_time TIMESTAMPTZ NOT NULL, end_time TIMESTAMPTZ NOT NULL, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- prizes CREATE TABLE IF NOT EXISTS prizes ( id UUID PRIMARY KEY, activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT NULL, total_count BIGINT NOT NULL, remaining_count BIGINT NOT NULL, probability INT NOT NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_prizes_activity_id ON prizes(activity_id); CREATE INDEX IF NOT EXISTS idx_prizes_stock ON prizes(remaining_count); -- lottery_records CREATE TABLE IF NOT EXISTS lottery_records ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, prize_id UUID NULL REFERENCES prizes(id) ON DELETE SET NULL, prize_name TEXT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_records_user_id ON lottery_records(user_id); CREATE INDEX IF NOT EXISTS idx_records_prize_id ON lottery_records(prize_id); CREATE INDEX IF NOT EXISTS idx_records_created_at ON lottery_records(created_at); CREATE INDEX IF NOT EXISTS idx_records_user_created ON lottery_records(user_id, created_at);

整体上我觉得没啥毛病,不过活动状态status 用TEXT我觉得有点别扭。postgres支持定义类型的。让AI改下。有 AI 确实极大降低了试过的代价,有了想法,让AI实现就可以了,有时候确实有觉得程序员是不是没用了。
不过目前阶段AI确实像个理论知识丰富的实习生,背的知识很多,运用上还是有欠缺的。
譬如:status设计的不太合适。然后也没加单元测试。
让他补上吧。
image.png
环境变量也没用.env管理,也需要提醒。
image.png

启动服务

初始化数据库

这个我自己来。我安装的postgres.app

createdb lottery

运行迁移,生成表

sqlx migrate run

注意: 你需要先配置好.env 我反正是demo, 可以放出来

# Copy this file to .env and fill real values

# PostgreSQL connection string (example placeholder)
DATABASE_URL=postgres://postgres:postgres@localhost:5432/lottery

# JWT secret used for token signing (set a strong random string in production)
JWT_SECRET=xfdw!0-9xfdsamln$+_@!

# Server listen address
SERVER_ADDR=0.0.0.0:8080

# Admin credentials for management API (placeholders)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123

# Optional: tracing log level (info,debug,trace)
# RUST_LOG=info

image.png

image.png
成功。但是没示例数据,也可以让ai自己生成。

演示数据

已加入演示数据的迁移:migrations/0002_demo_seed.sql(1
个“进行中”活动 + 3 个奖品,权重合计
30,便于抽奖测试)。请按顺序执行 0001_init.sql 再执行
0002_demo_seed.sql,然后:
• 注册并登录获取 token:POST
/api/auth/register、/api/auth/login
• 查看奖品:GET /api/lottery/prizes
• 抽奖:POST /api/lottery/draw
说明:提交被 Droid‑Shield 阻止(因工作区存在 .env.example
误报为敏感信息);若需我提交,请同意将其改名为 env.sample
或临时关闭 Droid‑Shield。

db单测

然后就可以让ai加单元测试了。rust挺适合写单元测试的。

# 请用 https://crates.io/crates/sqlx-db-tester 写单元测试 #[tokio::test] fn some_awesom_test() { let tdb = TestPg::new( "postgres://postgres:postgres@localhost:5432".to_string(), std::path::Path::new("./migrations"), ) let pool = tdb.get_pool().await; // do something with the pool // when tdb gets dropped, the database will be dropped }

写单元测试时,发现AI没有分层,直接在router里查数据库了。所以让他重构下。把数据库操作分层放到service里面。

image.png
真好,又是一次性过。

api集成测试

image.png
又是一次过。
当前代码 1045 行,累计用了 93w token

rest client 测试代码

image.png

QPS测试

Q: 我想测试 抽奖接口的qps 如何测试呢?

方案A:Vegeta

  1. 获取一个或多用户令牌(建议多用户以绕过频率限制)
    • 单用户:
    TOKEN=(curl -s -X POST http://localhost:8080/api/auth/register
    -H ‘content-type: application/json’
    -d "{“username”:“benchUndefined control sequence \"tok”;
    done > targets.txt
    2) 压测(单用户示例)
    echo “POST http://localhost:8080/api/lottery/draw” | vegeta
    attack -duration=60s -rate=1000
    -header “Authorization: Bearer $TOKEN” | vegeta report
    3) 多用户轮询压测(绕过频率限制)
    vegeta attack -duration=60s -rate=2000 -targets=targets.txt |
    vegeta report
    可生成直方图/可视化
    vegeta report
    -type=‘hist[0,5ms,10ms,20ms,50ms,100ms,200ms,500ms,1s]’ <
    results.bin
    vegeta plot < results.bin > plot.html
    方案B:wrk(如安装了 wrk)
    • Lua 脚本(draw.lua),轮询多个 token:
    tokens = { “TOKEN1”,“TOKEN2”, … }
    counter = 0
    wrk.method = “POST”
    function request()
    counter = counter + 1
    local t = tokens[(counter-1) % #tokens + 1]
    return wrk.format(nil, “/api/lottery/draw”,
    {[“Authorization”]="Bearer "…t})
    end
    • 运行:
    wrk -t8 -c512 -d60s -s draw.lua http://localhost:8080
    提示
    • 为避免库存耗尽影响路径,压测前可将某奖品
    remaining_count/概率调大。
    • 若只想测服务内部算法吞吐(非HTTP),可运行已添加的 perf
    测试:PERF_OPS=500 PERF_CONCURRENCY=50 cargo test --test
    perf_lottery – --ignored

image.png

Makefile 管理

• 新增命令
• make migrate:运行内置迁移器(src/bin/migrate.rs)
• make prepare:运行库存准备器(src/bin/db_prepare.rs),确
保存在进行中活动与大库存奖品(可用
PREP_STOCK/PREP_PROBABILITY 覆盖)
• make serve:启动服务(前台)
• make qps:对 /api/lottery/draw 压测(使用
src/bin/qps_bench.rs,支持
BENCH_URL/BENCH_OPS/BENCH_CONC)
• make bench-local:后台启动服务→健康检查→迁移→准备库存→压测
→停止服务,输出 QPS 与分位延迟

说明:请在 .env 中设置 DATABASE_URL/JWT_SECRET 等后直接 make
bench-local 即可获得当前仅用 SQL 实现的基线 QPS。

总结

AI 花了一个小时 实现了纯基于数据库版本的抽奖实现。
并且实现了数据库迁移管理/单元测试/集成测试/压测。
消耗 token 118w, rust 代码行数:1454

过程非常顺利。一次编译错误都没有,我做的事情只是一直提要求。
image.png

这次压测的吞吐量约为 3951 QPS(10000 次请求在 2.53s 内完成 = 3951.68 QPS)。延迟分位说明:P50≈63.5ms、P95≈71.3ms、P99≈78.0ms,平均≈
63.9ms,代表单次抽奖接口的响应时延分布。若要更接近真实上限,建议服务和压测都用 release 模式并关闭调试日志后再测。
性能比我预期的高。
这也说明瓶颈就是数据库。要支持百万并发,需要引入redis.

本文链接:http://blog.go2live.cn/post/lottery-v1.html

-- EOF --