Files
junhong_cmp_fiber/internal/polling/carrierqueue/store.go
邹进 dae44bed30 feat: 按运营商账户调度的迁移、配置与账户队列 Lua
- 迁移 000251~000254:运营商账户调度参数、请求检查场景(预置 8 个)、卡轮询失败告警表,以及调度模式、步骤、退避、告警等受控系统配置(默认 legacy,行为不变);并发配置表注释标记废弃
- 受控系统配置注册与 JSON 结构校验:步骤只能是 4 种检查类型且不重复、退避序列递增
- yaml 增加 NSQ 地址、msg_timeout、租约、续期间隔、分发间隔、默认执行上限与 max_in_flight;引入 go-nsq
- 测试环境 docker-compose 增加 nsqlookupd 与 nsqd,Worker 注入 NSQ 地址
- 账户队列 Redis 结构与 Lua:领取(执行上限、优先占比、按限流组剩余 QPS 估算)、抢执行权、续期、收尾、请求检查(合并、冷却、执行中打优先标记)、移出、发布失败释放
2026-10-25 18:28:42 +09:00

279 lines
11 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 (
"context"
stderrors "errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// Store 封装按运营商账户调度的 Redis 结构与 Lua 原子操作。
type Store struct {
redis *redis.Client
}
// NewStore 创建账户队列存储。
func NewStore(redisClient *redis.Client) *Store {
return &Store{redis: redisClient}
}
// Client 返回底层 Redis 客户端。
func (s *Store) Client() *redis.Client {
return s.redis
}
// ClaimInput 是一次账户领取的参数。
type ClaimInput struct {
CarrierID uint
Lease time.Duration
InflightLimit int
PriorityRatio float64 // 0 表示不限
TokenPrefix string // 本轮派发 token 前缀(实例 + 轮次),token = 前缀:card_id
GroupBudget map[string]int // 限流组本秒剩余;nil 表示不按 QPS 限制
TypeGroup map[string]string // 检查类型 → 限流组
CountRealname bool // 预计调用数是否计入实名(账户未开实名管控时不计)
}
// Claimed 是一张被领取的卡。
type Claimed struct {
CardID uint
Token string
FromPriority bool
}
// Claim 执行一次账户领取,返回领取到的卡与本轮清理的过期执行数(处理中崩溃或超时次数)。
func (s *Store) Claim(ctx context.Context, input ClaimInput) ([]Claimed, int64, error) {
budget := ""
if input.GroupBudget != nil {
parts := make([]string, 0, len(input.GroupBudget))
for group, remaining := range input.GroupBudget {
parts = append(parts, group+"="+strconv.Itoa(remaining))
}
budget = strings.Join(parts, ";")
if budget == "" {
budget = "none=0"
}
}
typeParts := make([]string, 0, len(input.TypeGroup))
for checkType, group := range input.TypeGroup {
typeParts = append(typeParts, checkType+"="+group)
}
countRealname := "0"
if input.CountRealname {
countRealname = "1"
}
raw, err := claimScript.Run(ctx, s.redis,
[]string{constants.RedisCarrierPriorityQueueKey(input.CarrierID), constants.RedisCarrierQueueKey(input.CarrierID), constants.RedisCarrierRunningKey(input.CarrierID)},
time.Now().UnixMilli(), input.Lease.Milliseconds(), input.InflightLimit, strconv.FormatFloat(input.PriorityRatio, 'f', 3, 64),
input.TokenPrefix, budget, strings.Join(typeParts, ";"), countRealname,
).Slice()
if err != nil {
return nil, 0, err
}
if len(raw) == 0 {
return nil, 0, nil
}
expired, _ := raw[0].(int64)
claimed := make([]Claimed, 0, (len(raw)-1)/3)
for index := 1; index+2 < len(raw); index += 3 {
cardID, _ := strconv.ParseUint(fmt.Sprint(raw[index]), 10, 64)
claimed = append(claimed, Claimed{CardID: uint(cardID), Token: fmt.Sprint(raw[index+1]), FromPriority: fmt.Sprint(raw[index+2]) == "1"})
}
return claimed, expired, nil
}
// AcquireExec 抢执行权:返回 1 抢到,0 处理中标记已不是该 token(过期或重复投递),-1 该 token 已有执行者。
func (s *Store) AcquireExec(ctx context.Context, cardID uint, token, execValue string, ttl time.Duration) (int64, error) {
return acquireExecScript.Run(ctx, s.redis, []string{constants.RedisCardClaimKey(cardID), constants.RedisCardExecKey(cardID)},
token, execValue, ttl.Milliseconds()).Int64()
}
// Renew 续期租约与执行权;返回 false 表示 token 或执行权已不一致,调用方必须停止后续步骤。
func (s *Store) Renew(ctx context.Context, cardID, carrierID uint, token, execValue string, lease, execTTL time.Duration) (bool, error) {
result, err := renewScript.Run(ctx, s.redis,
[]string{constants.RedisCardClaimKey(cardID), constants.RedisCardExecKey(cardID), constants.RedisCarrierRunningKey(carrierID),
constants.RedisCarrierPriorityQueueKey(carrierID), constants.RedisCarrierQueueKey(carrierID)},
token, execValue, lease.Milliseconds(), execTTL.Milliseconds(), time.Now().UnixMilli(), cardID).Int64()
return result == 1, err
}
// Finish 收尾:写回业务结果(set 字段与删除字段),token 一致时释放处理中标记并按最早到期设置 score。
// 返回 false 表示 token 已不一致,只写入了业务结果。
func (s *Store) Finish(ctx context.Context, cardID, carrierID uint, token string, set map[string]string, del []string) (bool, error) {
args := []any{token, cardID, time.Now().UnixMilli(), len(set)}
for field, value := range set {
args = append(args, field, value)
}
for _, field := range del {
args = append(args, field)
}
result, err := finishScript.Run(ctx, s.redis,
[]string{constants.RedisCardDueKey(cardID), constants.RedisCardClaimKey(cardID), constants.RedisCardExecKey(cardID),
constants.RedisCarrierRunningKey(carrierID), constants.RedisCarrierPriorityQueueKey(carrierID), constants.RedisCarrierQueueKey(carrierID)},
args...).Int64()
return result == 1, err
}
// Release 发布失败时按 token 删除处理中标记并把 score 恢复为当前时间。
func (s *Store) Release(ctx context.Context, cardID, carrierID uint, token string) error {
return releaseScript.Run(ctx, s.redis,
[]string{constants.RedisCardClaimKey(cardID), constants.RedisCarrierRunningKey(carrierID),
constants.RedisCarrierPriorityQueueKey(carrierID), constants.RedisCarrierQueueKey(carrierID)},
token, cardID, time.Now().UnixMilli()).Err()
}
// CheckRequest 是一次请求检查。
type CheckRequest struct {
CardID uint
CarrierID uint
CheckTypes []string
Cooldown time.Duration
ConfirmDelays []time.Duration
Expected string // 延迟确认的期望结果(card_status 为 online / offline),满足时取消后续确认
}
// RequestCheck 把卡的指定检查类型设为立即到期放入优先队列;返回实际请求的类型数,卡不在轮询中返回 -1。
func (s *Store) RequestCheck(ctx context.Context, request CheckRequest) (int64, error) {
if len(request.CheckTypes) == 0 {
return 0, nil
}
confirm := make([]string, 0, len(request.ConfirmDelays))
for _, delay := range request.ConfirmDelays {
confirm = append(confirm, strconv.FormatInt(int64(delay/time.Second), 10))
}
args := []any{time.Now().UnixMilli(), request.CardID, request.Cooldown.Milliseconds(), strings.Join(confirm, ","), request.Expected}
for _, checkType := range request.CheckTypes {
args = append(args, checkType)
}
return requestCheckScript.Run(ctx, s.redis,
[]string{constants.RedisCardDueKey(request.CardID), constants.RedisCarrierPriorityQueueKey(request.CarrierID),
constants.RedisCarrierQueueKey(request.CarrierID), constants.RedisCardClaimKey(request.CardID)},
args...).Int64()
}
// Remove 把卡移出所属账户队列并删除到期信息(关闭轮询、删除卡)。
func (s *Store) Remove(ctx context.Context, cardID, carrierID uint) error {
return removeScript.Run(ctx, s.redis,
[]string{constants.RedisCarrierPriorityQueueKey(carrierID), constants.RedisCarrierQueueKey(carrierID), constants.RedisCardDueKey(cardID)},
cardID).Err()
}
// Upsert 写入卡各检查类型到期时间并按最早到期放入账户队列(已在优先队列中的保留在优先队列)。
// 卡所属账户变化时先移出旧账户队列。dues 为空表示该卡没有任何需要执行的检查类型,移出队列。
func (s *Store) Upsert(ctx context.Context, cardID, carrierID uint, dues map[string]time.Time) error {
dueKey := constants.RedisCardDueKey(cardID)
previous, err := s.redis.HGet(ctx, dueKey, fieldCarrier).Uint64()
if err != nil && !stderrors.Is(err, redis.Nil) {
return err
}
pipe := s.redis.TxPipeline()
if previous != 0 && uint(previous) != carrierID {
pipe.ZRem(ctx, constants.RedisCarrierPriorityQueueKey(uint(previous)), cardID)
pipe.ZRem(ctx, constants.RedisCarrierQueueKey(uint(previous)), cardID)
}
if len(dues) == 0 {
pipe.ZRem(ctx, constants.RedisCarrierPriorityQueueKey(carrierID), cardID)
pipe.ZRem(ctx, constants.RedisCarrierQueueKey(carrierID), cardID)
pipe.Del(ctx, dueKey)
_, err := pipe.Exec(ctx)
return err
}
values := map[string]any{fieldCarrier: carrierID}
var earliest time.Time
for _, checkType := range constants.AllCheckTypes() {
due, ok := dues[checkType]
if !ok {
pipe.HDel(ctx, dueKey, fieldDue+checkType)
continue
}
values[fieldDue+checkType] = due.UnixMilli()
if earliest.IsZero() || due.Before(earliest) {
earliest = due
}
}
pipe.HSet(ctx, dueKey, values)
_, err = pipe.Exec(ctx)
if err != nil {
return err
}
priorityKey := constants.RedisCarrierPriorityQueueKey(carrierID)
if inPriority, _ := s.redis.ZScore(ctx, priorityKey, strconv.FormatUint(uint64(cardID), 10)).Result(); inPriority > 0 {
return s.redis.ZAdd(ctx, priorityKey, redis.Z{Score: float64(earliest.UnixMilli()), Member: cardID}).Err()
}
return s.redis.ZAdd(ctx, constants.RedisCarrierQueueKey(carrierID), redis.Z{Score: float64(earliest.UnixMilli()), Member: cardID}).Err()
}
// CardDue 是卡到期信息的解析结果。
type CardDue struct {
CarrierID uint
Priority bool
Types map[string]TypeDue
}
// TypeDue 是卡某检查类型的到期与失败信息。
type TypeDue struct {
Due time.Time
HasDue bool
LastOK time.Time
FailCount int
FirstFailed time.Time
LastError string
Confirm []time.Duration
Expected string
}
// ReadDue 读取并解析卡到期信息;Hash 不存在时返回空结构。
func (s *Store) ReadDue(ctx context.Context, cardID uint) (CardDue, error) {
raw, err := s.redis.HGetAll(ctx, constants.RedisCardDueKey(cardID)).Result()
if err != nil {
return CardDue{}, err
}
return ParseDue(raw), nil
}
// ParseDue 解析卡到期 Hash。
func ParseDue(raw map[string]string) CardDue {
result := CardDue{Types: map[string]TypeDue{}}
if value, err := strconv.ParseUint(raw[fieldCarrier], 10, 64); err == nil {
result.CarrierID = uint(value)
}
result.Priority = raw[fieldPriority] == "1"
for _, checkType := range constants.AllCheckTypes() {
item := TypeDue{}
if ms, err := strconv.ParseInt(raw[fieldDue+checkType], 10, 64); err == nil {
item.Due, item.HasDue = time.UnixMilli(ms), true
}
if ms, err := strconv.ParseInt(raw[fieldOK+checkType], 10, 64); err == nil && ms > 0 {
item.LastOK = time.UnixMilli(ms)
}
item.FailCount, _ = strconv.Atoi(raw[fieldFail+checkType])
if ms, err := strconv.ParseInt(raw[fieldFirstFail+checkType], 10, 64); err == nil && ms > 0 {
item.FirstFailed = time.UnixMilli(ms)
}
item.LastError = raw[fieldError+checkType]
for _, part := range strings.Split(raw[fieldConfirm+checkType], ",") {
if seconds, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && seconds > 0 {
item.Confirm = append(item.Confirm, time.Duration(seconds)*time.Second)
}
}
item.Expected = raw[fieldExpected+checkType]
result.Types[checkType] = item
}
return result
}
// Field 名称导出给执行器组装收尾写入。
func FieldDue(checkType string) string { return fieldDue + checkType }
func FieldOK(checkType string) string { return fieldOK + checkType }
func FieldFail(checkType string) string { return fieldFail + checkType }
func FieldFirstFail(checkType string) string { return fieldFirstFail + checkType }
func FieldError(checkType string) string { return fieldError + checkType }
func FieldConfirm(checkType string) string { return fieldConfirm + checkType }
func FieldExpected(checkType string) string { return fieldExpected + checkType }