2026-05-12 20:07:49 +08:00

266 lines
7.5 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 router 维护 room-service 的房间 owner 路由目录和 lease 语义。
package router
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"hyapp/pkg/idgen"
)
// roomRouteKeyPrefix 是生产房间 owner lease 的 Redis key 前缀。
const roomRouteKeyPrefix = "room:route:"
// healthLeaseKey 是 ready 探针专用 key避免探针污染真实房间路由。
const healthLeaseKey = "room:healthcheck:lease"
const ensureOwnerScript = `
local current = redis.call("GET", KEYS[1])
if not current then
redis.call("SET", KEYS[1], ARGV[3], "PX", ARGV[2])
return ARGV[3]
end
local ok, decoded = pcall(cjson.decode, current)
if not ok then
redis.call("SET", KEYS[1], ARGV[3], "PX", ARGV[2])
return ARGV[3]
end
local expires_at_ms = tonumber(decoded["expires_at_ms"]) or 0
if decoded["node_id"] == ARGV[4] and expires_at_ms > tonumber(ARGV[1]) then
local next_ok, next_decoded = pcall(cjson.decode, ARGV[3])
if not next_ok then
redis.call("SET", KEYS[1], ARGV[3], "PX", ARGV[2])
return ARGV[3]
end
decoded["room_id"] = next_decoded["room_id"]
decoded["expires_at_ms"] = next_decoded["expires_at_ms"]
local refreshed = cjson.encode(decoded)
redis.call("SET", KEYS[1], refreshed, "PX", ARGV[2])
return refreshed
end
if expires_at_ms <= tonumber(ARGV[1]) then
redis.call("SET", KEYS[1], ARGV[3], "PX", ARGV[2])
return ARGV[3]
end
return current
`
const verifyOwnerScript = `
local current = redis.call("GET", KEYS[1])
if not current then
return 0
end
local ok, decoded = pcall(cjson.decode, current)
if not ok then
return 0
end
if decoded["node_id"] == ARGV[2] and decoded["lease_token"] == ARGV[3] and tonumber(decoded["expires_at_ms"]) > tonumber(ARGV[1]) then
return 1
end
return 0
`
// RedisDirectory 用 Redis 保存 room_id 到执行节点的带 TTL 租约。
type RedisDirectory struct {
// client 是 go-redis 客户端,连接生命周期由 app.App 关闭。
client *redis.Client
}
// redisLeaseValue 是 Redis JSON 载荷,字段名稳定便于排查和跨语言工具读取。
type redisLeaseValue struct {
RoomID string `json:"room_id"`
NodeID string `json:"node_id"`
LeaseToken string `json:"lease_token"`
ExpiresAt int64 `json:"expires_at_ms"`
}
// NewRedisDirectory 创建 Redis 目录实现。
func NewRedisDirectory(client *redis.Client) *RedisDirectory {
return &RedisDirectory{client: client}
}
// NewRedisClient 创建 Redis 客户端,并验证连接可用。
func NewRedisClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
// Redis 只保存热路由/lease不作为房间恢复的唯一来源。
client := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
if err := client.Ping(ctx).Err(); err != nil {
// 启动探测失败时关闭客户端,避免调用方遗留半初始化连接。
_ = client.Close()
return nil, err
}
return client, nil
}
// Ping 验证 Redis 当前可访问,调用方负责传入短 deadline。
func (d *RedisDirectory) Ping(ctx context.Context) error {
// ready 探针调用该方法确认 Redis 基础连通性。
return d.client.Ping(ctx).Err()
}
// ProbeLease 使用与房间归属相同的 Lua 路径探测 Redis lease 原子操作能力。
func (d *RedisDirectory) ProbeLease(ctx context.Context, nodeID string, now time.Time, ttl time.Duration) error {
// 探针必须走同一段 Lua才能发现 Eval 权限或 cjson 支持问题。
next := Lease{
RoomID: "__healthcheck__",
NodeID: nodeID,
LeaseToken: idgen.New("lease"),
ExpiresAt: now.Add(ttl),
}
payload, err := encodeLease(next)
if err != nil {
return err
}
// Lua 脚本保证 read-check-set 原子化,避免 GET + SET 竞争导致双 owner。
value, err := d.client.Eval(ctx,
ensureOwnerScript,
[]string{healthLeaseKey},
strconv.FormatInt(now.UnixMilli(), 10),
strconv.FormatInt(ttl.Milliseconds(), 10),
payload,
nodeID,
).Text()
if err != nil {
return err
}
// 返回值可能是已有租约或新租约,能解码即可证明脚本路径正常。
_, err = decodeLease(value)
return err
}
// Lookup 查询房间当前 owner 租约。
func (d *RedisDirectory) Lookup(ctx context.Context, roomID string) (Lease, bool, error) {
value, err := d.client.Get(ctx, routeKey(roomID)).Result()
if err != nil {
if err == redis.Nil {
// key 不存在表示没有当前 owner。
return Lease{}, false, nil
}
return Lease{}, false, err
}
lease, err := decodeLease(value)
if err != nil {
// Redis 值损坏时不能静默接管,否则可能破坏单活。
return Lease{}, false, err
}
return lease, true, nil
}
// EnsureOwner 在租约不存在、过期或归当前节点时授予本节点执行权。
func (d *RedisDirectory) EnsureOwner(ctx context.Context, roomID string, nodeID string, now time.Time, ttl time.Duration) (Lease, error) {
// next 是当前节点希望写入的租约Lua 会决定是否真正采用它。
// 同节点续租时 Lua 会保留现有 lease_token只刷新 expires_at_ms
// 避免后台 worker 续租把正在执行的同节点命令误判为旧 owner。
next := Lease{
RoomID: roomID,
NodeID: nodeID,
LeaseToken: idgen.New("lease"),
ExpiresAt: now.Add(ttl),
}
payload, err := encodeLease(next)
if err != nil {
return Lease{}, err
}
// Redis TTL 和 payload.expires_at_ms 同时写入TTL 用于自动清理payload 用于脚本判断。
value, err := d.client.Eval(ctx,
ensureOwnerScript,
[]string{routeKey(roomID)},
strconv.FormatInt(now.UnixMilli(), 10),
strconv.FormatInt(ttl.Milliseconds(), 10),
payload,
nodeID,
).Text()
if err != nil {
return Lease{}, err
}
lease, err := decodeLease(value)
if err != nil {
// 返回值无法解码时调用方不能确认 owner必须失败。
return Lease{}, err
}
return lease, nil
}
// VerifyOwner 校验调用方持有的 lease token 仍是当前有效 owner。
func (d *RedisDirectory) VerifyOwner(ctx context.Context, roomID string, nodeID string, leaseToken string, now time.Time) (bool, error) {
value, err := d.client.Eval(ctx,
verifyOwnerScript,
[]string{routeKey(roomID)},
strconv.FormatInt(now.UnixMilli(), 10),
nodeID,
leaseToken,
).Int()
if err != nil {
return false, err
}
return value == 1, nil
}
// ForceExpire 只服务测试,生产启动路径不会调用。
func (d *RedisDirectory) ForceExpire(roomID string) {
// Redis 实现直接删除 key 来模拟 lease 过期,避免等待真实 TTL。
_ = d.client.Del(context.Background(), routeKey(roomID)).Err()
}
func routeKey(roomID string) string {
// 路由 key 只由房间 ID 决定,所有节点必须计算出同一个 Redis key。
return roomRouteKeyPrefix + roomID
}
func encodeLease(lease Lease) (string, error) {
// 使用 JSON 而不是二进制,方便线上通过 redis-cli 直接排查 owner。
payload, err := json.Marshal(redisLeaseValue{
RoomID: lease.RoomID,
NodeID: lease.NodeID,
LeaseToken: lease.LeaseToken,
ExpiresAt: lease.ExpiresAt.UnixMilli(),
})
if err != nil {
return "", err
}
return string(payload), nil
}
func decodeLease(value string) (Lease, error) {
var payload redisLeaseValue
if err := json.Unmarshal([]byte(value), &payload); err != nil {
// 损坏值直接返回错误,让上层 ready 或命令路径暴露问题。
return Lease{}, err
}
// Redis 中保存毫秒时间戳,恢复为 time.Time 便于 ValidAt 判断。
return Lease{
RoomID: payload.RoomID,
NodeID: payload.NodeID,
LeaseToken: payload.LeaseToken,
ExpiresAt: time.UnixMilli(payload.ExpiresAt),
}, nil
}