单例服务必须无状态或只存全局配置,请求级数据须通过协程上下文(如Context::set/get)、RequestInterface或SessionInterface隔离存储,避免成员变量污染;优先选用scoped或transient作用域替代单例。

Hyperf 3.0 中服务容器的依赖注入本身是安全的,但“单例对象状态污染”问题并非来自 DI 容器机制本身,而是源于开发者将**请求级、协程级状态**错误地塞进了本该无状态的单例服务中。修复的关键不是改容器配置,而是厘清生命周期边界、约束数据存放位置。
单例服务必须无状态或只存全局配置
单例(Singleton)在 Hyperf 进程常驻期间始终复用同一个实例。如果它内部保存了用户 ID、临时 token、请求上下文数据等,多个协程并发访问时必然互相覆盖。
- ❌ 错误示例:在单例服务里定义 public $currentUserId = null;,并在每次请求中赋值
- ✅ 正确做法:把用户 ID 从 Request 或 Session 中按需获取,不缓存在单例属性中
- ✅ 配置类、连接池管理器、工具类等真正无状态或只读配置的服务,才适合设为单例
请求/协程级数据必须走上下文隔离通道
用户会话、请求参数、中间件注入的上下文等,都属于一次请求(即一个协程)的私有数据,不能靠单例对象承载。
- Session 数据必须通过 SessionInterface(依赖注入获取)操作,它底层自动绑定当前协程上下文
- Request 对象同样需注入 RequestInterface,而非尝试缓存 $_GET/$_POST 全局变量
- 若需跨中间件/控制器传递临时数据,可用 Context::set() + Context::get(),它基于协程 ID 隔离存储
慎用 @Singleton 注解,优先考虑 scoped 或 transient
Hyperf 的 DI 容器支持作用域(scoped)和瞬态(transient)生命周期,它们比单例更贴合实际业务场景。
- Scoped:在一次 HTTP 请求生命周期内共享同一实例(类似 Laravel 的 request scope),适合需要复用但又不能跨请求的状态封装类
- Transient:每次注入都新建实例,适合轻量、无共享需求的工具类或 DTO 构造器
- 注册方式示例(config/autoload/dependencies.php):
'App\Service\OrderValidator' => ['class' => App\Service\OrderValidator::class, 'scope' => 'scoped']
排查已污染的单例:用协程 ID 打印日志定位
当怀疑某个单例被污染时,不要只看逻辑,要验证实际运行时的数据归属。
- 在服务方法中加入:var_dump(co::tid(), $this->someState);
- 观察不同协程 ID(co::tid)是否输出了彼此混杂的 state 值
- 若发现多个协程共用同一对象且状态错乱,说明该服务不该是单例,应改为 scoped 或重构为无状态


















