10000 个人抢 100 件商品,最后卖出去 137 件——这句"段子"我们在表设计那篇就破了。但秒杀真正的第一道门,不是库存,是身份:你是谁?你有没有资格抢?如果你连"注册"这一步都把明文密码写进库,那后面无论 Redis 预扣、Lua 原子、MQ 削峰做得再漂亮,用户表一旦泄露就是全裸——所有"高并发安全"都建立在沙滩上。

本文是「秒杀系统(C++ / Drogon)」系列第三章(登录模块)的开篇。配套仓库 seckill-cpp。当前阶段一 v0.1.x 已落地登录 / 短信模块,目录为 sql/user_schema.sql、src/service/UserService.{h,cc}、src/controllers/UserController.{h,cc}、src/service/password.{h,cc}、src/service/Jwt.{h,cc}、src/service/SessionStore.{h,cc},路由在 src/main.cc 注册。所有方案均为原创、不依赖任何外部教程。

一、注册接口:是什么、坑在哪、本质一句话

是什么:用户注册接口 = 把"账号(手机号 + 密码)"安全地落库,并为后续登录签发 Token 准备好身份基础。它是整个鉴权链路的"建身份"环节,本身不发 Token(Token 留给登录)。

坑在哪:

  • 明文密码:直接 INSERT password=用户输入,一旦拖库,所有账号裸奔。这是最不该犯的错。
  • 弱哈希:用 MD5(password) 甚至不加盐,彩虹表一秒反查;用固定 salt 等于没加盐。
  • 查重与并发:两个请求同时用同一手机号注册,没唯一索引就会插出两条——后面"一人一单"的资格校验全崩。
  • "无状态 JWT"假象:很多人以为 JWT 无状态就够,但 JWT 一旦签发无法主动吊销(改密码/封号后旧 Token 仍有效),必须有一层可吊销的会话态。

本质一句话:注册接口的本质是"安全地建立账号身份"——密码永远不落明文,会话用 Redis 做可吊销的权威态、JWT 只做不可伪造的凭证。

二、用户表设计

密码哈希用 PBKDF2-HMAC-SHA256,派生长度 32 字节 → hex 编码 64 字符;salt 16 字节 → hex 32 字符。字段定长 CHAR 比 VARCHAR 更省心,也避免长度漂移。

1
2
3
4
5
6
7
8
9
10
11
12
-- sql/user_schema.sql
CREATE TABLE `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`phone` VARCHAR(20) NOT NULL COMMENT '登录手机号,唯一',
`password_hash` CHAR(64) NOT NULL COMMENT 'PBKDF2-HMAC-SHA256 派生,hex 编码',
`salt` CHAR(32) NOT NULL COMMENT '16 字节随机 salt,hex 编码',
`nickname` VARCHAR(64) DEFAULT '' COMMENT '昵称,可空',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`) COMMENT '查重 + 防并发插重的核心约束'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_phone 是唯一索引,不只是为了查重快,更是最后一道并发防线:即使应用层查重有竞态,数据库的唯一约束会让第二个 INSERT 直接报 ER_DUP_ENTRY,绝不会插出两条。

三、密码哈希:自实现 PBKDF2(OpenSSL),不引第三方 bcrypt 库

C++ 没有 Spring Security 的 BCrypt,但 OpenSSL 自带工业级 KDF。我选 PBKDF2-HMAC-SHA256 而非直接引 bcrypt-cpp,理由是:依赖更少(OpenSSL 本就是 Drogon/Redis/hiredis/libcurl 的底层依赖,几乎必然在场)、可调迭代次数对抗硬件升级、算法透明可审计。代价是要自己管好 salt 与 hex 编码——而这恰恰是本系列想讲透的"自实现功力"。

1
2
3
4
5
6
7
8
9
// src/service/password.h(登录模块)
namespace seckill::auth {
std::string genSalt(std::size_t bytes = 16); // CSPRNG salt,hex 返回
std::string pbkdf2Hex(const std::string& password,
const std::string& salt,
int iterations = 120000, // 12 万次≈30~60ms
std::size_t dkLen = 32); // 32 字节→64 hex
bool constantTimeEquals(const std::string& a, const std::string& b); // 常量时间比对
}
1
2
3
4
5
// src/service/password.cc(核心两行)
int rc = PKCS5_PBKDF2_HMAC(password.data(), (int)password.size(),
(const unsigned char*)salt.data(), (int)salt.size(),
iterations, EVP_sha256(), (int)dkLen, out.data());
// RAND_bytes 出 salt;比对用 constantTimeEquals,绝不用 ==(防计时攻击)

