首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Go + Redis Lua + PostgreSQL 实现一人一码:如何保证高并发下的原子绑定?

Go + Redis Lua + PostgreSQL 实现一人一码:如何保证高并发下的原子绑定?

原创
作者头像
唯览空间
发布于 2026-10-30 21:19:37
发布于 2026-10-30 21:19:37
600
举报

Go + Redis Lua + PostgreSQL 实现一人一码:如何保证高并发下的原子绑定?

前言

最近在开发知识付费课程的授权访问功能时,遇到了一个比较典型的并发一致性问题。

假设一套课程出售给 100 名学员,系统生成 100 个不同的授权码。学员收到课程链接和授权码后,第一次成功访问课程时,将授权码绑定到自己的账号。

业务规则看起来很简单:

  1. 一个授权码只能绑定一个用户。
  2. 一个用户在同一份分享中只能绑定一个授权码。
  3. 已绑定用户可以重复访问,不会重复消耗授权码。
  4. 不同用户不能使用其他人已绑定的授权码。
  5. 高并发、请求重试、服务重启后仍应满足上述约束。

真正麻烦的是并发场景。

例如,用户 A 和用户 B 几乎同时提交同一个授权码,或者用户 A 同时在两个设备上提交不同的授权码。

如果简单地先查询、再判断、最后更新,就可能出现竞争条件。

本文结合 Go、Redis Lua、PostgreSQL,设计一个可实施的一人一码绑定方案。

需要强调:Redis 负责短时间的并发协调,数据库事务与唯一约束负责持久化后的正确性。两者职责不同。

一、为什么普通的查询再绑定存在问题?

假设授权码 ABCDE-23456 还没有绑定用户。

常见代码逻辑如下:

代码语言:javascript
复制
code := findAuthCode(codeDigest)

if code.BoundUserID == "" {
    code.BoundUserID = currentUserID
    save(code)
}

单个请求没有问题,但两个请求同时执行时,可能出现:

代码语言:javascript
复制
时间        用户 A             用户 B

T1        查询授权码
T2                           查询授权码
T3        发现未绑定
T4                           发现未绑定
T5        更新绑定 A
T6                           更新绑定 B

这属于典型的 TOCTOU(检查时间与使用时间不一致)问题。

另外,还有反向竞争:

同一个用户同时使用授权码 X 和授权码 Y。

即使每个授权码分别通过了“未绑定”检查,也可能导致同一用户占用了两个授权码。

因此这里存在两个需要同时满足的不变量:

代码语言:javascript
复制
同一授权码 -> 最多一个账号

同一分享 + 同一账号 -> 最多一个授权码

仅仅对授权码进行加锁,不足以保证第二个约束。

二、Redis 应该负责什么?

一种设计方式是在提交数据库事务前,先使用 Redis 进行短期预占。

每次绑定需要同时预占两个资源:

  • 授权码资源:防止不同用户同时争夺同一个码。
  • 用户资源:防止同一用户同时争夺两个不同的码。

例如:

代码语言:javascript
复制
bind:{share42}:code:code100
bind:{share42}:user:user88

这里的 {share42} 是 Redis Cluster 的 Hash Tag。

通过这种命名方式,同一分享的两个 Key 可以落在相同 Hash Slot,从而允许一个 Lua 脚本操作这两个 Key。

注意,示例中的用户 ID 必须由服务端验证过的身份得到,不能直接信任客户端传入的用户 ID。

此外,Key 中应使用授权码内部 ID,而不是授权码明文。

三、使用 Lua 保证双资源预占的原子性

为什么不用两条普通 SET NX 命令?

因为如果第一条成功、第二条失败,程序就需要处理部分预占成功的问题。

而使用 Redis Lua,可以把检查和操作放到服务器端的单次脚本执行中,避免其他客户端在检查和写入之间插入操作。

示例脚本:

代码语言:javascript
复制
-- 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 Lua 脚本?

使用 go-redis/v9:

