最近在开发知识付费课程的授权访问功能时,遇到了一个比较典型的并发一致性问题。
假设一套课程出售给 100 名学员,系统生成 100 个不同的授权码。学员收到课程链接和授权码后,第一次成功访问课程时,将授权码绑定到自己的账号。
业务规则看起来很简单:
真正麻烦的是并发场景。
例如,用户 A 和用户 B 几乎同时提交同一个授权码,或者用户 A 同时在两个设备上提交不同的授权码。
如果简单地先查询、再判断、最后更新,就可能出现竞争条件。
本文结合 Go、Redis Lua、PostgreSQL,设计一个可实施的一人一码绑定方案。
需要强调:Redis 负责短时间的并发协调,数据库事务与唯一约束负责持久化后的正确性。两者职责不同。
假设授权码 ABCDE-23456 还没有绑定用户。
常见代码逻辑如下:
code := findAuthCode(codeDigest)
if code.BoundUserID == "" {
code.BoundUserID = currentUserID
save(code)
}单个请求没有问题,但两个请求同时执行时,可能出现:
时间 用户 A 用户 B
T1 查询授权码
T2 查询授权码
T3 发现未绑定
T4 发现未绑定
T5 更新绑定 A
T6 更新绑定 B这属于典型的 TOCTOU(检查时间与使用时间不一致)问题。
另外,还有反向竞争:
同一个用户同时使用授权码 X 和授权码 Y。
即使每个授权码分别通过了“未绑定”检查,也可能导致同一用户占用了两个授权码。
因此这里存在两个需要同时满足的不变量:
同一授权码 -> 最多一个账号
同一分享 + 同一账号 -> 最多一个授权码仅仅对授权码进行加锁,不足以保证第二个约束。
一种设计方式是在提交数据库事务前,先使用 Redis 进行短期预占。
每次绑定需要同时预占两个资源:
例如:
bind:{share42}:code:code100
bind:{share42}:user:user88这里的 {share42} 是 Redis Cluster 的 Hash Tag。
通过这种命名方式,同一分享的两个 Key 可以落在相同 Hash Slot,从而允许一个 Lua 脚本操作这两个 Key。
注意,示例中的用户 ID 必须由服务端验证过的身份得到,不能直接信任客户端传入的用户 ID。
此外,Key 中应使用授权码内部 ID,而不是授权码明文。
为什么不用两条普通 SET NX 命令?
因为如果第一条成功、第二条失败,程序就需要处理部分预占成功的问题。
而使用 Redis Lua,可以把检查和操作放到服务器端的单次脚本执行中,避免其他客户端在检查和写入之间插入操作。
示例脚本:
-- KEYS[1]:授权码预占 Key
-- KEYS[2]:用户预占 Key
-- ARGV[1]:唯一请求 ID
-- ARGV[2]:预占有效期(毫秒)
local codeOwner = redis.call("GET", KEYS[1])
local userOwner = redis.call("GET", KEYS[2])
-- 当前授权码被其他请求预占
if codeOwner and codeOwner ~= ARGV[1] then
return 1
end
-- 当前用户正在绑定其他授权码
if userOwner and userOwner ~= ARGV[1] then
return 2
end
if not codeOwner then
redis.call(
"SET", KEYS[1], ARGV[1],
"PX", ARGV[2]
)
end
if not userOwner then
redis.call(
"SET", KEYS[2], ARGV[1],
"PX", ARGV[2]
)
end
return 0返回值定义:
返回值 | 含义 |
|---|---|
0 | 预占成功,可以继续数据库事务 |
1 | 授权码正被其他请求处理 |
2 | 当前用户正处理另一绑定请求 |
每次请求应使用唯一随机请求 ID。同一个请求重试时可以复用该 ID。
这样可以让重复请求识别自己的临时预占。
但是需要注意一个细节:
Lua 的原子执行不等于 SQL 事务式回滚。
如果脚本已经执行部分写操作,之后发生运行错误,Redis 不保证自动撤销之前的写入。
因此,临时预占需要 TTL,并且最终结果必须由数据库验证。
使用 go-redis/v9:
go get github.com/redis/go-redis/v9示例代码:
package binding
import (
"context"
"github.com/redis/go-redis/v9"
)
var reserveScript = redis.NewScript(`
local codeOwner = redis.call("GET", KEYS[1])
local userOwner = redis.call("GET", KEYS[2])
if codeOwner and codeOwner ~= ARGV[1] then
return 1
end
if userOwner and userOwner ~= ARGV[1] then
return 2
end
if not codeOwner then
redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
end
if not userOwner then
redis.call("SET", KEYS[2], ARGV[1], "PX", ARGV[2])
end
return 0
`)
func ReserveBinding(
ctx context.Context,
rdb redis.UniversalClient,
codeKey string,
userKey string,
requestID string,
) (int64, error) {
return reserveScript.Run(
ctx,
rdb,
[]string{codeKey, userKey},
requestID,
15000,
).Int64()
}这里把预占时间示例设置为 15 秒。
实际生产环境应根据数据库事务的最大执行时间设置,并限制事务超时。
如果 Redis 请求失败,不能假设预占成功。可以选择失败关闭,或者明确切换到经过验证的数据库事务路径。
Redis 预占成功,仍然不等于授权码已经绑定成功。
真正的绑定结果必须等数据库事务成功提交。
这是整个设计最重要的部分。
Redis 不能与 PostgreSQL 组成一个普通的原子事务。
假设出现:
用户 A:Redis 预占成功
Redis 主节点故障
|
新主节点没有同步到预占记录
|
用户 B:重新预占成功
|
两个请求都进入数据库如果数据库没有自己的约束,两个请求仍然可能产生冲突。
所以必须让数据库成为最终权威数据源。
示例表结构:
CREATE TABLE share_auth_codes (
id BIGSERIAL PRIMARY KEY,
auth_code_id TEXT NOT NULL UNIQUE,
tenant_id TEXT NOT NULL,
share_id TEXT NOT NULL,
code_digest TEXT NOT NULL UNIQUE,
bound_principal_id TEXT NOT NULL DEFAULT '',
first_used_at TIMESTAMPTZ
);首先,授权码必须唯一。
code_digest 保存授权码经过带密钥哈希处理后的摘要,不建议保存可直接查询匹配的明文授权码。
其次,通过部分唯一索引限制同一个用户在同一份分享中的绑定数量:
CREATE UNIQUE INDEX uniq_share_bound_user
ON share_auth_codes (
tenant_id,
share_id,
bound_principal_id
)
WHERE bound_principal_id <> '';这个索引的作用是:
注意,这里的 bound_principal_id 应当是经过身份系统归一化的稳定主体 ID。
如果同一个人通过不同登录渠道获得了不同的主体 ID,数据库无法自动知道它们属于同一账号,需要由可信身份映射层完成归一化。
在数据库事务内,首先检查当前用户在这份分享中是否已经绑定授权码。
如果已经绑定,直接按照现有绑定关系处理,不需要占用另一枚码。
如果尚未绑定,再尝试条件更新:
UPDATE share_auth_codes
SET
bound_principal_id = $4,
first_used_at = NOW()
WHERE tenant_id = $1
AND share_id = $2
AND code_digest = $3
AND bound_principal_id = ''
RETURNING auth_code_id;参数分别为:
$1 = tenantID
$2 = shareID
$3 = HMAC 摘要
$4 = 已验证的稳定用户 ID这是一种 Compare-And-Set 思路:
只有当授权码仍未绑定时,才允许执行更新。
如果两个不同用户争夺同一个码,最终只能有一个成功更新该授权码记录。
如果同一个用户同时绑定两个不同码,数据库部分唯一索引还会阻止第二个已绑定记录成立。
对于 PostgreSQL 唯一约束冲突,需要回滚当前事务,再重新查询最终绑定状态,而不是在已经出错的事务内继续执行。
完整的访问结算应该采用同一个数据库事务:
BEGIN
|
|-- 验证分享状态和当前策略
|
|-- 验证访问会话与用户身份
|
|-- 查询已有绑定
|
|-- 条件绑定授权码
|
|-- 原子扣减访问次数 / 配额
|
|-- 写入成功访问记录
|
COMMIT任一环节失败,全部回滚。
例如,授权码有效,但是用户访问次数已达上限,或者课程流量配额不足,那么不应该产生一个已经绑定成功、实际却无法访问的异常状态。
如果访问记录属于外部日志系统或异步消息系统,则不能直接把它们视为同一个数据库事务的一部分,需要另外设计可靠投递机制。
建议区分三个阶段。
用户输入授权码,服务端验证格式、摘要、分享范围和当前绑定状态。
此时不能直接绑定。
检查通过后,将匹配到的授权码内部 ID 暂存到当前访问会话。
但这一步仍不消耗授权码。
当用户真正请求到受保护的文件内容,或获得有效视频播放授权时,执行数据库事务。
这时才完成授权码绑定。
这样可以避免用户仅打开验证页面,尚未成功进入课程,就提前占用授权码。
同时,后续重试应该使用已绑定身份继续访问,而不是重复消耗一枚新码。
不能直接对两个 Key 执行普通 DEL。
原因是预占可能已经过期,新的请求可能重新获得了同名 Key。
旧请求如果直接删除,就可能误删新请求的预占。
应该通过 Lua 比较请求 ID 后再删除:
if redis.call("GET", KEYS[1]) == ARGV[1] then
redis.call("DEL", KEYS[1])
end
if redis.call("GET", KEYS[2]) == ARGV[1] then
redis.call("DEL", KEYS[2])
end
return 1无论数据库事务成功还是失败,都应尝试安全释放预占。
如果服务崩溃,TTL 则负责清理遗留预占。
需要强调:
Redis 里保存的是短期协调状态,不是永久绑定结果。
后续访问仍应依据数据库中的绑定关系判断资格。
这是最值得关注的部分。
异常场景 | 正确处理方式 |
|---|---|
Redis 预占成功,数据库更新失败 | 数据库回滚,释放预占 |
Redis 预占成功,应用服务崩溃 | TTL 自动过期,数据库没有提交则视为未绑定 |
数据库提交成功,HTTP 响应丢失 | 重试时查询已有绑定,幂等返回 |
Redis 锁提前过期 | 数据库条件更新和唯一约束继续保护绑定 |
Redis 主从切换丢失预占记录 | 允许重新竞争,但数据库必须拒绝冲突绑定 |
用户重复提交同一授权码 | 复用已经成功的账号绑定 |
用户同时提交两枚不同授权码 | 最多保留一条已绑定记录 |
这里必须区别两种一致性:
Redis 内部的原子性
Lua 保证一个脚本执行过程中不会被其他 Redis 命令穿插。
跨 Redis 和 PostgreSQL 的一致性
无法仅凭 Lua 脚本保证。
因此,这套方案的正确设计应该是:
Redis 优化并发协调,PostgreSQL 保证最终持久化状态的约束。
如果 Redis 失效,系统可以短暂降低可用性或退回数据库路径,但不能因此允许两个账号绑定同一个码。
仅通过正常业务流程测试,并不足以证明一人一码绑定安全。
至少需要以下测试:
测试一:100 个用户同时绑定同一个码
预期数据库中该授权码最多出现一个成功绑定主体。
测试二:同一个用户同时绑定 100 个不同码
预期同一个分享中最多有一枚授权码被该用户占用。
测试三:同一个用户重复请求 100 次
预期不会重复消耗授权码,访问计数遵循独立的幂等结算规则。
测试四:数据库事务中途失败
预期授权码绑定、配额扣减和访问记录全部回滚。
测试五:Redis 预占到期后发生并发竞争
预期即使两个请求都进入数据库阶段,最终也只能满足唯一约束的一种绑定结果。
测试六:数据库提交成功后服务立即断开
重新请求时,应能识别已提交的绑定,避免重复分配。
测试应使用真实 PostgreSQL 和 Redis,并通过并发请求、故障注入与数据库最终状态检查验证。
不能仅凭 Mock 测试或者 Lua 脚本返回成功就证明生产环境的一致性。
一人一码功能看起来只是为课程增加一个授权码输入框,真正的技术难点在于账号身份、并发竞争、数据库事务和访问结算的一致性。
完整的思路可以概括为:
安全随机授权码
|
HMAC 摘要验证
|
可信用户身份归一化
|
Redis Lua 双资源原子预占
|
数据库条件更新 + 唯一约束
|
访问权限与配额事务结算
|
提交成功
|
释放 Redis 临时预占
|
后续访问复用绑定关系这套方案的关键并不是把所有状态放在 Redis 中,而是明确各组件的职责。
在开发唯览空间的课程授权码能力时,这类问题具有实际业务意义:课程可以上传一次,再按学员分发独立授权码,同时避免同一个码被多个账号重复绑定。
但需要说明,一人一码限制的是已验证的账号身份,并不是现实世界中的自然人。一个人注册多个独立账号、共享已登录设备,仍然需要通过其他机制管理风险。
对于安全敏感的授权业务,最终应以数据库不变量和可复现的故障测试为依据,而不是仅凭 Redis 锁就宣称实现了强一致性。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。