C++ 细节:RAND_bytes 来自 OpenSSL 的 CSPRNG,比 std::rand() 安全得多——后者是线性同余、可预测,拿来做 salt 等于给攻击者减难度。salt 与哈希分离存储,验证时重新派生比对,绝不在库里存"明文密码 + 一次性比较"。

四、UserService::registerUser:查重 → 哈希 → 落库(含验证码分支)

沿用本仓库既有风格:服务层只做业务,不碰 HTTP;回调语义统一 (bool ok, string msg)。Drogon 的 DbClient 用流式回调;空结果集走成功回调(size()==0),不是异常回调。

一个容易被忽略的性能坑:PBKDF2 是设计成慢的(12 万次迭代 ≈ 30~60ms CPU)。它必须丢进独立工作线程,算完用 drogon::app().getLoop()->queueInLoop 切回 IO 线程——否则登录接口的慢会传染给同线程排队的秒杀请求。

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
// src/service/UserService.cc(节选)
void UserService::registerUser(const std::string& phone, const std::string& password,
const std::string& code,
std::function<void(bool, const std::string&)> &&cb) {
if (!SmsService::isValidPhone(phone)) { cb(false, "INVALID_PHONE"); return; }
if ((int)password.size() < cfg_.minPasswordLength) { cb(false, "WEAK_PASSWORD"); return; }

std::function<void()> doRegister = [this, phone, password, cb]() {
db_->execSqlAsync("SELECT id FROM user WHERE phone = ?",
[this, phone, password, cb](const drogon::orm::Result &r) {
if (r.size() > 0) { cb(false, "PHONE_REGISTERED"); return; } // 快路径查重
const std::string salt = genSalt();
asyncHashPassword(password, salt, // 工作线程跑 PBKDF2
[this, phone, salt, cb](std::string hash) {
db_->execSqlAsync(
"INSERT INTO user (phone,password_hash,salt,status,create_time)"
" VALUES (?,?,?,1,NOW())",
[cb, phone](const drogon::orm::Result &) { cb(true, "OK"); },
[cb, phone](const std::exception_ptr &eptr) {
// 唯一键冲突不是系统故障,是"并发下被抢先注册了"
if (isDuplicateEntry(describe(eptr))) { cb(false, "PHONE_REGISTERED"); return; }
cb(false, "DB_ERROR:" + describe(eptr));
}, phone, hash, salt);
});
},
[cb, phone](const std::exception_ptr &eptr) { cb(false, "DB_ERROR:" + describe(eptr)); },
phone);
};

// 默认 requireSmsOnRegister=true:先校验验证码,通过才走 doRegister
if (!cfg_.requireSmsOnRegister) { doRegister(); return; }
sms_->verifyCode(phone, code, [cb, doRegister](SmsService::VerifyResult r) {
if (r == SmsService::VerifyResult::Ok) doRegister();
else cb(false, r == SmsService::VerifyResult::EmptyCode ? "CODE_EXPIRED"
: r == SmsService::VerifyResult::WrongCode ? "CODE_WRONG"
: r == SmsService::VerifyResult::TooManyAttempts ? "CODE_TOO_MANY_ATTEMPTS"
: "CODE_INVALID");
});
}

并发保险:即使两个相同手机号请求在 SELECT 处竞态都"查不到",第二个 INSERT 也会撞 uk_phone 唯一索引报错,掉进 DB_ERROR 分支并识别成 PHONE_REGISTERED——应用层查重是快路径,数据库唯一约束是兜底,两层都不会放过重复注册。

五、UserController + 路由注册

本仓库控制器不继承 HttpController(Drogon v1.9.10 起对带依赖注入、无默认构造的 HttpController 子类调 registerController 会静态断言失败),改为在 main.cc 用 registerHandler 手动挂路由,lambda 转交给实例。

1
2
3
4
5
6
7
8
// src/main.cc(登录模块路由,Redis 不可用时整体 503,不影响秒杀链路)
drogon::app().registerHandler("/api/user/register",
[](const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
auto &b = bundle();
if (!b.authReady) { callback(unavailable("register")); return; } // Redis 没起→503
b.user->registerUser(req, std::move(cb));
}, {drogon::Post});

下面把整条注册链路画成时序图——重点看密码哈希发生在哪一层(独立工作线程),以及明文密码的"存活半径"被压缩到什么范围。

图 1:用户注册接口时序(PBKDF2 在工作线程,明文不落库) 客户端 Controller Service MySQL POST /api/user/register registerUser(phone,pwd,code) SELECT id WHERE phone=? 空(未注册) worker:PBKDF2 INSERT(phone,hash,salt) OK cb(true,"OK") 200 {code:0} 注:明文密码只在 Service 内存里存在,PBKDF2 在 worker 线程完成

