Files
junhong_cmp_fiber/internal/polling/carrierqueue/scripts.go
邹进 2fe7c1f8ac feat: 按运营商账户调度的分发器、卡任务执行器与模式切换
- 四个轮询处理器拆为步骤函数 Step(返回结果分类),旧 Asynq Handle 只做并发控制与原有重入队映射,行为不变;carrier 模式下旧 handler 直接结束
- 网关限流增加等待模式(ctx 标记),NSQ 消费者等名额到单步超时,超时按限流处理不计失败;记录限流等待次数
- 卡任务执行器:核对 token 并抢执行权,按配置步骤顺序串行执行到期检查(单步超时、总超时、panic 恢复、流量已触发评估时跳过套餐),执行中续期并 Touch,续期失败立即停止;收尾写回到期、连续失败、退避、延迟确认与告警
- 分发器:每秒按账户抢分发锁领取(执行上限按消费能力收紧、优先占比、按限流组剩余 QPS 估算),发布到 polling.card.{账户},发布失败释放;消费旧优先提示与手动触发;心跳与处理中标记到期计数
- NSQ 生产者与按账户动态订阅的消费者管理器,优雅退出宽限期
- 失败告警异步写 tb_polling_failure_alert(同卡同类型一条未处理,成功转已恢复),账户窗口内告警数超阈值产生账户级告警
- 模式监听:切 carrier 时旧分片调度停止出队,加锁初始化新结构后开始领取并订阅;回 legacy 时停止消费、重建旧分片队列;生命周期(API 与 Worker)在 carrier 模式写账户队列
2026-10-25 18:44:23 +09:00

