feat(ticker_stream, book_ticker_stream): 优化内存管理与Redis写入逻辑

在 `ticker_24h_stream.py` 和 `book_ticker_stream.py` 中引入新的内存管理机制,限制进程内缓存的最大条数为 500,避免内存无限增长。更新 Redis 写入逻辑,确保在有 Redis 时优先写入 Redis,而不在进程内存中常驻数据。通过定期从 Redis 拉取数据并合并,提升了系统的内存使用效率与稳定性,同时优化了日志记录以减少高负载时的输出频率。此改动进一步增强了系统性能与资源管理能力。
This commit is contained in:
薇薇安 2026-02-19 00:34:35 +08:00
parent a498520c51
commit f4feea6b87
3 changed files with 81 additions and 31 deletions

View File

@ -93,11 +93,24 @@ cd backend
- K 线缓存进程内存应该**接近 0**(只有降级时的少量数据)
- 内存占用应该**稳定**,不再持续增长
## 补充Ticker24h / BookTicker 进程内存(本次一并修复)
**原因**Leader 进程里 `_ticker_24h_cache`、`_book_ticker_cache` 一直随 WS 消息更新,全市场 200+ 交易对常驻进程内存,且每次写 Redis 前还 `dict(_ticker_24h_cache)` 做完整拷贝,加重内存和分配。
**修改**`ticker_24h_stream.py` / `book_ticker_stream.py`
- **有 Redis 时**WS 只做「从 Redis 读出 → 合并本批/本条 → 写回 Redis」**不再更新** `_ticker_24h_cache` / `_book_ticker_cache`
- 进程内缓存只由 **refresh 循环**(每 2 秒从 Redis 拉一次)回填,并限制最多 **500 条**,避免无限增长。
- **无 Redis 时**:仍只写进程内存,并同样限制 500 条。
这样 Leader 不再在进程里常驻全量 ticker/bookTicker也不为写 Redis 做整份拷贝。
---
## 其他可能的内存增长源
如果重启后内存仍在增长,检查:
若重启后内存仍涨,可再查:
1. **WebSocket 消息队列**:检查是否有消息堆积
2. **日志**:检查日志文件大小
3. **数据库连接**:检查连接池是否正常释放
4. **其他缓存**:检查 `_price_cache`、`_symbol_info_cache` 等是否正常清理
1. **WebSocket 消息队列**:是否有任务/消息堆积
2. **日志**:日志文件与 Redis 日志 handler 是否过
3. **数据库连接**连接池是否及时释放
4. **其他缓存**`_price_cache`、`_symbol_info_cache` 等是否有上限并定期淘汰

View File

