mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
feat(redis): add configurable connection pool settings for Redis client
This commit is contained in:
@@ -160,6 +160,10 @@ class ConfigModel(BaseModel):
|
|||||||
CACHE_BACKEND_URL: Optional[str] = "redis://localhost:6379"
|
CACHE_BACKEND_URL: Optional[str] = "redis://localhost:6379"
|
||||||
# Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb"
|
# Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb"
|
||||||
CACHE_REDIS_MAXMEMORY: Optional[str] = None
|
CACHE_REDIS_MAXMEMORY: Optional[str] = None
|
||||||
|
# Redis 连接池最大连接数
|
||||||
|
CACHE_REDIS_MAX_CONNECTIONS: int = 256
|
||||||
|
# Redis 连接池耗尽时等待可用连接的时间(秒)
|
||||||
|
CACHE_REDIS_POOL_TIMEOUT: int = 3
|
||||||
# 全局图片缓存,将媒体图片缓存到本地
|
# 全局图片缓存,将媒体图片缓存到本地
|
||||||
GLOBAL_IMAGE_CACHE: bool = False
|
GLOBAL_IMAGE_CACHE: bool = False
|
||||||
# 全局图片缓存保留天数
|
# 全局图片缓存保留天数
|
||||||
|
|||||||
+71
-18
@@ -1,10 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
|
import threading
|
||||||
from typing import Any, Optional, Generator, Tuple, AsyncGenerator, Union
|
from typing import Any, Optional, Generator, Tuple, AsyncGenerator, Union
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
from redis.asyncio import BlockingConnectionPool as AsyncBlockingConnectionPool
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -83,7 +85,13 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
- 支持内存限制和淘汰策略设置
|
- 支持内存限制和淘汰策略设置
|
||||||
- 提供键名生成和区域管理功能
|
- 提供键名生成和区域管理功能
|
||||||
"""
|
"""
|
||||||
CONFIG_WATCH = {"CACHE_BACKEND_TYPE", "CACHE_BACKEND_URL", "CACHE_REDIS_MAXMEMORY"}
|
CONFIG_WATCH = {
|
||||||
|
"CACHE_BACKEND_TYPE",
|
||||||
|
"CACHE_BACKEND_URL",
|
||||||
|
"CACHE_REDIS_MAXMEMORY",
|
||||||
|
"CACHE_REDIS_MAX_CONNECTIONS",
|
||||||
|
"CACHE_REDIS_POOL_TIMEOUT",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""
|
"""
|
||||||
@@ -91,31 +99,46 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
self.redis_url = settings.CACHE_BACKEND_URL
|
self.redis_url = settings.CACHE_BACKEND_URL
|
||||||
self.client = None
|
self.client = None
|
||||||
|
self._connect_lock = threading.RLock()
|
||||||
|
|
||||||
def _connect(self):
|
def _connect(self):
|
||||||
"""
|
"""
|
||||||
建立Redis连接
|
建立Redis连接
|
||||||
"""
|
"""
|
||||||
|
if self.client is not None:
|
||||||
|
return
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
if self.client is None:
|
with self._connect_lock:
|
||||||
self.client = redis.Redis.from_url(
|
if self.client is not None:
|
||||||
|
return
|
||||||
|
self.redis_url = settings.CACHE_BACKEND_URL
|
||||||
|
connection_pool = redis.BlockingConnectionPool.from_url(
|
||||||
self.redis_url,
|
self.redis_url,
|
||||||
decode_responses=False,
|
decode_responses=False,
|
||||||
socket_timeout=_socket_timeout,
|
socket_timeout=_socket_timeout,
|
||||||
socket_connect_timeout=_socket_connect_timeout,
|
socket_connect_timeout=_socket_connect_timeout,
|
||||||
health_check_interval=_health_check_interval,
|
health_check_interval=_health_check_interval,
|
||||||
|
max_connections=settings.CACHE_REDIS_MAX_CONNECTIONS,
|
||||||
|
timeout=settings.CACHE_REDIS_POOL_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
client = redis.Redis(connection_pool=connection_pool)
|
||||||
# 测试连接,确保Redis可用
|
# 测试连接,确保Redis可用
|
||||||
self.client.ping()
|
client.ping()
|
||||||
|
self.client = client
|
||||||
logger.info(f"Successfully connected to Redis:{self.redis_url}")
|
logger.info(f"Successfully connected to Redis:{self.redis_url}")
|
||||||
self.set_memory_limit()
|
self.set_memory_limit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
logger.error(f"Failed to connect to Redis: {e}")
|
logger.error(f"Failed to connect to Redis: {e}")
|
||||||
self.client = None
|
self.client = None
|
||||||
raise RuntimeError("Redis connection failed") from e
|
raise RuntimeError("Redis connection failed") from e
|
||||||
|
|
||||||
def on_config_changed(self):
|
def on_config_changed(self):
|
||||||
self.close()
|
with self._connect_lock:
|
||||||
|
self.redis_url = settings.CACHE_BACKEND_URL
|
||||||
|
self.close()
|
||||||
self._connect()
|
self._connect()
|
||||||
|
|
||||||
def get_reload_name(self):
|
def get_reload_name(self):
|
||||||
@@ -296,10 +319,11 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
关闭Redis客户端的连接池
|
关闭Redis客户端的连接池
|
||||||
"""
|
"""
|
||||||
if self.client:
|
with self._connect_lock:
|
||||||
self.client.close()
|
if self.client:
|
||||||
self.client = None
|
self.client.close()
|
||||||
logger.debug("Redis connection closed")
|
self.client = None
|
||||||
|
logger.debug("Redis connection closed")
|
||||||
|
|
||||||
|
|
||||||
class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
||||||
@@ -313,7 +337,13 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
- 提供键名生成和区域管理功能
|
- 提供键名生成和区域管理功能
|
||||||
- 所有操作都是异步的
|
- 所有操作都是异步的
|
||||||
"""
|
"""
|
||||||
CONFIG_WATCH = {"CACHE_BACKEND_TYPE", "CACHE_BACKEND_URL", "CACHE_REDIS_MAXMEMORY"}
|
CONFIG_WATCH = {
|
||||||
|
"CACHE_BACKEND_TYPE",
|
||||||
|
"CACHE_BACKEND_URL",
|
||||||
|
"CACHE_REDIS_MAXMEMORY",
|
||||||
|
"CACHE_REDIS_MAX_CONNECTIONS",
|
||||||
|
"CACHE_REDIS_POOL_TIMEOUT",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""
|
"""
|
||||||
@@ -322,31 +352,53 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self.redis_url = settings.CACHE_BACKEND_URL
|
self.redis_url = settings.CACHE_BACKEND_URL
|
||||||
self.client: Optional[Redis] = None
|
self.client: Optional[Redis] = None
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
self._connect_lock: Optional[asyncio.Lock] = None
|
||||||
|
self._connect_lock_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
|
def _get_connect_lock(self, current_loop: asyncio.AbstractEventLoop) -> asyncio.Lock:
|
||||||
|
"""
|
||||||
|
获取当前事件循环对应的异步连接锁
|
||||||
|
"""
|
||||||
|
if self._connect_lock is None or self._connect_lock_loop is not current_loop:
|
||||||
|
self._connect_lock = asyncio.Lock()
|
||||||
|
self._connect_lock_loop = current_loop
|
||||||
|
return self._connect_lock
|
||||||
|
|
||||||
async def _connect(self):
|
async def _connect(self):
|
||||||
"""
|
"""
|
||||||
建立异步Redis连接
|
建立异步Redis连接
|
||||||
"""
|
"""
|
||||||
|
current_loop = asyncio.get_running_loop()
|
||||||
|
connect_lock = self._get_connect_lock(current_loop)
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
current_loop = asyncio.get_running_loop()
|
async with connect_lock:
|
||||||
# 检测事件循环是否发生变化,如果变化则重新连接
|
# 检测事件循环是否发生变化,如果变化则重新连接
|
||||||
if self.client is not None and self._loop is not current_loop:
|
if self.client is not None and self._loop is not current_loop:
|
||||||
logger.debug("Event loop changed, reconnecting Redis (async)")
|
logger.debug("Event loop changed, reconnecting Redis (async)")
|
||||||
await self._close_client()
|
await self._close_client()
|
||||||
if self.client is None:
|
if self.client is not None:
|
||||||
self.client = Redis.from_url(
|
return
|
||||||
|
self.redis_url = settings.CACHE_BACKEND_URL
|
||||||
|
connection_pool = AsyncBlockingConnectionPool.from_url(
|
||||||
self.redis_url,
|
self.redis_url,
|
||||||
decode_responses=False,
|
decode_responses=False,
|
||||||
socket_timeout=_socket_timeout,
|
socket_timeout=_socket_timeout,
|
||||||
socket_connect_timeout=_socket_connect_timeout,
|
socket_connect_timeout=_socket_connect_timeout,
|
||||||
health_check_interval=_health_check_interval,
|
health_check_interval=_health_check_interval,
|
||||||
|
max_connections=settings.CACHE_REDIS_MAX_CONNECTIONS,
|
||||||
|
timeout=settings.CACHE_REDIS_POOL_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
client = Redis(connection_pool=connection_pool)
|
||||||
self._loop = current_loop
|
self._loop = current_loop
|
||||||
# 测试连接,确保Redis可用
|
# 测试连接,确保Redis可用
|
||||||
await self.client.ping()
|
await client.ping()
|
||||||
|
self.client = client
|
||||||
logger.info(f"Successfully connected to Redis (async):{self.redis_url}")
|
logger.info(f"Successfully connected to Redis (async):{self.redis_url}")
|
||||||
await self.set_memory_limit()
|
await self.set_memory_limit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if client:
|
||||||
|
await client.close()
|
||||||
logger.error(f"Failed to connect to Redis (async): {e}")
|
logger.error(f"Failed to connect to Redis (async): {e}")
|
||||||
self.client = None
|
self.client = None
|
||||||
self._loop = None
|
self._loop = None
|
||||||
@@ -365,6 +417,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self._loop = None
|
self._loop = None
|
||||||
|
|
||||||
async def on_config_changed(self):
|
async def on_config_changed(self):
|
||||||
|
self.redis_url = settings.CACHE_BACKEND_URL
|
||||||
await self._close_client()
|
await self._close_client()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
|
|||||||
+118
-1
@@ -1,7 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from app.core.cache import AsyncFileBackend, FileBackend, MemoryBackend
|
from app.core.cache import AsyncFileBackend, FileBackend, MemoryBackend
|
||||||
from app.helper.redis import RedisHelper
|
from app.core.config import settings
|
||||||
|
from app.helper.redis import AsyncRedisHelper, RedisHelper
|
||||||
|
|
||||||
|
|
||||||
def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path):
|
def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path):
|
||||||
@@ -49,6 +50,122 @@ def test_redis_original_key_decodes_quoted_key():
|
|||||||
assert RedisHelper._RedisHelper__get_original_key(redis_key) == "nested/poster one.jpg"
|
assert RedisHelper._RedisHelper__get_original_key(redis_key) == "nested/poster one.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redis_helper_uses_blocking_pool_settings(monkeypatch):
|
||||||
|
"""
|
||||||
|
Redis 同步客户端应使用阻塞连接池,避免并发峰值直接耗尽 Redis 连接数。
|
||||||
|
"""
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
"""模拟同步 Redis 客户端。"""
|
||||||
|
|
||||||
|
def __init__(self, connection_pool):
|
||||||
|
self.connection_pool = connection_pool
|
||||||
|
self.config_calls = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def ping(self):
|
||||||
|
"""模拟 Redis ping。"""
|
||||||
|
calls["ping"] = True
|
||||||
|
|
||||||
|
def config_set(self, key, value):
|
||||||
|
"""记录 Redis 配置写入。"""
|
||||||
|
self.config_calls.append((key, value))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""标记客户端已关闭。"""
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def fake_from_url(url, **kwargs):
|
||||||
|
"""记录连接池构造参数。"""
|
||||||
|
calls["pool"] = {"url": url, **kwargs}
|
||||||
|
return "pool"
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "CACHE_BACKEND_URL", "redis://cache:6379/2")
|
||||||
|
monkeypatch.setattr(settings, "CACHE_REDIS_MAX_CONNECTIONS", 7)
|
||||||
|
monkeypatch.setattr(settings, "CACHE_REDIS_POOL_TIMEOUT", 3)
|
||||||
|
monkeypatch.setattr("app.helper.redis.redis.BlockingConnectionPool.from_url", fake_from_url)
|
||||||
|
monkeypatch.setattr("app.helper.redis.redis.Redis", FakeClient)
|
||||||
|
|
||||||
|
helper = RedisHelper()
|
||||||
|
helper.close()
|
||||||
|
helper._connect()
|
||||||
|
|
||||||
|
assert calls["pool"]["url"] == "redis://cache:6379/2"
|
||||||
|
assert calls["pool"]["max_connections"] == 7
|
||||||
|
assert calls["pool"]["timeout"] == 3
|
||||||
|
assert calls["pool"]["decode_responses"] is False
|
||||||
|
assert calls["ping"] is True
|
||||||
|
assert ("maxmemory-policy", "allkeys-lru") in helper.client.config_calls
|
||||||
|
|
||||||
|
helper.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_redis_helper_uses_blocking_pool_settings(monkeypatch):
|
||||||
|
"""
|
||||||
|
Redis 异步客户端应使用阻塞连接池,避免高并发缓存读取立刻抛出连接耗尽错误。
|
||||||
|
"""
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
class FakeAsyncClient:
|
||||||
|
"""模拟异步 Redis 客户端。"""
|
||||||
|
|
||||||
|
def __init__(self, connection_pool):
|
||||||
|
self.connection_pool = connection_pool
|
||||||
|
self.config_calls = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def ping(self):
|
||||||
|
"""模拟 Redis ping。"""
|
||||||
|
calls["ping"] = True
|
||||||
|
|
||||||
|
async def config_set(self, key, value):
|
||||||
|
"""记录 Redis 配置写入。"""
|
||||||
|
self.config_calls.append((key, value))
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""标记客户端已关闭。"""
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def fake_from_url(url, **kwargs):
|
||||||
|
"""记录连接池构造参数。"""
|
||||||
|
calls["pool"] = {"url": url, **kwargs}
|
||||||
|
return "async_pool"
|
||||||
|
|
||||||
|
async def run_connect():
|
||||||
|
helper = AsyncRedisHelper()
|
||||||
|
await helper.close()
|
||||||
|
await helper._connect()
|
||||||
|
config_calls = list(helper.client.config_calls)
|
||||||
|
await helper.close()
|
||||||
|
return config_calls
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "CACHE_BACKEND_URL", "redis://cache:6379/3")
|
||||||
|
monkeypatch.setattr(settings, "CACHE_REDIS_MAX_CONNECTIONS", 9)
|
||||||
|
monkeypatch.setattr(settings, "CACHE_REDIS_POOL_TIMEOUT", 4)
|
||||||
|
monkeypatch.setattr("app.helper.redis.AsyncBlockingConnectionPool.from_url", fake_from_url)
|
||||||
|
monkeypatch.setattr("app.helper.redis.Redis", FakeAsyncClient)
|
||||||
|
|
||||||
|
config_calls = asyncio.run(run_connect())
|
||||||
|
|
||||||
|
assert calls["pool"]["url"] == "redis://cache:6379/3"
|
||||||
|
assert calls["pool"]["max_connections"] == 9
|
||||||
|
assert calls["pool"]["timeout"] == 4
|
||||||
|
assert calls["pool"]["decode_responses"] is False
|
||||||
|
assert calls["ping"] is True
|
||||||
|
assert ("maxmemory-policy", "allkeys-lru") in config_calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_redis_helpers_watch_pool_settings():
|
||||||
|
"""
|
||||||
|
Redis 连接池配置变化应触发客户端重建。
|
||||||
|
"""
|
||||||
|
assert "CACHE_REDIS_MAX_CONNECTIONS" in RedisHelper.CONFIG_WATCH
|
||||||
|
assert "CACHE_REDIS_POOL_TIMEOUT" in RedisHelper.CONFIG_WATCH
|
||||||
|
assert "CACHE_REDIS_MAX_CONNECTIONS" in AsyncRedisHelper.CONFIG_WATCH
|
||||||
|
assert "CACHE_REDIS_POOL_TIMEOUT" in AsyncRedisHelper.CONFIG_WATCH
|
||||||
|
|
||||||
|
|
||||||
def test_async_file_backend_missing_region_has_no_items(tmp_path):
|
def test_async_file_backend_missing_region_has_no_items(tmp_path):
|
||||||
"""
|
"""
|
||||||
异步文件缓存缺失区域时应返回空迭代,而不是伪造空 key。
|
异步文件缓存缺失区域时应返回空迭代,而不是伪造空 key。
|
||||||
|
|||||||
Reference in New Issue
Block a user