六、可吊销会话的"契约"设计(为登录铺垫,注册不发 Token)

注册只建身份、不发 Token——Token 是登录成功后的凭证。但我们要现在就把会话层的契约定下来,否则登录代码会反过来改表结构。本系列实际落地的契约如下(与原定方案已不同,见下节):

  • JWT 只做凭证,不做权威态:Jwt(自实现 HS256)签发 sub=userId, exp, jti,客户端每次请求带在 Authorization 头。JWT 本身无状态、无法吊销。
  • Redis 做权威态:登录成功后写 sess:{jti} → userId,设 TTL = Token 有效期。jti 是 JWT 唯一 ID。
  • 吊销 = 删 Redis key:改密码/封号时 DEL sess:{jti},旧 Token 即使未过期,网关校验 EXISTS sess:{jti} 失败即拒绝——可吊销性由此而来。
  • Key 规范:sess:{jti},value = userId(字符串),EXPIRE 与 JWT 的 exp 对齐。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/service/Jwt.h(自实现 HS256,不引 jwt-cpp)
class Jwt {
public:
std::string sign(int64_t userId, const std::string &jti, int64_t nowSeconds = 0) const;
bool verify(const std::string &token, JwtClaims &out, int64_t nowSeconds = 0) const;
};

// src/service/SessionStore.cc(Drogon 内置 nosql::RedisClient,非 redis-plus-plus)
void SessionStore::save(const std::string &jti, int64_t userId,
std::function<void(bool)> &&cb) {
redis_->execCommandAsync([](const drogon::nosql::RedisResult &) { cb(true); },
[](const std::exception &e) { /* SESS_SAVE_FAILED */ cb(false); },
"SETEX %s %d %s", key(jti).c_str(), ttlSeconds_, std::to_string(userId).c_str());
}
// 网关校验:exists(jti) 失败即拒绝(fail-close);revoke=DEL key 即吊销

为什么注册不发 Token:注册成功立刻发 Token 等于"建完身份马上给进门钥匙",既违反最小权限(注册接口不该隐含登录态),也让"注册即登录"成为刷号脚本的便利通道。身份与凭证在时序上分离,是安全设计的基本纪律。

七、三处选型变更(相对博客 3.1 原定方案)

博客 3.1 最初定的是 redis-plus-plus + jwt-cpp,实际落地时三处都改了。这是故意的、有理由的改,不是中途翻车——核心约束是:Drogon 的 IO 线程上只允许非阻塞操作,任何同步阻塞的第三方 API 都会结构性地卡死整条链路。

环节 原定方案 实际采用 改的理由
Redis 客户端 redis-plus-plus Drogon 内置 nosql::RedisClient redis-plus-plus 是同步 API。Drogon handler 跑在 IO 线程(threads_num=4),在 IO 线程里同步等一次 Redis 往返,会把这个线程上排队的请求全部卡住——这是结构性问题,不是"多一个依赖"的量级。内置客户端异步、与事件循环同构、零额外依赖(只需 libhiredis-dev)
JWT 签发 jwt-cpp 自实现 HS256(service/Jwt.*) HS256 全部内涵就是「base64url(头).base64url(体).base64url(HMAC-SHA256)」,三个动作各十几行,用已在场的 OpenSSL 就能写。引 header-only 库要 FetchContent 从 GitHub 拉,WSL 网络是额外风险点;自己写编译期零外部依赖、签名过程完全可审计
短信发送 (原定未涉及 SDK) 直连腾讯云 API 3.0 + 自实现 TC3-HMAC-SHA256 签名 官方 tencentcloud-sdk-cpp 要拉 core 模块(数百文件、依赖 protobuf 等),编译十几分钟,而需要的只是「签一个 TC3 签名 + POST 一段 JSON」。自实现约 120 行,依赖只有 libcurl + OpenSSL——这两个本来就在装

净效果:登录模块引入的新依赖只有 libhiredis-dev 与 redis-server,其余全部复用既有组件。

八、可运行验证步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1) 建表(先有 user_schema.sql)
mysql -h127.0.0.1 -useckill -p < sql/user_schema.sql

# 2) 启动 Drogon(WSL 编译:scripts/build-wsl.sh && ./build/src/seckill-cpp)
# 3) 注册一个账号(Redis 起着时默认要验证码;mock 模式验证码只写 logs/seckill.log)
curl -X POST http://127.0.0.1:8080/api/user/register \
-H 'Content-Type: application/json' \
-d '{"phone":"13800001111","password":"abc123","code":"123456"}'