@ -18,8 +18,10 @@ except ImportError:
KEY_BOOK_TICKER = "market:book_ticker"
# 最优挂单缓存symbol -> { bidPrice, bidQty, askPrice, askQty, time }
# 有 Redis 时由 refresh 循环从 Redis 回填,不在此无限累积
_book_ticker_cache: Dict[str, Dict[str, Any]] = {}
_book_ticker_updated_at: float = 0.0
_BOOK_TICKER_CACHE_MAX_KEYS = 500 # 进程内存最多保留 500 个
def get_book_ticker_cache() -> Dict[str, Dict[str, Any]]:
@ -159,7 +161,6 @@ class BookTickerStream:
data = json.loads(raw)
except Exception:
return
# 可能是单条对象或组合流格式
if isinstance(data, dict) and "stream" in data:
ticker_data = data.get("data", {})
else:
@ -173,7 +174,7 @@ class BookTickerStream:
return
try:
_book_ticker_cache[s] = {
item = {
"symbol": s,
"bidPrice": float(ticker_data.get("b", 0)),
"bidQty": float(ticker_data.get("B", 0)),
@ -184,26 +185,38 @@ class BookTickerStream:
except (TypeError, ValueError):
return
_book_ticker_updated_at = time.monotonic()
logger.debug(f"BookTickerStream: 已更新 {s} bid={_book_ticker_cache[s]['bidPrice']:.4f} ask={_book_ticker_cache[s]['askPrice']:.4f}")
# 有 Redis 时只写 Redis不写进程内存由 refresh 循环回填)
if self._redis_cache:
try:
loop = asyncio.get_event_loop()
copy = dict(_book_ticker_cache)
loop.create_task(self._write_book_ticker_to_redis(copy))
asyncio.get_event_loop().create_task(
self._merge_and_write_book_ticker_to_redis(s, item)
)
except Exception as e:
logger.debug("BookTickerStream: 写入 Redis 调度失败 %s", e)
return
_book_ticker_cache[s] = item
_book_ticker_updated_at = time.monotonic()
if len(_book_ticker_cache) > _BOOK_TICKER_CACHE_MAX_KEYS:
keys = list(_book_ticker_cache.keys())
for k in keys[_BOOK_TICKER_CACHE_MAX_KEYS:]:
del _book_ticker_cache[k]
logger.debug(f"BookTickerStream: 已更新 {s} bid={item['bidPrice']:.4f} ask={item['askPrice']:.4f}")
async def _write_book_ticker_to_redis(self, data: Dict[str, Dict[str, Any]]) -> None:
async def _merge_and_write_book_ticker_to_redis(self, symbol: str, item: Dict[str, Any]) -> None:
"""从 Redis 读出、合并单条、写回,不占用进程内存常驻"""
try:
if self._redis_cache:
await self._redis_cache.set(KEY_BOOK_TICKER, data, ttl=30)
if not self._redis_cache:
return
existing = await self._redis_cache.get(KEY_BOOK_TICKER)
merged = dict(existing) if isinstance(existing, dict) else {}
merged[symbol] = item
await self._redis_cache.set(KEY_BOOK_TICKER, merged, ttl=30)
except Exception as e:
logger.debug("BookTickerStream: 写入 Redis 失败 %s", e)
async def refresh_book_ticker_from_redis_loop(redis_cache: Any, interval_sec: float = 2.0) -> None:
"""非 Leader 或共用模式:定期从 Redis 拉取 bookTicker 到本地缓存。所有进程可调用"""
"""定期从 Redis 拉取 bookTicker 到本地缓存Leader 与非 Leader 都跑),并限制条数"""
global _book_ticker_cache, _book_ticker_updated_at
if redis_cache is None:
return
@ -213,8 +226,12 @@ async def refresh_book_ticker_from_redis_loop(redis_cache: Any, interval_sec: fl
data = await redis_cache.get(KEY_BOOK_TICKER)
if data and isinstance(data, dict):
_book_ticker_cache.update(data)
if len(_book_ticker_cache) > _BOOK_TICKER_CACHE_MAX_KEYS:
keys = list(_book_ticker_cache.keys())
for k in keys[_BOOK_TICKER_CACHE_MAX_KEYS:]:
del _book_ticker_cache[k]
_book_ticker_updated_at = time.monotonic()
logger.debug("BookTicker: 从 Redis 刷新 %s 个交易对", len(data))
logger.debug("BookTicker: 从 Redis 刷新 %s 个交易对", len(_book_ticker_cache))
except asyncio.CancelledError:
break
except Exception as e:

View File

@ -18,8 +18,10 @@ except ImportError:
KEY_TICKER_24H = "market:ticker_24h"
# 全市场 24h ticker 缓存symbol -> { symbol, price, volume, changePercent, ts }
# 有 Redis 时由 refresh 循环从 Redis 回填,不在此无限累积
_ticker_24h_cache: Dict[str, Dict[str, Any]] = {}
_ticker_24h_updated_at: float = 0.0
_TICKER_24H_CACHE_MAX_KEYS = 500 # 进程内存最多保留 500 个,避免无限增长
def get_tickers_24h_cache() -> Dict[str, Dict[str, Any]]:
@ -118,15 +120,14 @@ class Ticker24hStream:
data = json.loads(raw)
except Exception:
return
# 可能是单条对象stream 名)或数组;文档说是数组
if isinstance(data, list):
arr = data
elif isinstance(data, dict):
# 组合流格式 { "stream": "!ticker@arr", "data": [ ... ] }
arr = data.get("data") if isinstance(data.get("data"), list) else [data]
else:
return
now_ms = int(time.time() * 1000)
new_items = {}
for t in arr:
if not isinstance(t, dict):
continue
@ -136,37 +137,52 @@ class Ticker24hStream:
try:
price = float(t.get("c") or t.get("lastPrice") or 0)
change_pct = float(t.get("P") or t.get("priceChangePercent") or 0)
# 成交量:优先 quoteVolumeUSDT文档可能为 q 或 quoteVolume
vol = float(t.get("quoteVolume") or t.get("q") or t.get("v") or 0)
except (TypeError, ValueError):
continue
_ticker_24h_cache[s] = {
new_items[s] = {
"symbol": s,
"price": price,
"volume": vol,
"changePercent": change_pct,
"ts": now_ms,
}
_ticker_24h_updated_at = time.monotonic()
logger.debug(f"Ticker24hStream: 已更新 {len(arr)} 条,缓存共 {len(_ticker_24h_cache)} 个交易对")
if not new_items:
return
# 有 Redis 时只写 Redis不写进程内存由 refresh 循环从 Redis 回填,避免双重占用)
if self._redis_cache:
try:
loop = asyncio.get_event_loop()
copy = dict(_ticker_24h_cache)
loop.create_task(self._write_ticker_24h_to_redis(copy))
asyncio.get_event_loop().create_task(
self._merge_and_write_ticker_24h_to_redis(new_items)
)
except Exception as e:
logger.debug("Ticker24hStream: 写入 Redis 调度失败 %s", e)
return
# 无 Redis 时才写进程内存
for s, v in new_items.items():
_ticker_24h_cache[s] = v
_ticker_24h_updated_at = time.monotonic()
if len(_ticker_24h_cache) > _TICKER_24H_CACHE_MAX_KEYS:
keys = list(_ticker_24h_cache.keys())
for k in keys[_TICKER_24H_CACHE_MAX_KEYS:]:
del _ticker_24h_cache[k]
logger.debug(f"Ticker24hStream: 已更新 {len(new_items)} 条,缓存共 {len(_ticker_24h_cache)} 个交易对")
async def _write_ticker_24h_to_redis(self, data: Dict[str, Dict[str, Any]]) -> None:
async def _merge_and_write_ticker_24h_to_redis(self, new_items: Dict[str, Dict[str, Any]]) -> None:
"""从 Redis 读出、合并新数据、写回,不占用进程内存常驻"""
try:
if self._redis_cache:
await self._redis_cache.set(KEY_TICKER_24H, data, ttl=120)
if not self._redis_cache:
return
existing = await self._redis_cache.get(KEY_TICKER_24H)
merged = dict(existing) if isinstance(existing, dict) else {}
merged.update(new_items)
await self._redis_cache.set(KEY_TICKER_24H, merged, ttl=120)
except Exception as e:
logger.debug("Ticker24hStream: 写入 Redis 失败 %s", e)
async def refresh_ticker_24h_from_redis_loop(redis_cache: Any, interval_sec: float = 2.0) -> None:
"""非 Leader 或共用模式:定期从 Redis 拉取 24h ticker 到本地缓存。所有进程可调用"""
"""定期从 Redis 拉取 24h ticker 到本地缓存Leader 与非 Leader 都跑,避免进程内常驻全量)"""
global _ticker_24h_cache, _ticker_24h_updated_at
if redis_cache is None:
return
@ -176,8 +192,12 @@ async def refresh_ticker_24h_from_redis_loop(redis_cache: Any, interval_sec: flo
data = await redis_cache.get(KEY_TICKER_24H)
if data and isinstance(data, dict):
_ticker_24h_cache.update(data)
if len(_ticker_24h_cache) > _TICKER_24H_CACHE_MAX_KEYS:
keys = list(_ticker_24h_cache.keys())
for k in keys[_TICKER_24H_CACHE_MAX_KEYS:]:
del _ticker_24h_cache[k]
_ticker_24h_updated_at = time.monotonic()
logger.debug("Ticker24h: 从 Redis 刷新 %s 个交易对", len(data))
logger.debug("Ticker24h: 从 Redis 刷新 %s 个交易对", len(_ticker_24h_cache))
except asyncio.CancelledError:
break
except Exception as e: