feat(runner): implement cluster mode with Redis-based task dispatching and registry
Introduce a distributed task execution system for gRPC runners. This includes: - A `TaskBus` for cluster-mode task dispatching via Redis Pub/Sub. - A `RunnerRegistry` to track active runner instances in Redis. - Support for both standalone and cluster modes via configuration. - Refactored `RunnerHub` and `TaskManager` to use a `TaskDispatcher` interface, decoupling task submission from local execution. - Added distributed locking to `HotRankWorker` to ensure only one instance performs periodic hot rank recalculations in a multi-instance deployment. - Updated dependency injection (Wire) to support the new runner components and registry.
This commit is contained in:
@@ -13,7 +13,7 @@ import (
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/repository"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
redislib "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -23,29 +23,84 @@ type hotRankScored struct {
|
||||
}
|
||||
|
||||
// HotRankWorker 定时在候选池上重算热门分,写入 Redis ZSET(仅 TopN)及 top_ids(Redis+本地缓存)
|
||||
// 多实例部署时通过 Redis 分布式锁保证仅主实例执行定时刷新,避免重复计算。
|
||||
type HotRankWorker struct {
|
||||
cfg *config.Config
|
||||
postRepo repository.PostRepository
|
||||
channelRepo repository.ChannelRepository
|
||||
c cache.Cache
|
||||
rdb *redislib.Client // 用于分布式锁(可为 nil,降级为本地运行)
|
||||
mu sync.Mutex
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
}
|
||||
|
||||
func NewHotRankWorker(cfg *config.Config, postRepo repository.PostRepository, channelRepo repository.ChannelRepository, c cache.Cache) *HotRankWorker {
|
||||
const (
|
||||
// hotRankLockKey Redis 分布式锁键
|
||||
hotRankLockKey = "hot_rank:leader_lock"
|
||||
// hotRankLockTTL 锁持有时间(略大于刷新间隔,确保单实例持有)
|
||||
hotRankLockTTL = 5 * time.Minute
|
||||
// hotRankLockRetryInterval 竞争锁的重试间隔
|
||||
hotRankLockRetryInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
func NewHotRankWorker(cfg *config.Config, postRepo repository.PostRepository, channelRepo repository.ChannelRepository, c cache.Cache, rdb *redislib.Client) *HotRankWorker {
|
||||
return &HotRankWorker{
|
||||
cfg: cfg,
|
||||
postRepo: postRepo,
|
||||
channelRepo: channelRepo,
|
||||
c: c,
|
||||
rdb: rdb,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动定时任务(启动时立即执行一次)
|
||||
// tryAcquireLock 尝试获取分布式锁,返回是否成功
|
||||
func (w *HotRankWorker) tryAcquireLock(ctx context.Context) bool {
|
||||
if w.rdb == nil {
|
||||
return true // 无 Redis 时降级为本地运行
|
||||
}
|
||||
ok, err := w.rdb.SetNX(ctx, hotRankLockKey, "1", hotRankLockTTL).Result()
|
||||
if err != nil {
|
||||
zap.L().Warn("hot rank: failed to acquire leader lock, running locally", zap.Error(err))
|
||||
return true // Redis 出错时降级为本地运行
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// keepLockAlive 周期性续期分布式锁
|
||||
func (w *HotRankWorker) keepLockAlive(ctx context.Context) {
|
||||
if w.rdb == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(hotRankLockTTL / 2)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-w.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.rdb.Expire(ctx, hotRankLockKey, hotRankLockTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// releaseLock 释放分布式锁
|
||||
func (w *HotRankWorker) releaseLock() {
|
||||
if w.rdb == nil {
|
||||
return
|
||||
}
|
||||
w.rdb.Del(context.Background(), hotRankLockKey)
|
||||
}
|
||||
|
||||
// Start 启动定时任务
|
||||
// 多实例部署时通过 Redis 分布式锁竞争主实例角色:
|
||||
// - 获得锁的实例负责执行定时刷新,并周期续期
|
||||
// - 未获得锁的实例定期重试竞争(当主实例宕机时可接管)
|
||||
func (w *HotRankWorker) Start(ctx context.Context) {
|
||||
if w == nil || w.cfg == nil || !w.cfg.HotRank.Enabled || w.c == nil {
|
||||
close(w.doneCh)
|
||||
@@ -59,25 +114,63 @@ func (w *HotRankWorker) Start(ctx context.Context) {
|
||||
go func() {
|
||||
defer close(w.doneCh)
|
||||
|
||||
if err := w.Refresh(ctx); err != nil {
|
||||
if !errors.Is(err, cache.ErrKeyNotFound) {
|
||||
zap.L().Warn("hot rank initial refresh failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
isLeader := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
if isLeader {
|
||||
w.releaseLock()
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := w.Refresh(ctx); err != nil {
|
||||
if !errors.Is(err, cache.ErrKeyNotFound) {
|
||||
zap.L().Warn("hot rank refresh failed", zap.Error(err))
|
||||
default:
|
||||
}
|
||||
|
||||
acquired := w.tryAcquireLock(ctx)
|
||||
if acquired {
|
||||
if !isLeader {
|
||||
zap.L().Info("hot rank: acquired leader lock, becoming primary")
|
||||
isLeader = true
|
||||
// 启动续期协程
|
||||
lockCtx, lockCancel := context.WithCancel(ctx)
|
||||
go w.keepLockAlive(lockCtx)
|
||||
|
||||
// 主实例立即执行一次刷新
|
||||
if err := w.Refresh(ctx); err != nil {
|
||||
if !errors.Is(err, cache.ErrKeyNotFound) {
|
||||
zap.L().Warn("hot rank initial refresh failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
lockCancel()
|
||||
w.releaseLock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := w.Refresh(ctx); err != nil {
|
||||
if !errors.Is(err, cache.ErrKeyNotFound) {
|
||||
zap.L().Warn("hot rank refresh failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 不可能走到这里(isLeader==true 的分支里有无限循环)
|
||||
} else {
|
||||
// 未获得锁,作为备用实例等待重试
|
||||
zap.L().Debug("hot rank: another instance holds leader lock, retrying later")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return
|
||||
case <-time.After(hotRankLockRetryInterval):
|
||||
// 重试竞争锁
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -92,6 +185,7 @@ func (w *HotRankWorker) Stop() {
|
||||
close(w.stopCh)
|
||||
})
|
||||
<-w.doneCh
|
||||
w.releaseLock()
|
||||
}
|
||||
|
||||
// Refresh 立即重算热门分(线程安全)
|
||||
@@ -240,15 +334,15 @@ func (w *HotRankWorker) Refresh(ctx context.Context) error {
|
||||
}
|
||||
|
||||
const pinnedScoreBase = 1000.0
|
||||
zMembers := make([]redis.Z, 0, len(orderedIDs))
|
||||
zMembers := make([]redislib.Z, 0, len(orderedIDs))
|
||||
for i, id := range pinned {
|
||||
zMembers = append(zMembers, redis.Z{
|
||||
zMembers = append(zMembers, redislib.Z{
|
||||
Score: pinnedScoreBase - float64(i)*1e-6,
|
||||
Member: id,
|
||||
})
|
||||
}
|
||||
for _, h := range hotPicks {
|
||||
zMembers = append(zMembers, redis.Z{Score: h.norm * 0.99, Member: h.id})
|
||||
zMembers = append(zMembers, redislib.Z{Score: h.norm * 0.99, Member: h.id})
|
||||
}
|
||||
|
||||
if err := w.c.ZReplaceSortedSet(ctx, zsetKey, zMembers); err != nil {
|
||||
@@ -448,15 +542,15 @@ func (w *HotRankWorker) refreshChannelRanks(
|
||||
}
|
||||
|
||||
const pinnedScoreBase = 1000.0
|
||||
chZMembers := make([]redis.Z, 0, len(chOrderedIDs))
|
||||
chZMembers := make([]redislib.Z, 0, len(chOrderedIDs))
|
||||
for i, id := range chPinned {
|
||||
chZMembers = append(chZMembers, redis.Z{
|
||||
chZMembers = append(chZMembers, redislib.Z{
|
||||
Score: pinnedScoreBase - float64(i)*1e-6,
|
||||
Member: id,
|
||||
})
|
||||
}
|
||||
for _, h := range hotPicks {
|
||||
chZMembers = append(chZMembers, redis.Z{Score: h.norm * 0.99, Member: h.id})
|
||||
chZMembers = append(chZMembers, redislib.Z{Score: h.norm * 0.99, Member: h.id})
|
||||
}
|
||||
|
||||
chZSetKey := cache.HotRankZSetChannelKey(ch.ID)
|
||||
|
||||
Reference in New Issue
Block a user