代码语言:javascript
复制
go get github.com/redis/go-redis/v9

示例代码:

代码语言:javascript
复制
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 组成一个普通的原子事务。

假设出现:

代码语言:javascript
复制
用户 A:Redis 预占成功

Redis 主节点故障
      |
新主节点没有同步到预占记录
      |
用户 B:重新预占成功
      |
两个请求都进入数据库

如果数据库没有自己的约束,两个请求仍然可能产生冲突。

所以必须让数据库成为最终权威数据源。

示例表结构:

代码语言:javascript
复制
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 保存授权码经过带密钥哈希处理后的摘要,不建议保存可直接查询匹配的明文授权码。

其次,通过部分唯一索引限制同一个用户在同一份分享中的绑定数量:

代码语言:javascript
复制
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,数据库无法自动知道它们属于同一账号,需要由可信身份映射层完成归一化。

六、使用 SQL 条件更新完成真正绑定

在数据库事务内,首先检查当前用户在这份分享中是否已经绑定授权码。

如果已经绑定,直接按照现有绑定关系处理,不需要占用另一枚码。

如果尚未绑定,再尝试条件更新:

代码语言:javascript
复制
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;

参数分别为:

代码语言:javascript
复制
$1 = tenantID
$2 = shareID
$3 = HMAC 摘要
$4 = 已验证的稳定用户 ID

这是一种 Compare-And-Set 思路:

只有当授权码仍未绑定时,才允许执行更新。

如果两个不同用户争夺同一个码,最终只能有一个成功更新该授权码记录。

如果同一个用户同时绑定两个不同码,数据库部分唯一索引还会阻止第二个已绑定记录成立。

对于 PostgreSQL 唯一约束冲突,需要回滚当前事务,再重新查询最终绑定状态,而不是在已经出错的事务内继续执行。

事务边界

完整的访问结算应该采用同一个数据库事务:

代码语言:javascript
复制
BEGIN
   |
   |-- 验证分享状态和当前策略
   |
   |-- 验证访问会话与用户身份
   |
   |-- 查询已有绑定
   |
   |-- 条件绑定授权码
   |
   |-- 原子扣减访问次数 / 配额
   |
   |-- 写入成功访问记录
   |
COMMIT

任一环节失败,全部回滚。

例如,授权码有效,但是用户访问次数已达上限,或者课程流量配额不足,那么不应该产生一个已经绑定成功、实际却无法访问的异常状态。

如果访问记录属于外部日志系统或异步消息系统,则不能直接把它们视为同一个数据库事务的一部分,需要另外设计可靠投递机制。

七、什么时候才是真正的授权码核销?

建议区分三个阶段。

阶段 1:验证授权码

用户输入授权码,服务端验证格式、摘要、分享范围和当前绑定状态。

此时不能直接绑定。

阶段 2:建立待处理访问会话

检查通过后,将匹配到的授权码内部 ID 暂存到当前访问会话。

但这一步仍不消耗授权码。

阶段 3:首次有效内容访问并完成结算

当用户真正请求到受保护的文件内容,或获得有效视频播放授权时,执行数据库事务。

这时才完成授权码绑定。

这样可以避免用户仅打开验证页面,尚未成功进入课程,就提前占用授权码。

同时,后续重试应该使用已绑定身份继续访问,而不是重复消耗一枚新码。

八、数据库提交后,Redis 怎么释放预占?

不能直接对两个 Key 执行普通 DEL。

原因是预占可能已经过期,新的请求可能重新获得了同名 Key。

旧请求如果直接删除,就可能误删新请求的预占。

应该通过 Lua 比较请求 ID 后再删除:

代码语言:javascript
复制
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 预占成功,数据库更新失败

数据库回滚,释放预占

Redis 预占成功,应用服务崩溃

TTL 自动过期,数据库没有提交则视为未绑定

数据库提交成功,HTTP 响应丢失

重试时查询已有绑定,幂等返回

Redis 锁提前过期

