hyperf控制器默认为单例,所有请求共享同一实例,导致$this->userid等成员变量跨协程串号;正确做法是用context::set/get管理请求级状态,而非依赖对象属性或static。

Hyperf控制器是单例,不是每次请求新建一个实例
Hyperf 默认把 Controller 注册为单例(Singleton),Worker 进程启动后只初始化一次,后续所有请求都复用同一个对象实例。这意味着 $this->userId、$this->cache、$this->requestId 这类属性,一旦被某个协程写入,就留在内存里,随时可能被下一个协程读到——不是“偶尔出错”,而是“必然串号”。
常见错误现象包括:
- 用户 A 登录后,用户 B 的接口返回了 A 的头像或权限数据
- 缓存数组越积越大,最终 OOM;或者 key 冲突导致查到错误数据
- 压测时偶发 500,日志显示
Undefined index: user_id,但代码里明明刚 set 过
协程上下文隔离 ≠ 对象属性自动隔离
PHP 没有语言级协程感知机制,protected $data = [] 是进程级共享的,而 Context::get() 才是真正绑定到当前协程生命周期的。你可以把协程上下文理解成“每个请求专属的便签纸”,而类属性是“办公室墙上共用的白板”。
正确做法必须明确区分场景:
- 临时状态(如当前用户 ID、请求追踪 ID)→ 用
Context::set('user_id', $uid)和Context::get('user_id') - 请求级 Request/Response 对象 → 直接从方法参数获取,它们本身已协程安全
- 配置值(如
app.name)→ 用#[Value("app.name")]注解,由 DI 容器在实例化时注入,不随请求变化 - 绝对不要:在构造函数或方法里给
$this->xxx赋值,除非该值在整个 Worker 生命周期内恒定不变(比如硬编码的超时阈值)
WebSocket 场景下类属性污染更致命
WebSocket 连接长驻、协程不退出,如果把 $connection 或 $fd 存进控制器属性,等于把整个连接句柄钉死在单例对象上。结果是:
- 后续任意请求调用该控制器方法,都会误操作前一个用户的连接
- 心跳检测失效,因为
$this->lastPingTime被不同连接反复覆盖 - Redis Pub/Sub 广播发错频道,因为
$this->channel已不是当前连接所属
正确路径只有一条:所有连接相关数据必须存在 Context 里,且在 onOpen / onMessage / onClose 回调中显式管理,配合 Context::override() 隔离子协程上下文(如异步任务)。
为什么 static 属性也危险
static $counter++ 看似只是计数,但它在 Swoole Worker 进程内全局共享,不受协程上下文影响。哪怕你只在日志里打个计数器,高并发下也会出现数值跳跃、重复或丢失——这不是竞态问题,是根本性设计误用。
替代方案很直接:
- 需要统计请求数?走 Prometheus +
Counter或 RedisINCR - 需要生成唯一 ID?用
swoole_timer_tick()+ 微秒时间戳,或uniqid('', true) - 需要跨协程传递少量元数据?仍走
Context::set(),别碰 static
真正容易被忽略的是:连 IDE 的自动补全和 PHPStan 静态分析都看不出这类问题,只有上线后流量一上来,数据就开始“随机漂移”。











