Add distributed deployment support for snowflake ID (#927)

* feat: Add support distributed deployment for Snowflake

* Update the algorithm implementation

* Remove duplicate codes and update error messages
This commit is contained in:
Dylan
2025-11-19 12:48:24 +08:00
committed by GitHub
parent 551dc51c52
commit 16bcaf963d
4 changed files with 190 additions and 64 deletions
+2 -2
View File
@@ -70,6 +70,6 @@ class UploadUrl:
class SnowflakeInfo: class SnowflakeInfo:
timestamp: int timestamp: int
datetime: str datetime: str
cluster_id: int datacenter_id: int
node_id: int worker_id: int
sequence: int sequence: int
+9
View File
@@ -52,6 +52,15 @@ class Settings(BaseSettings):
# Redis # Redis
REDIS_TIMEOUT: int = 5 REDIS_TIMEOUT: int = 5
# .env Snowflake
SNOWFLAKE_DATACENTER_ID: int | None = None
SNOWFLAKE_WORKER_ID: int | None = None
# Snowflake
SNOWFLAKE_REDIS_PREFIX: str = 'fba:snowflake'
SNOWFLAKE_HEARTBEAT_INTERVAL_SECONDS: int = 30
SNOWFLAKE_NODE_TTL_SECONDS: int = 60
# .env Token # .env Token
TOKEN_SECRET_KEY: str # 密钥 secrets.token_urlsafe(32) TOKEN_SECRET_KEY: str # 密钥 secrets.token_urlsafe(32)
+7
View File
@@ -34,6 +34,7 @@ from backend.utils.demo_site import demo_site
from backend.utils.health_check import ensure_unique_route_names, http_limit_callback from backend.utils.health_check import ensure_unique_route_names, http_limit_callback
from backend.utils.openapi import simplify_operation_ids from backend.utils.openapi import simplify_operation_ids
from backend.utils.serializers import MsgSpecJSONResponse from backend.utils.serializers import MsgSpecJSONResponse
from backend.utils.snowflake import snowflake
@asynccontextmanager @asynccontextmanager
@@ -57,11 +58,17 @@ async def register_init(app: FastAPI) -> AsyncGenerator[None, None]:
http_callback=http_limit_callback, http_callback=http_limit_callback,
) )
# 初始化 snowflake 节点
await snowflake.init()
# 创建操作日志任务 # 创建操作日志任务
create_task(OperaLogMiddleware.consumer()) create_task(OperaLogMiddleware.consumer())
yield yield
# 释放 snowflake 节点
await snowflake.shutdown()
# 关闭 redis 连接 # 关闭 redis 连接
await redis_client.aclose() await redis_client.aclose()
+172 -62
View File
@@ -1,15 +1,22 @@
import asyncio
import datetime
import os
import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from backend.common.dataclasses import SnowflakeInfo from backend.common.dataclasses import SnowflakeInfo
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.log import log
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.timezone import timezone
@dataclass(frozen=True) @dataclass(frozen=True)
class SnowflakeConfig: class SnowflakeConfig:
"""雪花算法配置类""" """雪花算法配置类,采用 Twitter 原版 Snowflake 64 位 ID 位分配配置(通用标准)"""
# 位分配 # 位分配
WORKER_ID_BITS: int = 5 WORKER_ID_BITS: int = 5
@@ -29,81 +36,184 @@ class SnowflakeConfig:
# 元年时间戳 # 元年时间戳
EPOCH: int = 1262275200000 EPOCH: int = 1262275200000
# 默认值 # 时钟回拨容忍阈值,应对 NTP 自动同步引起的正常回跳(非标准)
DEFAULT_DATACENTER_ID: int = 1 CLOCK_BACKWARD_TOLERANCE_MS: int = 10_000
DEFAULT_WORKER_ID: int = 0
DEFAULT_SEQUENCE: int = 0
class SnowflakeNodeManager:
"""雪花算法节点管理器,负责从 Redis 分配和管理节点 ID"""
def __init__(self) -> None:
"""初始化节点管理器"""
self.datacenter_id: int | None = None
self.worker_id: int | None = None
self.node_redis_prefix: str = f'{settings.SNOWFLAKE_REDIS_PREFIX}:nodes'
self._heartbeat_task: asyncio.Task | None = None
async def acquire_node_id(self) -> tuple[int, int]:
"""从 Redis 获取可用的 datacenter_id 和 worker_id"""
occupied_nodes = set()
async for key in redis_client.scan_iter(match=f'{self.node_redis_prefix}:*'):
parts = key.split(':')
if len(parts) >= 5:
try:
datacenter_id = int(parts[-2])
worker_id = int(parts[-1])
occupied_nodes.add((datacenter_id, worker_id))
except ValueError:
continue
# 顺序查找第一个可用的 ID 组合
for datacenter_id in range(SnowflakeConfig.MAX_DATACENTER_ID + 1):
for worker_id in range(SnowflakeConfig.MAX_WORKER_ID + 1):
if (datacenter_id, worker_id) not in occupied_nodes and await self._register(datacenter_id, worker_id):
return datacenter_id, worker_id
raise errors.ServerError(msg='无可用的雪花算法节点,节点已耗尽')
async def _register(self, datacenter_id: int, worker_id: int) -> bool:
key = f'{self.node_redis_prefix}:{datacenter_id}:{worker_id}'
value = f'pid:{os.getpid()}-ts:{timezone.now().timestamp()}'
return await redis_client.set(key, value, nx=True, ex=settings.SNOWFLAKE_NODE_TTL_SECONDS)
async def start_heartbeat(self, datacenter_id: int, worker_id: int) -> None:
"""启动节点心跳"""
self.datacenter_id = datacenter_id
self.worker_id = worker_id
async def heartbeat() -> None:
key = f'{self.node_redis_prefix}:{datacenter_id}:{worker_id}'
while True:
await asyncio.sleep(settings.SNOWFLAKE_HEARTBEAT_INTERVAL_SECONDS)
try:
await redis_client.expire(key, settings.SNOWFLAKE_NODE_TTL_SECONDS)
log.debug(f'雪花算法节点心跳任务开始:datacenter_id={datacenter_id}, worker_id={worker_id}')
except Exception as e:
log.error(f'雪花算法节点心跳任务失败:{e}')
self._heartbeat_task = asyncio.create_task(heartbeat())
async def release(self) -> None:
"""释放节点"""
if self._heartbeat_task:
self._heartbeat_task.cancel()
try:
await self._heartbeat_task
except asyncio.CancelledError:
log.debug(f'雪花算法节点心跳任务释放:datacenter_id={self.datacenter_id}, worker_id={self.worker_id}')
if self.datacenter_id is not None and self.worker_id is not None:
key = f'{self.node_redis_prefix}:{self.datacenter_id}:{self.worker_id}'
await redis_client.delete(key)
class Snowflake: class Snowflake:
"""雪花算法类""" """雪花算法类"""
def __init__( def __init__(self) -> None:
self, """初始化雪花算法"""
cluster_id: int = SnowflakeConfig.DEFAULT_DATACENTER_ID, self.datacenter_id: int | None = None
node_id: int = SnowflakeConfig.DEFAULT_WORKER_ID, self.worker_id: int | None = None
sequence: int = SnowflakeConfig.DEFAULT_SEQUENCE, self.sequence: int = 0
) -> None: self.last_timestamp: int = -1
"""
初始化雪花算法生成器
:param cluster_id: 集群 ID (0-31) self._lock = threading.Lock()
:param node_id: 节点 ID (0-31) self._initialized = False
:param sequence: 起始序列号 self._node_manager: SnowflakeNodeManager | None = None
""" self._auto_allocated = False # 标记是否由 Redis 自动分配 ID
if cluster_id < 0 or cluster_id > SnowflakeConfig.MAX_DATACENTER_ID:
raise errors.RequestError(msg=f'集群编号必须在 0-{SnowflakeConfig.MAX_DATACENTER_ID} 之间')
if node_id < 0 or node_id > SnowflakeConfig.MAX_WORKER_ID:
raise errors.RequestError(msg=f'节点编号必须在 0-{SnowflakeConfig.MAX_WORKER_ID} 之间')
self.node_id = node_id async def init(self) -> None:
self.cluster_id = cluster_id """初始化雪花算法"""
self.sequence = sequence if self._initialized:
self.last_timestamp = -1 return
with self._lock:
# 环境变量固定分配
if settings.SNOWFLAKE_DATACENTER_ID is not None and settings.SNOWFLAKE_WORKER_ID is not None:
self.datacenter_id = settings.SNOWFLAKE_DATACENTER_ID
self.worker_id = settings.SNOWFLAKE_WORKER_ID
log.debug(
f'雪花算法使用环境变量固定节点:datacenter_id={self.datacenter_id}, worker_id={self.worker_id}'
)
elif (settings.SNOWFLAKE_DATACENTER_ID is not None and settings.SNOWFLAKE_WORKER_ID is None) or (
settings.SNOWFLAKE_DATACENTER_ID is None and settings.SNOWFLAKE_WORKER_ID is not None
):
log.error('雪花算法 datacenter_id 和 worker_id 配置错误,只允许同时非 None 或同时为 None')
raise errors.ServerError(msg='雪花算法配置失败,请联系系统管理员')
else:
# Redis 动态分配
self._node_manager = SnowflakeNodeManager()
self.datacenter_id, self.worker_id = await self._node_manager.acquire_node_id()
self._auto_allocated = True
await self._node_manager.start_heartbeat(self.datacenter_id, self.worker_id)
log.debug(
f'雪花算法使用 Redis 动态分配节点:datacenter_id={self.datacenter_id}, worker_id={self.worker_id}'
)
# 严格校验范围
if not (0 <= self.datacenter_id <= SnowflakeConfig.MAX_DATACENTER_ID):
log.error(f'雪花算法 datacenter_id 配置失败,必须在 0~{SnowflakeConfig.MAX_DATACENTER_ID} 之间')
raise errors.ServerError(msg='雪花算法数据中心配置失败,请联系系统管理员')
if not (0 <= self.worker_id <= SnowflakeConfig.MAX_WORKER_ID):
log.error(f'雪花算法 worker_id 配置失败,必须在 0~{SnowflakeConfig.MAX_WORKER_ID} 之间')
raise errors.ServerError(msg='雪花算法工作机器配置失败,请联系系统管理员')
self._initialized = True
async def shutdown(self) -> None:
"""释放 Redis 节点"""
if self._node_manager and self._auto_allocated:
await self._node_manager.release()
@staticmethod @staticmethod
def _current_millis() -> int: def _current_ms() -> int:
"""返回当前毫秒时间戳""" return int(timezone.now().timestamp() * 1000)
return int(time.time() * 1000)
def _next_millis(self, last_timestamp: int) -> int: def _till_next_ms(self, last_timestamp: int) -> int:
""" """等待直到下一毫秒"""
等待至下一毫秒 ts = self._current_ms()
while ts <= last_timestamp:
:param last_timestamp: 上次生成 ID 的时间戳 time.sleep(0.0001)
:return: ts = self._current_ms()
""" return ts
timestamp = self._current_millis()
while timestamp <= last_timestamp:
time.sleep((last_timestamp - timestamp + 1) / 1000.0)
timestamp = self._current_millis()
return timestamp
def generate(self) -> int: def generate(self) -> int:
"""生成雪花 ID""" """生成雪花 ID"""
timestamp = self._current_millis() if not self._initialized:
raise errors.ServerError(msg='雪花 ID 生成失败,雪花算法未初始化')
if timestamp < self.last_timestamp: with self._lock:
raise errors.ServerError(msg=f'系统时间倒退,拒绝生成 ID 直到 {self.last_timestamp}') timestamp = self._current_ms()
if timestamp == self.last_timestamp: # 时钟回拨处理
self.sequence = (self.sequence + 1) & SnowflakeConfig.SEQUENCE_MASK if timestamp < self.last_timestamp:
if self.sequence == 0: back_ms = self.last_timestamp - timestamp
timestamp = self._next_millis(self.last_timestamp) if back_ms <= SnowflakeConfig.CLOCK_BACKWARD_TOLERANCE_MS:
else: log.warning(f'检测到时钟回拨 {back_ms} ms,等待恢复...')
self.sequence = 0 timestamp = self._till_next_ms(self.last_timestamp)
else:
raise errors.ServerError(msg=f'雪花 ID 生成失败,时钟回拨超过 {back_ms} ms,请立即联系系统管理员')
self.last_timestamp = timestamp # 同毫秒内序列号递增
if timestamp == self.last_timestamp:
self.sequence = (self.sequence + 1) & SnowflakeConfig.SEQUENCE_MASK
if self.sequence == 0:
timestamp = self._till_next_ms(self.last_timestamp)
else:
self.sequence = 0
return ( self.last_timestamp = timestamp
((timestamp - SnowflakeConfig.EPOCH) << SnowflakeConfig.TIMESTAMP_LEFT_SHIFT)
| (self.cluster_id << SnowflakeConfig.DATACENTER_ID_SHIFT) # 组合 64 位 ID
| (self.node_id << SnowflakeConfig.WORKER_ID_SHIFT) return (
| self.sequence ((timestamp - SnowflakeConfig.EPOCH) << SnowflakeConfig.TIMESTAMP_LEFT_SHIFT)
) | (self.datacenter_id << SnowflakeConfig.DATACENTER_ID_SHIFT)
| (self.worker_id << SnowflakeConfig.WORKER_ID_SHIFT)
| self.sequence
)
@staticmethod @staticmethod
def parse_id(snowflake_id: int) -> SnowflakeInfo: def parse(snowflake_id: int) -> SnowflakeInfo:
""" """
解析雪花 ID,获取其包含的详细信息 解析雪花 ID,获取其包含的详细信息
@@ -111,15 +221,15 @@ class Snowflake:
:return: :return:
""" """
timestamp = (snowflake_id >> SnowflakeConfig.TIMESTAMP_LEFT_SHIFT) + SnowflakeConfig.EPOCH timestamp = (snowflake_id >> SnowflakeConfig.TIMESTAMP_LEFT_SHIFT) + SnowflakeConfig.EPOCH
cluster_id = (snowflake_id >> SnowflakeConfig.DATACENTER_ID_SHIFT) & SnowflakeConfig.MAX_DATACENTER_ID datacenter_id = (snowflake_id >> SnowflakeConfig.DATACENTER_ID_SHIFT) & SnowflakeConfig.MAX_DATACENTER_ID
node_id = (snowflake_id >> SnowflakeConfig.WORKER_ID_SHIFT) & SnowflakeConfig.MAX_WORKER_ID worker_id = (snowflake_id >> SnowflakeConfig.WORKER_ID_SHIFT) & SnowflakeConfig.MAX_WORKER_ID
sequence = snowflake_id & SnowflakeConfig.SEQUENCE_MASK sequence = snowflake_id & SnowflakeConfig.SEQUENCE_MASK
return SnowflakeInfo( return SnowflakeInfo(
timestamp=timestamp, timestamp=timestamp,
datetime=time.strftime(settings.DATETIME_FORMAT, time.localtime(timestamp / 1000)), datetime=timezone.to_str(datetime.datetime.fromtimestamp(timestamp / 1000, timezone.tz_info)),
cluster_id=cluster_id, datacenter_id=datacenter_id,
node_id=node_id, worker_id=worker_id,
sequence=sequence, sequence=sequence,
) )