1. 项目概述:当“方便”成为系统性故障的温床
我见过太多次了——一个刚毕业的后端工程师,或者一个正在赶工期的全栈开发者,在深夜三点对着满屏报错抓耳挠腮。他反复运行同一段代码,本地一切正常;CI流水线却在凌晨四点准时崩掉;运维同事发来截图,生产环境里某个接口返回的数据莫名其妙少了字段,而日志里连个异常堆栈都找不到。最后排查三天,发现罪魁祸首是一行global config,藏在某个被遗忘的工具函数里,像一颗静默的定时炸弹。
这篇文章讲的不是理论,是我亲手踩过的坑、亲手写的烂代码、亲手熬过的通宵。它来自真实项目现场:一个用 Flask 搭建的 SaaS 后台服务,初期只有三个人维护,上线三个月后用户量涨了八倍,测试覆盖率从 72% 掉到 41%,而最诡异的 Bug 全部指向同一个源头——全局变量。不是“可能有问题”,而是“必然出问题”。你用它图一时之便,它就用不可复现的随机失败、内存持续暴涨、测试顺序依赖、多进程连接泄漏,一记记重拳打在你最脆弱的交付节奏上。
核心关键词已经非常清晰:全局变量、不可复现 Bug、测试污染、线程不安全、内存泄漏、单例失效、导入副作用、隐式依赖、猴子补丁失控。这些词不是教科书里的概念,而是我在ps aux --sort=-%mem里看到的 32GB 占用进程、在pytest -v --tb=short输出里反复跳变的失败用例、在strace -p <pid>日志中看到的成千上万次重复的connect()系统调用。它适合所有正在写 Python(尤其是 Web 或数据服务)的开发者,无论你是刚学完import语法的新手,还是带过十人团队的 Tech Lead。因为只要你的代码里还存在global关键字、模块级可变对象、或未经防护的顶层赋值,你就站在同一个悬崖边上。这不是危言耸听,这是我在生产环境里用服务器宕机时长换来的经验。
2. 全局变量的七宗罪:为什么“方便”是最大的幻觉
2.1 它们根本不是“全局”的,而是“伪全局”
先破除一个最危险的误解:Python 里根本没有真正意义上的“全局变量”。所谓global x,只是告诉解释器:“请在当前模块的顶层作用域里找变量x,别去闭包或局部作用域里瞎翻。” 这个“全局”仅限于单个模块文件内部。一旦你跨文件引用,比如from config import API_KEY,你拿到的其实是config模块命名空间里那个对象的引用副本。这听起来很学术?不,它直接导致了灾难性的后果。
想象一下这个场景:你在test_api.py里写了config.API_KEY = "test_123",然后调用call_api('users')。表面上看,你改了配置,API 应该走测试环境。但call_api函数内部是这样写的:
# api.py def call_api(endpoint): import config # 注意!这里每次调用都重新 import url = f"https://api.example.com/{endpoint}" headers = {'X-API-Key': config.API_KEY} return requests.get(url, headers=headers)问题来了:import config这一行,在函数执行时才发生。而config.API_KEY = "test_123"是在测试函数里提前执行的。按理说,call_api应该读到新值。但现实是,Python 的 import 机制会缓存已加载的模块。第一次import config时,模块被加载进sys.modules;后续所有import config都直接返回缓存对象。所以config.API_KEY的修改是生效的。
那 Bug 出在哪?出在更隐蔽的地方:模块导入顺序和初始化时机。假设app.py在启动时就import config并立即执行了connect_database(config.DATABASE_URL)。此时config.DATABASE_URL还是"postgresql://localhost/mydb"。而你的测试test_database.py里又写了config.DATABASE_URL = "postgresql://localhost/testdb"。如果pytest先加载app.py(触发了数据库连接),再加载test_database.py(修改 URL),那么app.py里已经建立的连接,用的还是旧 URL。而test_database.py里新建的连接,用的是新 URL。两个连接共存,但你的业务逻辑可能只认其中一个。这种“连接错乱”不会抛异常,只会让你查数据库时发现数据对不上——用户在测试库创建了记录,但在主库里查不到。这种 Bug 的复现条件苛刻到令人绝望:必须是特定的模块加载顺序 + 特定的测试执行顺序 + 特定的初始化时机。你本地调试时,IDE 可能按字母序加载,CI 里 pytest 可能按文件修改时间加载,生产环境里 Gunicorn 的 worker 启动顺序又完全不同。于是,“在我机器上是好的”成了最真实的开发日常。
提示:
import不是零成本操作。它会执行模块顶层的所有代码。把数据库连接、HTTP 客户端初始化、甚至print("Loading config...")放在模块顶层,就是在给所有导入者埋雷。真正的“全局”只存在于你的想象中,现实中它是一片由模块缓存、导入路径、执行时序构成的混沌沼泽。
2.2 它们天生与并发为敌:线程安全?不存在的
“我的服务是单线程的,不用管线程安全。”——这是我听过最危险的自我安慰。Flask 默认的开发服务器flask run是单线程没错,但生产环境呢?Gunicorn 默认开 4 个 worker,每个 worker 内部又是多线程处理请求。Uvicorn + ASGI?那更是协程满天飞。只要你的代码跑在非单线程环境里,任何未加防护的全局可变状态,就是一场等待爆发的雪崩。
回到那个经典的计数器例子:
# counter.py request_count = 0 def track_request(): global request_count request_count += 1 return request_count在单线程下,它完美工作。request_count从 0 开始,每来一个请求就加 1,日志里清清楚楚写着Request #1,Request #2。但放到 Gunicorn 的 4 个工作进程中,每个进程都有自己的request_count变量。所以你会看到四个独立的计数序列:Worker-1 的#1,#2,#3;Worker-2 的#1,#2,#3……这看起来好像也没啥大问题?问题在于,如果你的业务逻辑依赖这个计数器做决策,比如“每 1000 个请求触发一次数据归档”,那么归档动作会在每个 Worker 里各自触发,导致数据被重复归档四次,或者因为计数器太小而频繁触发,拖垮数据库。
更致命的是线程内竞争。假设你用的是--threads 4的 Gunicorn 配置,一个 Worker 进程里有 4 个线程同时处理请求。request_count += 1看似原子操作,实则包含三步:1) 读取request_count的当前值;2) 将其加 1;3) 将新值写回request_count。线程 A 和 B 同时执行第 1 步,都读到0;然后都执行第 2 步,得到1;最后都执行第 3 步,把1写回去。结果就是,两个请求只让计数器增加了 1,而不是 2。这就是典型的Race Condition(竞态条件)。它不会 crash,不会报错,只会让你的日志里出现大量重复的Request #1,而真正的请求总数却在后台悄悄丢失。这种 Bug 的复现概率和服务器负载强相关:低流量时几乎不出现,高并发压测时满屏都是。你花一周时间写压力测试脚本,就是为了稳定复现这个 Bug,只为证明它真的存在。
注意:
threading.Lock能解决线程竞争,但会引入新的性能瓶颈。锁是串行化资源访问的手段,意味着所有想更新计数器的线程,必须排队等待。在 QPS 达到 5000+ 的服务里,这个锁会成为整个系统的吞吐量天花板。我亲眼见过一个监控服务,因为一个全局计数器加了锁,导致平均响应时间从 12ms 暴涨到 89ms,最终被架构组强制下线重写。
2.3 它们是内存泄漏的完美温床:没有清理,只有堆积
缓存,是全局变量最常被“合理化”的使用场景。“不缓存,每次查数据库太慢了!”——这话没错。但错在把缓存实现得像一个无底洞。看看这个原始版本:
# cache.py _cache = {} def cache_set(key, value): _cache[key] = value def cache_get(key): return _cache.get(key)简洁,高效,符合直觉。问题出在cache_set的语义上:它没有“过期”、没有“淘汰”、没有“大小限制”。它就是一个纯粹的字典追加操作。在用户服务里,key是f"user:{user_id}",user_id是自增整数。第一天,缓存了 1000 个用户;第二天,缓存了 2000 个;第三天,3000 个……一个月后,缓存里躺着 30000 个用户对象。每个用户对象平均占 2KB 内存,光缓存就吃掉 60MB。这还不算什么。更可怕的是,这些对象的生命周期完全脱离了业务逻辑的控制。用户注销了,他的数据还在缓存里;用户资料更新了,缓存里的旧数据永远不会自动刷新;甚至,当你的服务重启时,这个_cache字典会被清空,但你的业务代码可能根本没意识到——它还以为缓存是“热”的,直到下一次查询失败才暴露问题。
内存泄漏的后果是渐进式的。初期,你只会觉得服务启动越来越慢,因为要加载更多缓存数据;中期,GC(垃圾回收)频率飙升,CPU 时间大量消耗在内存管理上;后期,MemoryError开始随机出现,服务进程被 OOM Killer 杀死。而定位根源极其困难:ps aux显示进程占用了 16GB 内存,但pympler工具分析堆内存,却发现大部分对象都挂在_cache这个全局字典下。你删掉几行缓存代码,内存占用立刻回落,但业务方会质问:“为什么我们用户的页面加载变慢了?”——因为缓存没了,所有请求都打到了数据库。
提示:真正的缓存必须有明确的“契约”。这个契约包括:最大容量(
maxsize)、过期策略(TTL)、淘汰算法(LRU/LFU)、以及最重要的——谁负责清理。把清理责任推给“系统自动 GC”是懒惰的表现。GC 只回收不可达对象,而_cache字典本身是全局可达的,它里面的每一个value都是强引用,GC 永远不会碰它们。你必须自己动手,设计一套主动的、可控的清理机制。
2.4 它们让测试变成俄罗斯轮盘赌:顺序决定生死
单元测试的核心信条是什么?隔离性(Isolation)。每个测试用例都应该像一个真空中的理想实验,不受其他用例的任何影响。全局变量,是隔离性的天然死敌。它让测试从“验证功能”退化为“祈祷运气”。
考虑这两个测试:
# test_api.py import config def test_api_with_debug(): config.DEBUG = True # 修改全局状态 result = call_api_endpoint() assert 'debug_info' in result # test_retry.py import config def test_retry_logic(): # 这个测试期望 DEBUG 是 False result = call_api_endpoint() assert 'debug_info' not in result单独运行test_api.py,通过;单独运行test_retry.py,也通过。但当你运行pytest test_api.py test_retry.py时,test_api先执行,把config.DEBUG设为True,然后test_retry执行,它读到的DEBUG还是True,断言失败。反过来,pytest test_retry.py test_api.py,test_retry先执行(此时DEBUG是默认False,通过),test_api再执行(DEBUG还是False,但它需要True,失败)。测试结果完全取决于文件名的字母顺序,或者 pytest 的内部调度算法。这已经不是 Bug,这是对工程实践的嘲讽。
更隐蔽的问题是测试夹具(Fixture)的污染。假设你有一个databasefixture,它在每个测试前创建一个干净的测试数据库,并设置config.DATABASE_URL = "test_url"。如果这个 fixture 没有在teardown阶段把DATABASE_URL恢复成原始值,那么下一个测试,哪怕它跟数据库毫无关系,也会意外地连接到测试库,导致数据污染或连接超时。我曾经在一个数据分析项目里,因为一个mock_s3fixture 忘记恢复boto3的全局 session,导致后续所有涉及 AWS 的测试都失败,排查了整整两天,才发现问题出在一个被遗忘的conftest.py文件里。
注意:
pytest的autouse=Truefixture 是双刃剑。它能自动应用,但也意味着你无法控制它的执行时机。一个autouse的setup_loggingfixture 如果修改了logging.getLogger().level,它会影响所有测试的日志输出级别,让你在 CI 里看到海量无关 debug 日志,淹没了真正的错误信息。永远问自己:这个 fixture 的副作用,是否真的需要影响到每一个测试?
2.5 它们构建了看不见的依赖链:签名即谎言
函数签名是程序员之间最基础的契约。def process_user(user_id: int) -> User:这行代码向所有调用者承诺:“我只需要一个user_id,就能给你一个User对象。除此之外,我不需要任何外部状态。” 全局变量,让这个契约变成一张废纸。
看这个例子:
# api.py API_ENDPOINT = "https://api.production.com" # 模块级全局变量 def call_api(path: str) -> Response: url = f"{API_ENDPOINT}/{path}" # 隐式依赖 API_ENDPOINT return requests.get(url)这个函数的签名call_api(path: str)告诉你,它只依赖path。但它的实际行为,却严重依赖于api.py模块顶层的API_ENDPOINT变量。这个依赖是隐式的、不可见的、无法被类型检查器捕获的。当你想为它写测试时,你必须知道要去api.API_ENDPOINT这个位置修改值。当你想把它迁移到另一个项目时,你必须确保新项目里也有一个同名的全局变量。当你和同事协作时,他可能完全不知道这个函数背后藏着一个“开关”,直到某天他把API_ENDPOINT改成了测试地址,结果导致线上告警。
这种隐式依赖,让代码的可维护性指数级下降。你无法通过grep或 IDE 的“查找引用”功能,快速定位所有受API_ENDPOINT影响的函数。你必须通读所有代码,寻找import api和api.的调用。它把本应扁平、线性的依赖关系,扭曲成一张蜘蛛网。而这张网的中心,就是那个看似无害的API_ENDPOINT = ...。
提示:显式优于隐式,这是 Python 之禅的第一条。把依赖从函数体里“拽”出来,放到参数列表里,是最简单、最有效、最符合直觉的解法。
def call_api(path: str, endpoint: str = "https://api.production.com")。这一行改动,就让函数的契约变得清晰、可测试、可复用。它不再是一个“黑盒”,而是一个“白盒”,它的所有输入,都在签名里明明白白地列着。
3. 实战重构指南:七种场景的逐一手把手改造
3.1 配置管理:从config.py到可插拔的配置中心
原始config.py的问题在于:它既是配置定义,又是配置加载器,还是配置消费者。三重身份混在一起,导致了导入副作用和测试污染。重构目标:配置定义与配置加载分离,加载时机延迟,加载方式可配置。
第一步:定义纯数据配置类
# config/base.py from dataclasses import dataclass from typing import Optional @dataclass class DatabaseConfig: url: str pool_size: int = 20 max_overflow: int = 10 @dataclass class ApiConfig: endpoint: str key: str timeout: int = 30 @dataclass class AppConfig: database: DatabaseConfig api: ApiConfig debug: bool = False log_level: str = "INFO"这个AppConfig类,只是一个数据容器。它不执行任何 IO,不连接数据库,不读取环境变量。它只负责结构化地描述“配置应该长什么样”。好处是:1) 类型安全,IDE 可以提供完美补全;2) 可序列化,方便存入 JSON/YAML;3) 无副作用,可以随意import。
第二步:实现可插拔的配置加载器
# config/loader.py import os import json from pathlib import Path from typing import Dict, Any from config.base import AppConfig, DatabaseConfig, ApiConfig class ConfigLoader: def __init__(self, env: str = "development"): self.env = env self._config: Optional[AppConfig] = None def load_from_env(self) -> AppConfig: """从环境变量加载,用于生产环境""" return AppConfig( database=DatabaseConfig( url=os.environ["DATABASE_URL"], pool_size=int(os.environ.get("DB_POOL_SIZE", "20")), ), api=ApiConfig( endpoint=os.environ["API_ENDPOINT"], key=os.environ["API_KEY"], timeout=int(os.environ.get("API_TIMEOUT", "30")), ), debug=os.environ.get("DEBUG", "false").lower() == "true", log_level=os.environ.get("LOG_LEVEL", "INFO"), ) def load_from_file(self, config_path: Path) -> AppConfig: """从 JSON 文件加载,用于本地开发和测试""" with open(config_path) as f: raw_config = json.load(f) # 将字典映射到 dataclass,这里用 pydantic 会更健壮,但为简化,手动映射 db_conf = raw_config.get("database", {}) api_conf = raw_config.get("api", {}) return AppConfig( database=DatabaseConfig( url=db_conf.get("url", "sqlite:///dev.db"), pool_size=db_conf.get("pool_size", 10), ), api=ApiConfig( endpoint=api_conf.get("endpoint", "https://api.test.com"), key=api_conf.get("key", "test_key"), timeout=api_conf.get("timeout", 10), ), debug=raw_config.get("debug", False), log_level=raw_config.get("log_level", "DEBUG"), ) def get_config(self) -> AppConfig: if self._config is None: if self.env == "production": self._config = self.load_from_env() else: # 开发/测试环境,优先尝试 config.json, fallback 到默认 config_file = Path("config.json") if config_file.exists(): self._config = self.load_from_file(config_file) else: self._config = self.load_from_env() # 或者一个硬编码的默认配置 return self._config # 全局唯一的 loader 实例,但注意:它本身不持有配置数据,只负责加载 config_loader = ConfigLoader(env=os.getenv("ENV", "development"))第三步:在应用入口处加载配置
# app.py from config.loader import config_loader from flask import Flask def create_app(): app = Flask(__name__) # 关键:在这里才真正加载配置,而不是在模块顶层 config = config_loader.get_config() # 现在,你可以安全地用 config 创建各种服务 from database import DatabasePool db_pool = DatabasePool(config.database.url, config.database.pool_size) from api_client import APIClient api_client = APIClient(config.api.endpoint, config.api.key) # 将服务注入到 app 上下文,供路由使用 app.config['DB_POOL'] = db_pool app.config['API_CLIENT'] = api_client @app.route('/users') def users(): db = app.config['DB_POOL'].get_connection() # ... use db return "OK" return app # 生产环境启动 if __name__ == "__main__": app = create_app() app.run()测试时的用法:
# tests/conftest.py import pytest from config.loader import ConfigLoader from config.base import AppConfig, DatabaseConfig, ApiConfig @pytest.fixture def test_config(): """为测试提供一个干净的、隔离的配置实例""" return AppConfig( database=DatabaseConfig(url="sqlite:///test.db"), api=ApiConfig(endpoint="https://api.mock.com", key="mock_key"), debug=True, ) # tests/test_api.py def test_call_api_with_mock(test_config): client = APIClient(test_config.api.endpoint, test_config.api.key) result = client.call("users") # 断言...这个方案彻底解决了原始问题:1)import config不再触发任何 IO;2) 测试可以自由创建自己的AppConfig实例,互不影响;3) 配置来源(环境变量、文件、代码)完全可插拔;4) 所有依赖都显式传递,没有隐式状态。
3.2 计数器与状态追踪:从global到线程/进程安全的封装
原始global request_count的问题,本质是状态管理与并发模型的错配。解决方案不是加锁,而是将状态与执行上下文绑定。
方案一:线程局部存储(Thread-Local Storage)
# metrics/counter.py import threading from typing import Dict, Any class ThreadLocalCounter: def __init__(self): self._local = threading.local() def get_count(self, name: str) -> int: """获取当前线程下指定名称的计数器值""" if not hasattr(self._local, 'counters'): self._local.counters = {} return self._local.counters.get(name, 0) def inc_count(self, name: str, step: int = 1) -> int: """增加当前线程下指定名称的计数器值""" if not hasattr(self._local, 'counters'): self._local.counters = {} current = self._local.counters.get(name, 0) new_val = current + step self._local.counters[name] = new_val return new_val # 全局单例,但内部状态是线程隔离的 request_counter = ThreadLocalCounter() # 在 Flask 路由中使用 @app.route('/api/data') def data_endpoint(): count = request_counter.inc_count("api_data_requests") app.logger.info(f"Data request #{count} from thread {threading.current_thread().name}") return process_data()threading.local()为每个线程创建一个独立的命名空间。request_counter对象本身是全局的,但它内部的_local属性,对每个线程来说都是独一无二的。线程 A 调用inc_count("req"),只影响线程 A 的计数器;线程 B 调用,只影响线程 B 的。零锁,零竞争,完美解决线程安全问题。
方案二:进程安全的共享内存(适用于 Gunicorn 多进程)
线程局部存储在多进程下失效,因为每个进程有自己的内存空间。这时需要跨进程的共享状态,如multiprocessing.Value或 Redis。
# metrics/shared_counter.py import multiprocessing as mp from typing import Optional class SharedCounter: def __init__(self, initial_value: int = 0): # 使用 multiprocessing.Value 创建一个跨进程共享的整数 # 'i' 表示 signed integer self._value = mp.Value('i', initial_value) self._lock = mp.Lock() # 进程间锁 def get(self) -> int: with self._lock: return self._value.value def inc(self, step: int = 1) -> int: with self._lock: self._value.value += step return self._value.value # 在应用启动时创建全局共享计数器(注意:必须在主进程创建) if __name__ == "__main__": # 主进程创建 global_request_counter = SharedCounter(0) # 然后启动 Gunicorn,worker 进程会继承这个对象的引用 # (具体实现依赖于 Gunicorn 的 fork 模式)方案三:最推荐——依赖注入 + 请求上下文
最优雅的方案,是根本不维护一个全局计数器,而是利用 Web 框架的请求上下文。
# Flask 示例 from flask import g, request from datetime import datetime @app.before_request def before_request(): # 为每个请求创建一个唯一的 ID 和开始时间 g.request_id = request.headers.get('X-Request-ID', str(uuid.uuid4())) g.start_time = datetime.now() @app.after_request def after_request(response): # 记录本次请求的耗时和 ID duration = (datetime.now() - g.start_time).total_seconds() app.logger.info(f"Request {g.request_id} completed in {duration:.3f}s") return response在这个模式下,“计数”不再是全局的,而是每个请求的元数据。你需要的不是“总请求数”,而是“当前请求的唯一标识”和“本次请求的耗时”。这恰恰是监控和日志最需要的信息。它天然线程安全、进程安全,且无需任何额外的同步原语。
3.3 缓存:从无限增长的字典到有边界的智能缓存
原始_cache = {}的问题,是缺乏边界和策略。现代缓存必须回答三个问题:1) 最多存多少?2) 存多久?3) 满了怎么办?
方案一:functools.lru_cache—— 最简单的 LRU 缓存
# utils/cache.py from functools import lru_cache from typing import Any, Tuple # 为数据库查询函数添加缓存 @lru_cache(maxsize=1000) # 最多缓存 1000 个不同参数的调用结果 def get_user_by_id(user_id: int) -> dict: # 这里是真实的数据库查询 return database.query_one("SELECT * FROM users WHERE id = %s", user_id) # 使用 user = get_user_by_id(123) # 第一次调用,查数据库 user = get_user_by_id(123) # 第二次调用,直接返回缓存lru_cache的优势是开箱即用,线程安全,且实现了经典的 LRU(Least Recently Used)淘汰策略:当缓存满时,自动淘汰最久未使用的条目。maxsize=None表示无限制,但生产环境严禁使用。
方案二:带 TTL 的自定义缓存类
# utils/ttl_cache.py import time from typing import Any, Dict, Optional, Callable class TTLCache: def __init__(self, default_ttl: int = 300): """ 初始化一个带 TTL 的缓存。 :param default_ttl: 默认过期时间(秒) """ self._cache: Dict[str, tuple[Any, float]] = {} self._default_ttl = default_ttl def _make_key(self, *args, **kwargs) -> str: """生成缓存 key,简单起见,用字符串拼接""" key_parts = [str(arg) for arg in args] key_parts.extend([f"{k}={v}" for k, v in sorted(kwargs.items())]) return "|".join(key_parts) def set(self, key: str, value: Any, ttl: Optional[int] = None): """设置缓存项""" expire_at = time.time() + (ttl or self._default_ttl) self._cache[key] = (value, expire_at) def get(self, key: str) -> Optional[Any]: """获取缓存项,自动处理过期""" if key not in self._cache: return None value, expire_at = self._cache[key] if time.time() > expire_at: del self._cache[key] return None return value def delete(self, key: str): """删除缓存项""" self._cache.pop(key, None) # 全局缓存实例 user_cache = TTLCache(default_ttl=60) # 用户数据缓存 60 秒 def get_user_cached(user_id: int) -> dict: key = f"user:{user_id}" cached = user_cache.get(key) if cached is not None: return cached # 查询数据库 user = database.query_one("SELECT * FROM users WHERE id = %s", user_id) # 写入缓存 user_cache.set(key, user, ttl=60) return user这个TTLCache提供了精确的过期控制。你可以为不同的数据设置不同的 TTL:用户资料缓存 60 秒,商品价格缓存 5 秒,系统配置缓存 1 小时。它比lru_cache更灵活,但需要你手动管理 key 的生成和缓存的读写。
方案三:生产级选择——Redis
对于高并发、分布式服务,内置内存缓存远远不够。Redis 是事实标准。
# utils/redis_cache.py import redis import json import time from typing import Any, Optional class RedisCache: def __init__(self, host: str = "localhost", port: int = 6379, db: int = 0): self.client = redis.Redis(host=host, port=port, db=db, decode_responses=True) def set(self, key: str, value: Any, expire: int = 300): """设置缓存,value 会自动序列化为 JSON""" serialized = json.dumps(value) self.client.setex(key, expire, serialized) def get(self, key: str) -> Optional[Any]: """获取缓存,自动反序列化""" data = self.client.get(key) if data is None: return None return json.loads(data) # 使用 cache = RedisCache(host=os.getenv("REDIS_HOST", "localhost")) def get_user_redis(user_id: int) -> dict: key = f"user:{user_id}" cached = cache.get(key) if cached is not None: return cached user = database.query_one("SELECT * FROM users WHERE id = %s", user_id) cache.set(key, user, expire=60) # 缓存 60 秒 return userRedis 的优势是:1) 数据持久化,服务重启不丢;2) 支持丰富的数据结构(List, Set, Hash);3) 天然分布式,所有 worker 进程共享同一份缓存;4) 提供了INCR,EXPIRE,PUB/SUB等高级命令。代价是引入了一个外部依赖,需要运维 Redis 实例。
3.4 单例模式:从脆弱的全局变量到可靠的依赖注入
原始database.py的单例,败在了两个地方:1) 没有处理多线程下的双重检查锁定;2) 完全忽略了多进程场景。真正的单例,应该是“每个进程一个实例”,而不是“整个 Python 解释器一个实例”。
方案一:模块级单例(推荐用于简单场景)
# database/pool.py import threading from contextlib import contextmanager from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker class DatabasePool: _instance = None _lock = threading.Lock() def __new__(cls): if cls._instance is None: with cls._lock: if cls._instance is None: # 这里创建引擎,注意:SQLAlchemy 引擎本身是线程安全的 engine = create_engine( "postgresql://localhost/mydb", pool_size=20, max_overflow=10, pool_pre_ping=True, # 自动检测并替换失效连接 ) cls._instance = super().__new__(cls) cls._instance.engine = engine cls._instance.Session = sessionmaker(bind=engine) return cls._instance def get_session(self): return self.Session() # 使用 db_pool = DatabasePool() # 全局导入,但实例创建是延迟且线程安全的 @app.route('/users') def list_users(): session = db_pool.get_session() try: users = session.query(User).all() return jsonify([u.to_dict() for u in users]) finally: session.close() # 必须关闭,释放连接回池这个DatabasePool类,利用了 Python 的__new__方法和双重检查锁定(Double-Checked Locking),确保在多线程环境下,engine只被创建一次。create_engine返回的Engine对象,本身就是为多线程设计的,它内部维护了一个连接池,sessionmaker创建的 Session 也是线程局部的。所以这个单例是安全的。
方案二:应用级依赖注入(最推荐)
# app.py from database.pool import DatabasePool from api_client import APIClient class Application: def __init__(self, config): self.config = config self.db_pool = DatabasePool(config.database.url) self.api_client = APIClient(config.api.endpoint, config.api.key) self.logger = setup_logger(config.log_level) def handle_user_request(self, user_id: int): session = self.db_pool.get_session() try: user = session.query(User).filter(User.id ==