389 lines
11 KiB
Go
389 lines
11 KiB
Go
// Package router 维护 room-service 的房间 owner 路由目录和 lease 语义。
|
||
package router
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
"hyapp/pkg/idgen"
|
||
)
|
||
|
||
// roomRouteKeyPrefix 是生产房间 owner lease 的 Redis key 前缀。
|
||
const roomRouteKeyPrefix = "room:route:"
|
||
|
||
// roomNodeKeyPrefix 保存 room-service 节点的实例直连地址,owner 转发只读取该短租约。
|
||
const roomNodeKeyPrefix = "room:node:"
|
||
|
||
// 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
|
||
`
|
||
|
||
const releaseOwnerScript = `
|
||
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[1] then
|
||
redis.call("DEL", KEYS[1])
|
||
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"`
|
||
}
|
||
|
||
// redisNodeValue 是 Redis 中的节点注册 JSON,字段保持可读以便 redis-cli 排查。
|
||
type redisNodeValue struct {
|
||
NodeID string `json:"node_id"`
|
||
GRPCAddr string `json:"grpc_addr"`
|
||
UpdatedAt int64 `json:"updated_at_ms"`
|
||
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
|
||
}
|
||
|
||
// ReleaseOwner 在节点安全下线后删除自己持有的 route lease。
|
||
func (d *RedisDirectory) ReleaseOwner(ctx context.Context, roomID string, nodeID string) (bool, error) {
|
||
value, err := d.client.Eval(ctx, releaseOwnerScript, []string{routeKey(roomID)}, nodeID).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()
|
||
}
|
||
|
||
// RegisterNode 写入当前 room-service 实例地址;TTL 过期后 owner 转发不会再使用该节点。
|
||
func (d *RedisDirectory) RegisterNode(ctx context.Context, node NodeRegistration, ttl time.Duration) error {
|
||
if ttl <= 0 {
|
||
ttl = 30 * time.Second
|
||
}
|
||
node.NodeID = strings.TrimSpace(node.NodeID)
|
||
node.GRPCAddr = strings.TrimSpace(node.GRPCAddr)
|
||
if node.NodeID == "" || node.GRPCAddr == "" {
|
||
return fmt.Errorf("node_id and grpc_addr are required")
|
||
}
|
||
now := time.Now().UTC()
|
||
node.UpdatedAt = now
|
||
node.ExpiresAt = now.Add(ttl)
|
||
payload, err := encodeNode(node)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return d.client.Set(ctx, nodeKey(node.NodeID), payload, ttl).Err()
|
||
}
|
||
|
||
// LookupNode 查询 owner node_id 对应的实例地址。
|
||
func (d *RedisDirectory) LookupNode(ctx context.Context, nodeID string) (NodeRegistration, bool, error) {
|
||
value, err := d.client.Get(ctx, nodeKey(nodeID)).Result()
|
||
if err != nil {
|
||
if err == redis.Nil {
|
||
return NodeRegistration{}, false, nil
|
||
}
|
||
|
||
return NodeRegistration{}, false, err
|
||
}
|
||
|
||
node, err := decodeNode(value)
|
||
if err != nil {
|
||
return NodeRegistration{}, false, err
|
||
}
|
||
if !node.ValidAt(time.Now().UTC()) {
|
||
return NodeRegistration{}, false, nil
|
||
}
|
||
|
||
return node, true, nil
|
||
}
|
||
|
||
// UnregisterNode 删除节点注册;下线节点停止接受 owner 转发前必须调用。
|
||
func (d *RedisDirectory) UnregisterNode(ctx context.Context, nodeID string) error {
|
||
return d.client.Del(ctx, nodeKey(nodeID)).Err()
|
||
}
|
||
|
||
func routeKey(roomID string) string {
|
||
// 路由 key 只由房间 ID 决定,所有节点必须计算出同一个 Redis key。
|
||
return roomRouteKeyPrefix + roomID
|
||
}
|
||
|
||
func nodeKey(nodeID string) string {
|
||
// 节点 key 使用 node_id,不包含 app_code;一个实例可以服务多个 App 的房间。
|
||
return roomNodeKeyPrefix + strings.TrimSpace(nodeID)
|
||
}
|
||
|
||
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 encodeNode(node NodeRegistration) (string, error) {
|
||
payload, err := json.Marshal(redisNodeValue{
|
||
NodeID: strings.TrimSpace(node.NodeID),
|
||
GRPCAddr: strings.TrimSpace(node.GRPCAddr),
|
||
UpdatedAt: node.UpdatedAt.UnixMilli(),
|
||
ExpiresAt: node.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
|
||
}
|
||
|
||
func decodeNode(value string) (NodeRegistration, error) {
|
||
var payload redisNodeValue
|
||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||
return NodeRegistration{}, err
|
||
}
|
||
|
||
return NodeRegistration{
|
||
NodeID: strings.TrimSpace(payload.NodeID),
|
||
GRPCAddr: strings.TrimSpace(payload.GRPCAddr),
|
||
UpdatedAt: time.UnixMilli(payload.UpdatedAt),
|
||
ExpiresAt: time.UnixMilli(payload.ExpiresAt),
|
||
}, nil
|
||
}
|