数据库条件更新和唯一约束继续保护绑定

Redis 主从切换丢失预占记录

允许重新竞争,但数据库必须拒绝冲突绑定

用户重复提交同一授权码

复用已经成功的账号绑定

用户同时提交两枚不同授权码

最多保留一条已绑定记录

这里必须区别两种一致性:

Redis 内部的原子性

Lua 保证一个脚本执行过程中不会被其他 Redis 命令穿插。

跨 Redis 和 PostgreSQL 的一致性

无法仅凭 Lua 脚本保证。

因此,这套方案的正确设计应该是:

Redis 优化并发协调,PostgreSQL 保证最终持久化状态的约束。

如果 Redis 失效,系统可以短暂降低可用性或退回数据库路径,但不能因此允许两个账号绑定同一个码。

十、如何进行并发测试?

仅通过正常业务流程测试,并不足以证明一人一码绑定安全。

至少需要以下测试:

测试一:100 个用户同时绑定同一个码

预期数据库中该授权码最多出现一个成功绑定主体。

测试二:同一个用户同时绑定 100 个不同码

预期同一个分享中最多有一枚授权码被该用户占用。

测试三:同一个用户重复请求 100 次

预期不会重复消耗授权码,访问计数遵循独立的幂等结算规则。

测试四:数据库事务中途失败

预期授权码绑定、配额扣减和访问记录全部回滚。

测试五:Redis 预占到期后发生并发竞争

预期即使两个请求都进入数据库阶段,最终也只能满足唯一约束的一种绑定结果。

测试六:数据库提交成功后服务立即断开

重新请求时,应能识别已提交的绑定,避免重复分配。

测试应使用真实 PostgreSQL 和 Redis,并通过并发请求、故障注入与数据库最终状态检查验证。

不能仅凭 Mock 测试或者 Lua 脚本返回成功就证明生产环境的一致性。

十一、总结

一人一码功能看起来只是为课程增加一个授权码输入框,真正的技术难点在于账号身份、并发竞争、数据库事务和访问结算的一致性。

完整的思路可以概括为:

代码语言:javascript
复制
安全随机授权码
       |
HMAC 摘要验证
       |
可信用户身份归一化
       |
Redis Lua 双资源原子预占
       |
数据库条件更新 + 唯一约束
       |
访问权限与配额事务结算
       |
提交成功
       |
释放 Redis 临时预占
       |
后续访问复用绑定关系

这套方案的关键并不是把所有状态放在 Redis 中,而是明确各组件的职责。

在开发唯览空间的课程授权码能力时,这类问题具有实际业务意义:课程可以上传一次,再按学员分发独立授权码,同时避免同一个码被多个账号重复绑定。

但需要说明,一人一码限制的是已验证的账号身份,并不是现实世界中的自然人。一个人注册多个独立账号、共享已登录设备,仍然需要通过其他机制管理风险。

对于安全敏感的授权业务,最终应以数据库不变量和可复现的故障测试为依据,而不是仅凭 Redis 锁就宣称实现了强一致性。


参考资料

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • Go + Redis Lua + PostgreSQL 实现一人一码:如何保证高并发下的原子绑定?
    • 前言
    • 一、为什么普通的查询再绑定存在问题?
    • 二、Redis 应该负责什么?
    • 三、使用 Lua 保证双资源预占的原子性
    • 四、Go 如何执行 Redis Lua 脚本?
    • 五、为什么还需要数据库唯一约束?
    • 六、使用 SQL 条件更新完成真正绑定
      • 事务边界
    • 七、什么时候才是真正的授权码核销?
      • 阶段 1:验证授权码
      • 阶段 2:建立待处理访问会话
      • 阶段 3:首次有效内容访问并完成结算
    • 八、数据库提交后,Redis 怎么释放预占?
    • 九、如何处理 Redis 和数据库的一致性问题?
    • 十、如何进行并发测试?
    • 十一、总结
    • 参考资料
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档