255 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package carrierqueue
import "github.com/redis/go-redis/v9"
// 卡到期 Hash 字段前缀(见 constants.RedisCardDueKey)。
const (
fieldDue = "due:" // 各检查类型下次到期(毫秒)
fieldOK = "ok:" // 最近一次成功(毫秒),用于冷却判断
fieldFail = "fail:" // 连续失败次数
fieldFirstFail = "ffail:" // 首次失败(毫秒)
fieldError = "err:" // 最近错误(类别|信息)
fieldConfirm = "cfm:" // 待执行的延迟确认序列(秒,逗号分隔)
fieldExpected = "exp:" // 延迟确认的期望结果(如 offline / online),满足时取消后续确认
fieldPriority = "prio" // 优先标记:1 表示应在优先队列(执行中收到请求检查时由收尾移入优先队列)
fieldCarrier = "carrier"
)
// claimLua 账户领取(分发器抢到账户分发锁后执行)。
//
// KEYS[1] 优先队列;KEYS[2] 普通队列;KEYS[3] 执行中计数。
// ARGV:now_ms、lease_ms、执行上限、优先占比(0 表示不限)、token 前缀、
// 限流组本秒剩余("g=n;g=n",空串表示不按 QPS 限制)、检查类型所属限流组("type=g;...")、是否计入实名(1/0)。
// 返回 {本轮清理的过期执行数, card_id, token, 是否来自优先(1/0), ...}。
const claimLua = `
local now = tonumber(ARGV[1])
local lease = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local ratio = tonumber(ARGV[4])
local prefix = ARGV[5]
local unlimited = ARGV[6] == ''
local countRealname = ARGV[8] == '1'
local expired = redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', now)
local result = {expired}
local capacity = limit - redis.call('ZCARD', KEYS[3])
if capacity <= 0 then
return result
end
local budget = {}
for g, n in string.gmatch(ARGV[6], '([^=;]+)=(%-?%d+)') do budget[g] = tonumber(n) end
local typeGroup = {}
for t, g in string.gmatch(ARGV[7], '([^=;]+)=([^;]+)') do typeGroup[t] = g end
local gatewayTypes = {'card_status', 'carddata', 'realname'}
local used = {}
local taken = 0
local stop = false
local function tryClaim(cardId, fromPriority, qkey)
local dueKey = 'polling:card:' .. cardId .. ':due'
local need = {}
if not unlimited then
for _, t in ipairs(gatewayTypes) do
if t ~= 'realname' or countRealname then
local d = tonumber(redis.call('HGET', dueKey, 'due:' .. t) or '-1')
if d >= 0 and d <= now then
local g = typeGroup[t]
if g then need[g] = (need[g] or 0) + 1 end
end
end
end
for g, n in pairs(need) do
if budget[g] ~= nil and (used[g] or 0) + n > budget[g] then
stop = true
return false
end
end
end
local claimKey = 'polling:card:' .. cardId .. ':claim'
local token = prefix .. ':' .. cardId
local ok = redis.call('SET', claimKey, token, 'NX', 'PX', lease)
if not ok then
local ttl = redis.call('PTTL', claimKey)
if ttl < 0 then ttl = lease end
redis.call('ZADD', qkey, 'XX', now + ttl, cardId)
return false
end
for g, n in pairs(need) do used[g] = (used[g] or 0) + n end
redis.call('ZADD', qkey, 'XX', now + lease, cardId)
redis.call('ZADD', KEYS[3], now + lease, token)
if fromPriority then
redis.call('HDEL', dueKey, 'prio')
end
taken = taken + 1
table.insert(result, cardId)
table.insert(result, token)
table.insert(result, fromPriority and '1' or '0')
return true
end
local function drain(qkey, fromPriority, quota)
if quota <= 0 or stop then return end
local ids = redis.call('ZRANGEBYSCORE', qkey, '-inf', now, 'LIMIT', 0, quota * 2 + 10)
for _, id in ipairs(ids) do
if stop or quota <= 0 or taken >= capacity then return end
if tryClaim(id, fromPriority, qkey) then quota = quota - 1 end
end
end
local priorityQuota = capacity
if ratio > 0 then
priorityQuota = math.floor(capacity * ratio)
if priorityQuota < 1 then priorityQuota = 1 end
end
drain(KEYS[1], true, priorityQuota)
drain(KEYS[2], false, capacity - taken)
drain(KEYS[1], true, capacity - taken)
return result
`
// renewLua 续期:token 与执行权一致时延长租约与执行权,并推后 score 与执行中计数到期时间。
//
// KEYS[1] claim;KEYS[2] exec;KEYS[3] 执行中计数;KEYS[4] 优先队列;KEYS[5] 普通队列。
// ARGV:token、执行权值、lease_ms、exec_ttl_ms、now_ms、card_id。返回 1 成功,0 token 或执行权已不一致。
const renewLua = `
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
if redis.call('GET', KEYS[2]) ~= ARGV[2] then return 0 end
local lease = tonumber(ARGV[3])
local now = tonumber(ARGV[5])
redis.call('PEXPIRE', KEYS[1], lease)
redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[4]))
redis.call('ZADD', KEYS[3], 'XX', now + lease, ARGV[1])
redis.call('ZADD', KEYS[4], 'XX', now + lease, ARGV[6])
redis.call('ZADD', KEYS[5], 'XX', now + lease, ARGV[6])
return 1
`
// acquireExecLua 抢执行权:claim 等于 token 时 SET exec NX PX。返回 1 抢到,0 claim 不一致,-1 已有执行者。
//
// KEYS[1] claim;KEYS[2] exec。ARGV:token、执行权值、exec_ttl_ms。
const acquireExecLua = `
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
if redis.call('SET', KEYS[2], ARGV[2], 'NX', 'PX', tonumber(ARGV[3])) then return 1 end
return -1
`
// finishLua 收尾:写回业务结果;token 一致时删除处理中标记与执行权、移出执行中计数,
// 按优先标记把卡放入优先或普通队列,score = 各检查类型最早到期。卡已不在任一队列(已移出轮询)时不重新加入。
//
// KEYS[1] due;KEYS[2] claim;KEYS[3] exec;KEYS[4] 执行中计数;KEYS[5] 优先队列;KEYS[6] 普通队列。
// ARGV:token、card_id、now_ms、set 字段对数 n、字段1、值1、…、删除字段…。
// 返回 1 正常收尾,0 token 不一致(只写了业务结果)。
const finishLua = `
local n = tonumber(ARGV[4])
local idx = 5
for i = 1, n do
redis.call('HSET', KEYS[1], ARGV[idx], ARGV[idx + 1])
idx = idx + 2
end
while idx <= #ARGV do
redis.call('HDEL', KEYS[1], ARGV[idx])
idx = idx + 1
end
if redis.call('GET', KEYS[2]) ~= ARGV[1] then return 0 end
redis.call('DEL', KEYS[2])
redis.call('DEL', KEYS[3])
redis.call('ZREM', KEYS[4], ARGV[1])
local cardId = ARGV[2]
local inPriority = redis.call('ZSCORE', KEYS[5], cardId)
local inQueue = redis.call('ZSCORE', KEYS[6], cardId)
if not inPriority and not inQueue then return 1 end
local fields = redis.call('HGETALL', KEYS[1])
local nextDue = nil
for i = 1, #fields, 2 do
if string.sub(fields[i], 1, 4) == 'due:' then
local v = tonumber(fields[i + 1])
if v and (nextDue == nil or v < nextDue) then nextDue = v end
end
end
if nextDue == nil then nextDue = tonumber(ARGV[3]) + 3600000 end
if redis.call('HGET', KEYS[1], 'prio') == '1' then
redis.call('ZREM', KEYS[6], cardId)
redis.call('ZADD', KEYS[5], nextDue, cardId)
else
redis.call('ZREM', KEYS[5], cardId)
redis.call('ZADD', KEYS[6], nextDue, cardId)
end
return 1
`
// requestCheckLua 请求检查:把指定检查类型设为立即到期并放入优先队列;卡执行中时只打优先标记,收尾后立即再执行。
// 只作用于仍在轮询中的卡(在优先或普通队列中)。冷却期内(最近成功距今未超过冷却)的类型不请求。
//
// KEYS[1] due;KEYS[2] 优先队列;KEYS[3] 普通队列;KEYS[4] claim。
// ARGV:now_ms、card_id、cooldown_ms(0 不冷却)、确认延迟(秒,逗号分隔,可空)、期望结果(可空)、
// 是否放入优先队列(1/0;0 用于手动触发:只把到期设为现在并在普通队列中立即到期)、类型…。
// 返回本次实际请求的类型数;卡不在轮询中返回 -1。
const requestCheckLua = `
local now = tonumber(ARGV[1])
local cardId = ARGV[2]
local cooldown = tonumber(ARGV[3])
local inPriority = redis.call('ZSCORE', KEYS[2], cardId)
local inQueue = redis.call('ZSCORE', KEYS[3], cardId)
if not inPriority and not inQueue then return -1 end
local requested = 0
local toPriority = ARGV[6] == '1'
for i = 7, #ARGV do
local t = ARGV[i]
local skip = false
if cooldown > 0 then
local ok = tonumber(redis.call('HGET', KEYS[1], 'ok:' .. t) or '0')
if ok > 0 and now - ok < cooldown then skip = true end
end
if not skip then
redis.call('HSET', KEYS[1], 'due:' .. t, now)
if ARGV[4] ~= '' then
redis.call('HSET', KEYS[1], 'cfm:' .. t, ARGV[4])
if ARGV[5] ~= '' then redis.call('HSET', KEYS[1], 'exp:' .. t, ARGV[5]) else redis.call('HDEL', KEYS[1], 'exp:' .. t) end
end
requested = requested + 1
end
end
if requested == 0 then return 0 end
if not toPriority then
if redis.call('EXISTS', KEYS[4]) == 1 then return requested end
if inQueue then redis.call('ZADD', KEYS[3], now, cardId) else redis.call('ZADD', KEYS[2], now, cardId) end
return requested
end
redis.call('HSET', KEYS[1], 'prio', '1')
if redis.call('EXISTS', KEYS[4]) == 1 then return requested end
redis.call('ZREM', KEYS[3], cardId)
redis.call('ZADD', KEYS[2], now, cardId)
return requested
`
// removeLua 把卡移出所属账户队列(关闭轮询、删除卡等);执行中的卡收尾时因不在队列中而不会被重新加入。
// KEYS[1] 优先队列;KEYS[2] 普通队列;KEYS[3] due。ARGV:card_id。
const removeLua = `
redis.call('ZREM', KEYS[1], ARGV[1])
redis.call('ZREM', KEYS[2], ARGV[1])
redis.call('DEL', KEYS[3])
return 1
`
// releaseLua 发布失败时按 token 删除处理中标记、移出执行中计数,并把 score 恢复为当前时间。
// KEYS[1] claim;KEYS[2] 执行中计数;KEYS[3] 优先队列;KEYS[4] 普通队列。ARGV:token、card_id、now_ms。
const releaseLua = `
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
redis.call('DEL', KEYS[1])
redis.call('ZREM', KEYS[2], ARGV[1])
redis.call('ZADD', KEYS[3], 'XX', tonumber(ARGV[3]), ARGV[2])
redis.call('ZADD', KEYS[4], 'XX', tonumber(ARGV[3]), ARGV[2])
return 1
`
var (
claimScript = redis.NewScript(claimLua)
renewScript = redis.NewScript(renewLua)
acquireExecScript = redis.NewScript(acquireExecLua)
finishScript = redis.NewScript(finishLua)
requestCheckScript = redis.NewScript(requestCheckLua)
removeScript = redis.NewScript(removeLua)
releaseScript = redis.NewScript(releaseLua)
)