# 4) 查库确认密码是哈希值,绝不是明文
mysql -h127.0.0.1 -useckill -p -e \
"SELECT phone, password_hash, salt FROM user WHERE phone='13800001111'\G"
# password_hash 应为 64 位 hex,salt 为 32 位 hex;肉眼无法反推原密码

# 5) 重复注册同手机号 → 应返回 {"code":1,"msg":"PHONE_REGISTERED"}

九、两张对比表

表 1:密码存储方案对比

方案 抗彩虹表 抗暴力破解 加盐 评价
明文 ✗ ✗ ✗ 拖库即全裸,绝不可取
MD5(密码) ✗ ✗ ✗ 秒级反查,已淘汰
MD5(密码+salt),salt 固定 △ ✗ 弱 固定 salt = 全库共用,仍可被批量跑
bcrypt ✓ ✓ ✓ 自带 salt + 慢哈希,工业标准但需引库
PBKDF2-HMAC-SHA256(本文) ✓ ✓ ✓ 算法透明、依赖少(OpenSSL 自带)、迭代可调

表 2:会话模型对比

模型 可吊销 实现复杂度 适用
纯无状态 JWT ✗(改密/封号后仍有效) 低 短期、低风险 API
JWT + Redis 会话(本文) ✓(DEL key 即吊销) 中 秒杀等需强管控场景
纯 Redis Session(不签发 JWT) ✓ 低 单体,无跨服务诉求

功能抉择(本篇核心权衡)

① 为什么自实现 HS256 + Drogon Redis 会话,而不套 jwt-cpp + redis-plus-plus?
jwt-cpp / redis-plus-plus 都是优秀的库,但它们分别带来"FetchContent 从 GitHub 拉"和"同步 API 卡 IO 线程"两个本可避免的代价。HS256 三个动作各十几行、OpenSSL 已在场;Drogon 内置 nosql::RedisClient 异步且与事件循环同构。把"凭证不可伪造"和"会话可吊销"两件事拆开各司其职,既贴合 C++ 技术栈,也把鉴权链路每一行都摊开讲透——代价是多写几百行基础设施代码,换来对安全语义的完全掌控。

② 为什么密码哈希选 PBKDF2(OpenSSL) 而非直接引 bcrypt 库?
OpenSSL 是 Drogon/Redis/hiredis/libcurl 的底层依赖,几乎必然在场,引 bcrypt-cpp 是额外一项依赖与编译面;PBKDF2 算法公开可审计、迭代次数随硬件升级上调即可,没有"库停更就裸奔"的风险。代价是自己管理 salt 生成与 hex 编码,而这正是本系列想练的底层功力。

③ 为什么注册不发 Token,把"身份"和"凭证"在时序上分开?
注册只回答"你是谁",登录才回答"你现在能进门"。注册即发 Token 既违反最小权限,又给刷号脚本开了"注册即得会话"的便利。分两步后,登录模块才能独立引入风控(验证码、错误次数限制),注册接口保持纯粹的"建身份"。

④ 为什么短信也自实现腾讯云直连,而不引官方 SDK?
官方 C++ SDK 要拉 core 模块(数百文件、依赖 protobuf 等),编译十几分钟,而需要的只是「签一个 TC3 签名 + POST 一段 JSON」。自实现约 120 行,依赖只有 libcurl + OpenSSL——这两个在 setup-wsl.sh 里本来就装了。换来的是:零编译负担、签名过程完全可审计、出问题能直接打印待签串比对。

小结

  • 注册接口的本质是安全地建立账号身份:明文密码绝不落库,PBKDF2 加盐哈希是底线。
  • 查重靠应用层快路径 + uk_phone 唯一索引兜底双层防护,并发下也不会插重;验证码默认强制(防脚本批量注册)。
  • 密码哈希选 PBKDF2-HMAC-SHA256(OpenSSL 自带),且必须丢进工作线程算,否则慢哈希会传染同 IO 线程的秒杀请求。
  • 会话采用 JWT(自实现 HS256,凭证不可伪造)+ Redis(Drogon nosql::RedisClient,权威态可吊销) 双层模型,注册不发 Token,身份与凭证时序分离。
  • **三处选型变更(Redis 客户端 / JWT / 短信)**均为了"IO 线程零阻塞 + 编译期零额外外部依赖",详见第七节对照表。
  • 控制器沿用本仓库非 HttpController + registerHandler 的已验证模式,与 SeckillController 完全对齐。🐾

配套源码

本文对应代码已开源:Hespethorn/seckill-cpp

C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:

  • 阶段一(直打数据库):QPS≈440,0 超卖
  • 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS

如果对你有帮助,欢迎 star ⭐