"""pricebot 上游实例选择:按 trace_id 一致性 hash(ketama 风格)选实例。 单机多进程下,保证同一 trace_id(一次比价/领券的所有帧)落同一 pricebot 进程, 从而用进程内内存维护 session/coordinator,不需要 Redis。 为什么用一致性 hash 而非简单取模(crc32 % N): 加/减实例时,取模会让几乎所有 trace 重新映射(扩缩容全量中断进行中的比价); 一致性 hash(虚拟节点)只重映射约 1/N 的 trace,扩缩容对存量冲击最小。 ⚠️ 但内存态下,被重映射的那 1/N trace 仍会丢失 session(状态没外置), 所以扩缩容仍建议挑低峰。 hash 用 md5(纯函数),app-server 多 worker / 多实例算出的环一致,亲和不会因 app-server 自身水平扩展而被破坏。 """ from __future__ import annotations import bisect import hashlib from typing import Dict, List, Optional from app.core.config import settings # 每个真实实例在 hash 环上的虚拟节点数。越多分布越均匀。实例数少时(6~10)需要更多 # 虚拟节点才均匀:实测 150→负载偏差~16%、1000→~6%。环只在启动/实例变更时构建一次, # 1000×N 个点的构建与 bisect 查找开销都可忽略。 _VNODES_PER_NODE = 1000 def _hash(s: str) -> int: """取 md5 前 8 hex(32-bit)做环坐标。确定性、跨进程一致。""" return int(hashlib.md5(s.encode("utf-8")).hexdigest()[:8], 16) class _HashRing: """ketama 风格一致性 hash 环。不可变,实例列表变化时整体重建(见 _get_ring)。""" def __init__(self, nodes: List[str]): self._keys: List[int] = [] # 升序的环坐标 self._key_to_node: Dict[int, str] = {} for node in nodes: for v in range(_VNODES_PER_NODE): h = _hash(f"{node}#{v}") # 极小概率撞坐标,撞了跳过该虚拟节点(不影响正确性,仅少一个 vnode) if h not in self._key_to_node: self._key_to_node[h] = node self._keys.append(h) self._keys.sort() def pick(self, key: str) -> str: h = _hash(key) idx = bisect.bisect(self._keys, h) if idx == len(self._keys): idx = 0 # 环回绕 return self._key_to_node[self._keys[idx]] # 按实例列表缓存环,列表变了(改 PRICEBOT_INSTANCES + 重启)才重建。 _ring: Optional[_HashRing] = None _ring_nodes: tuple = () def _get_ring(nodes: List[str]) -> _HashRing: global _ring, _ring_nodes key = tuple(nodes) if _ring is None or key != _ring_nodes: _ring = _HashRing(nodes) _ring_nodes = key return _ring def pick_pricebot(trace_id: Optional[str]) -> str: """按 trace_id 选 pricebot 实例 base url。 - 实例列表来自 settings.pricebot_instances(空则单实例兜底 [PRICEBOT_BASE_URL]) - trace_id 缺失/空 或 单实例 → 直接返回第一个,不进环 """ nodes = settings.pricebot_instances if len(nodes) == 1 or not trace_id: return nodes[0] # str() 防御:协议保证 trace_id 是 str,但万一传入非 str(如 int)也不至于在 # _hash 的 .encode() 处炸,确定性地选到实例。 return _get_ring(nodes).pick(str(trace_id))