
1. 事故现场用户 A 怎么就看到了用户 B 的数据先聊个真实的坑。前两年我负责一个基于 Hyperf 做的 To B 系统上线两个月后陆续有客户反馈客服账号登录后偶尔会在订单列表里看到不属于自己客户的订单。刚开始以为是 SQL 条件漏了查了一圈没发现问题。更诡异的是这个 bug 在测试环境复现不出来一上生产就偶尔冒出来频率不高但每次都很要命。后来在一个崩溃现场加了日志把当前请求的 token、用户 ID、订单归属方全部打出来才定位到核心矛盾——请求参数里解析出来的用户 ID 确实是 A但后面所有业务查询用的都是 B 的用户 ID。A 看到 B 的数据不是跨权限漏洞而是代码里某个静态变量在作祟。那个变量在某个服务里被设计成全局缓存用户信息PHP-FPM 时代每个请求独立进程互不干扰到了 Swoole 常驻内存 协程并发环境下一个进程里同时跑着几十个协程大家读写的是同一块内存于是一锅粥。这就是协程上下文要解决的根本问题在并发协程里如何让每个协程拥有独立、安全、不串数据的数据存储空间。Hyperf 作为 Swoole 常驻框架把协程上下文做成了框架内置能力用好了它这类数据串号事故基本能杜绝。本文适合所有正在使用 Hyperf 或 Swoole 做常驻服务的 PHP 开发者尤其适合被并发数据错乱坑过、但还没系统理解协程上下文机制的人。我会先从原理讲透再给到实际可抄的代码和踩坑经验最后附排查方案。2. 协程为什么能把数据搞乱根源在共享内存 抢占式调度2.1 协程的内存模型与进程/线程的差异在深入 Hyperf 的解决方案前一定要先把协程的底层模型搞清楚。Swoole 的协程本质上是在同一个进程内通过事件循环和栈切换实现的用户态轻量级线程。它和进程、线程的区别可以这么看进程每个进程有自己的内存空间天然隔离通信要靠 IPC。线程同一进程内的多个线程共享堆内存但有内核级调度操作系统会做时间片切换。协程同一进程内的多个协程共享全部内存调度由用户态代码在 IO 等待点主动让出yield没有内核参与切换成本极低。正因为协程共享全部内存所以静态变量、全局变量、单例对象的属性在协程环境下天然是全局共享的。这不是 bug而是协程的固有特性。问题在于很多从 PHP-FPM 转过来的开发者潜意识里还带着一个进程只服务一个请求的老思路把用户态数据塞在静态变量里于是事故就发生了。2.2 静态变量的灾难现场从全局缓存到全局串扰我举个例子这是网上非常经典的反面教材class UserContext { public static ?int $userId null; }老 PHP 项目里登录中间件把用户 ID 往这里一塞后续所有代码去读看起来清爽高效。在 PHP-FPM 下每个请求一个进程进程结束变量销毁完全没问题。但在 Swoole/Hyperf 下// 请求 A 进入协程 1 UserContext::$userId 1001; // 此时请求 B 进入协程 2进程只有一个内存只有一份 UserContext::$userId 1002; // 请求 A 的协程继续执行读取 UserContext::$userId // 拿到的可能是 1002也可能是 1001取决于调度时序这不是理论推导我线上出的那个事故就是这么来的某个中间件把当前用户信息存到了静态属性里后续业务里再去读。两个请求并发时后写入的覆盖了先写入的客户数据直接串号。2.3 为什么 PHP-FPM 时代没这问题常驻进程就要命核心差异是执行模型PHP-FPM一个 Worker 进程同时只处理一个请求。请求进入创建变量执行请求结束所有变量销毁。静态变量也随着进程专属于当前请求。Swoole/Hyperf一个 Worker 进程同时处理成百上千个协程请求。进程不退出静态变量生命周期被无限拉长所有协程只要引用同一个类共享的就是同一块内存。也就是说同样的代码从 PHP-FPM 迁移到 Swoole 常驻模型如果不对数据存储位置做设计必然出并发安全问题。这不是框架的 bug是运行模型升级后的必然代价。解决思路不是不用静态变量有些框架内部配置还是得用而是凡是跟当前请求/当前业务链路绑定的数据必须放到按协程隔离的容器里也就是协程上下文。3. Hyperf 协程上下文的设计思路以协程 ID 为维度的独立存储3.1 协程上下文到底是什么Hyperf 的协程上下文Context简单说就是一个以协程 ID 为主键的数据容器。框架底层维护了一套存储结构每次读写数据时都会根据当前协程的 ID 找到属于这个协程的独立空间。协程 A 写入的数据存在 A 的空间协程 B 完全看不到协程结束时这个空间连带数据一起被销毁。这就好比同一间大办公室每个工位都有独立的带锁抽屉。静态变量是办公室中央的公用白板谁都能写写完就覆盖协程上下文是工位抽屉每个人只能碰自己的离职协程结束后抽屉清空。在 Hyperf 中这套能力通过Hyperf\Context\Context类提供早期版本是Hyperf\Utils\Context3.x 里已迁移。它的核心 API 就几个但非常好用。3.2 底层实现SplObjectStorage 与协程 ID 的配合Hyperf 协程上下文底层实现并不神秘。我把关键逻辑简化一下只是帮助理解实际源码更完善namespace Hyperf\Context; class Context { protected static array $context []; protected static \SplObjectStorage $storage; public static function set(string $id, mixed $value): mixed { if (Coroutine::inCoroutine()) { static::$context[Coroutine::id()][$id] $value; } else { static::$context[0][$id] $value; } return $value; } public static function get(string $id, mixed $default null): mixed { if (Coroutine::inCoroutine()) { return static::$context[Coroutine::id()][$id] ?? $default; } return static::$context[0][$id] ?? $default; } public static function has(string $id): bool { if (Coroutine::inCoroutine()) { return isset(static::$context[Coroutine::id()][$id]); } return isset(static::$context[0][$id]); } public static function destroy(?int $coroutineId null): void { $coroutineId ?? Coroutine::id(); unset(static::$context[$coroutineId]); } }这里用Coroutine::id()拿到当前协程的唯一标识再用协程 ID 作为数组 key 做隔离。Coroutine::id()返回 0 时表示当前不在协程环境中这时 Hyperf 会把数据放到$context[0]下保证框架在非协程场景比如某些命令也能正常读写。但这里有个容易忽略的点Swoole 的协程 ID 在协程销毁后新协程可能复用同一个 ID。也就是说如果协程结束后不清理$context[协程ID]下次有协程拿到同一个 ID 时会读到上一次遗留的脏数据。所以 Hyperf 在协程创建时会注册清理逻辑协程结束自动销毁上下文数据。3.3 Hyperf 是怎么自动清理上下文的直接用 Swoole 原生\Swoole\Coroutine::create()创建协程Hyperf 的自动清理机制不会生效。必须通过 Hyperf 封装的Hyperf\Coroutine\Coroutine::create()或go()函数框架才会在协程启动后挂一个清理操作。大致逻辑namespace Hyperf\Coroutine; class Coroutine { public static function create(callable $callable): int { $coroutineId \Swoole\Coroutine::create(function () use ($callable) { try { $callable(); } finally { Context::destroy(); } }); return $coroutineId; } }finally保证了只要协程代码执行完毕无论正常结束还是抛异常上下文都会被销毁。这套机制和 PHP 的finally关键字配合是协程上下文不会内存泄漏的关键保障。在 Web 请求场景下Hyperf 框架在收到 HTTP 请求后本来就是通过协程来处理的每个请求一个协程请求结束协程退出上下文自动销毁。所以你并不需要自己去操心清理问题但要记住上下文的数据生命周期 协程的生命周期。如果协程长时间不退出比如常驻队列消费上下文里的数据就会一直存活。4. 协程上下文 API 实战set/get/override 的正确打开方式4.1 基础读写set 与 get最常用的两个方法用法很简单use Hyperf\Context\Context; // 写入当前协程上下文 Context::set(order.id, 10086); // 读取当前协程上下文 $orderId Context::get(order.id);在超全局函数context_set和context_getHyperf 也提供了辅助函数的配合下代码可以写得很简洁context_set(user.id, $userId); $userId context_get(user.id);实际项目里我推荐把上下文操作封装到具体的 Service 或中间件里不要裸奔在业务控制器里否则 key 满天飞维护起来很痛苦。比如用户信息存取统一封装class AuthContext { private const KEY auth.user; public static function setUser(User $user): void { Context::set(self::KEY, $user); } public static function getUser(): ?User { return Context::get(self::KEY); } public static function hasUser(): bool { return Context::has(self::KEY); } }这样业务代码里调用AuthContext::getUser()就可以不用关心底层 key 是什么也方便以后扩展比如从纯 Context 换成 Redis 分布式上下文时只改这一个类。4.2 override在原有值基础上做修改Context::override()是很多新手会忽略的 API但它在中间件、AOP 里非常有用。它的语义是对当前协程上下文中的某个 key取旧值作为参数执行闭包后把返回值写回。use Hyperf\Context\Context; $result Context::override(request.attributes, function (?array $previous) { // 首次调用时 previous 为 null $previous $previous ?? []; $previous[trace_id] generateTraceId(); return $previous; });我在实战中比较常用的场景是在中间件里逐步给上下文里的请求体对象补充信息。比如多个中间件依次处理同一个请求对象每个中间件都想往对象上加额外的字段或标记用override可以优雅地做到取出-改造-放回一步到位。4.3 配合容器Context 和 ApplicationContext 别混淆新手经常会混淆两个东西Hyperf\Context\Context协程上下文按协程 ID 隔离用于存放请求级数据。Hyperf\Context\ApplicationContext应用容器上下文存放整个进程级共享的单例服务比如 Redis、DB 连接池对象。ApplicationContext 是整个 Worker 进程唯一的所有协程共享同一个容器实例而 Context 是每个协程独立的。大家提到容器时绝大多数情况说的是 ApplicationContext而协程上下文就是本文说的 Context两者不要混用。如果你把Context::set(user, $user)当成全局变量传给其他协程用那基本等于没用。5. 协程嵌套的坑子协程为什么拿不到父协程的数据5.1 一个典型的坑子协程读不到父协程上下文协程上下文按协程 ID 隔离意味着父协程 Context 里存的数据子协程默认是拿不到的。看这段代码use Hyperf\Context\Context; use Hyperf\Coroutine\Coroutine; Context::set(order.id, 10086); Coroutine::create(function () { // 子协程的 Context 是空白的 $orderId Context::get(order.id); // null var_dump($orderId); });很多同学第一次接触时会大呼神奇明明刚 set 了怎么子协程里读不到原因很简单子协程是一个全新的协程 ID它的 Context 空间是空的。这就是协程嵌套场景下最常见的数据传递难题。5.2 解决方案显式传参 or 手动拷贝上下文针对父子协程数据传递我的建议是分场景处理。第一子协程逻辑简单、只依赖少量数据时直接闭包 use 传参Coroutine::create(function () use ($orderId) { // 直接用 $orderId不用去 Context 里取 });这是最清晰、最不容易出错的方案也最容易阅读和排查。第二子协程需要完整继承父协程的部分上下文时可以用Context::copy()。Hyperf 高层版本提供了这个方法use Hyperf\Context\Context; use Hyperf\Coroutine\Coroutine; $coroutineId Coroutine::create(function () { // 子协程逻辑期望能拿到 trace_id 和 user_id }); // 父协程在子协程运行前或运行中手动拷贝上下文 Context::copy([trace.id, user.id], $coroutineId);一个坑提醒Coroutine::create()创建协程后是立即调度的理论上子协程可能先于Context::copy()执行。如果子协程依赖拷贝的数据最好把拷贝动作放在子协程内部去执行子协程里调用Context::copy()把父协程的数据拉过来。Hyperf 官方在 process 或 parallel 场景下有更成熟的方案但一般显式传参已经能覆盖绝大多数业务。第三使用parallel()并发执行的场景每个子任务默认也是独立上下文。如果希望所有子任务共享某个不可变参数比如 traceId同样采用显式传参的方式use Hyperf\Coroutine\Parallel; $parallel new Parallel(10); for ($i 0; $i 10; $i) { $parallel-add(function () use ($traceId) { // 并发执行每个子协程都有独立 Context return querySomething($traceId); }, task_{$i}); } $results $parallel-wait();5.3 连接池、事务与协程上下文的经典配合协程上下文在框架层面最重要的应用之一是数据库连接管理。数据库连接是不能简单全局共享的否则事务会错乱。假设协程 A 开了事务BEGIN还没提交协程 B 拿到同一个连接执行了UPDATE那 B 的修改可能被 A 的事务影响或者 A 回滚时把 B 的数据也回滚了。为了避免这个问题Hyperf 的连接池在分配连接时会把连接实例绑定到当前协程的上下文同一个协程内多次获取连接优先从 Context 里取保证拿到的是同一个连接事务才能连续。协程结束Context 销毁连接归还池子下次其他协程才能复用。这就是为什么你在 Hyperf 里写事务很放心不用手动管理连接。但如果你自己在代码里用一个静态属性持有PDO实例或者Redis连接再开协程并发操作就会踩到事务错乱的坑——本质上就是协程上下文隔离没做到位。6. 协程上下文的典型应用场景与代码实战6.1 用户登录态与请求级数据传递这是协程上下文最典型的业务场景。传统框架里用超全局变量$_SESSION、$_REQUEST在 Hyperf 里这些都是请求协程隔离的可以直接放到上下文里。下面是中间件 控制器的完整示例namespace App\Middleware; use Hyperf\Context\Context; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Message\ResponseInterface; class AuthMiddleware { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $token $request-getHeaderLine(Authorization); $user $this-authService-verifyToken($token); // 伪代码 if (!$user) { return $this-response-json([code 401, msg 未登录]); } // 将用户信息写入当前协程上下文 Context::set(auth.user, $user); Context::set(auth.user_id, $user-getId()); return $handler-handle($request); } }控制器里取用户信息namespace App\Controller; use Hyperf\Context\Context; class OrderController { public function list() { $userId Context::get(auth.user_id); $orders $this-orderService-getUserOrders($userId); return $this-response-json($orders); } }只要每个请求都是独立协程这个auth.user_id就绝对安全A 请求永远读不到 B 请求的用户 ID。注意如果中间件里用了异步任务或者go()派生子协程去查数据库子协程里要主动把 user_id 传下去别指望它自己能从父协程 Context 里拿到。6.2 链路追踪traceId 在协程中不丢线线上排查问题最烦的是日志里没有关联 ID。通过协程上下文可以轻松实现整个请求链路包括调用下游 RPC、Redis、MQ的 traceId 传递。入口中间件里use Hyperf\Context\Context; use Hyperf\Coroutine\Coroutine; class TraceMiddleware { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $traceId $request-getHeaderLine(X-Trace-Id) ?: $this-generateTraceId(); Context::set(trace.id, $traceId); Context::set(trace.span_id, $this-generateSpanId()); // 日志单例里绑定 traceId后续所有 log 自动带上 Logger::withContext([ trace_id $traceId, co_id Coroutine::id(), ]); return $handler-handle($request); } }在业务代码的任意位置可以直接Context::get(trace.id)拿到当前请求的全局追踪 ID。遇到需要并发调下游的场景子协程里也会把 traceId 一并带给下游服务use Hyperf\Coroutine\Coroutine; use Hyperf\Context\Context; $traceId Context::get(trace.id); $results \Hyperf\Coroutine\parallel([ function () use ($traceId) { return $this-httpClient-get(/api/service-a, [X-Trace-Id $traceId]); }, function () use ($traceId) { return $this-httpClient-get(/api/service-b, [X-Trace-Id $traceId]); }, ]);这样一次用户请求无论内部拆成多少个协程、多少个下游调用日志里都能通过 traceId 串起来。6.3 请求参数解析与全局过滤器有些场景里一个请求的生命周期内需要反复读取当前请求的参数或某个已解析的对象例如当前租户 ID、当前语言环境。每次都从 PSR-7 Request 对象里解析成本高、代码丑不如在入口中间件解析一次存入上下文// 租户中间件 Context::set(tenant.id, $request-getHeaderLine(X-Tenant-Id) ?: default); // 业务代码 $tenantId Context::get(tenant.id, default);再配合 Hyperf 的Context::override还可以做请求上下文参数一步步累积的需求比如把认证信息、日志信息、权限信息都挂载到同一个数组 key 上方便后期一次性取出。7. 高频问题排查数据错乱了怎么定位7.1 数据串扰排查手段如果你已经遇到数据错乱的线上问题按下面顺序排查能快速缩小范围第一步确认是否在协程中使用了静态变量/全局变量。全局搜一下static关键字和global重点看存放业务数据的属性。如果有静态属性在运行期被写入用户数据基本可以锁定了。第二步打点确认协程 ID。在可疑的读写位置输出\Swoole\Coroutine::getuid()对比两个请求是否在同一个协程 ID 下执行。如果不同协程 ID 却读到同一个值说明数据源不是协程上下文而是共享内存里的某个静态属性。第三步检查是否有协程没走框架封装。如果你直接用new \Swoole\Coroutine()或者裸\Swoole\Coroutine::create()创建协程上下文清理机制不会自动注册可能出现上下文残留问题——协程 ID 复用后读到上一次的数据。第四步检查是否有超长生命周期协程。如果某个消费队列协程常驻不退出它 Context 里的数据会一直积累即使不串号也可能造成内存增长。7.2 常见坑速查表症状可能原因解决方案用户数据串号静态属性存放用户信息迁移到 Context子协程里取不到父协程数据协程 ID 不同上下文隔离显式传参或 Context::copy协程结束后内存增长裸用 Swoole 协程创建函数统一用 go() 或 Coroutine::create同一个 Context key 值被覆盖多个协程同时写同一 key确认 key 是否真正按协程隔离上下文里数据还在但逻辑不对可能是协程 ID 复用 清理不彻底检查是否有未走框架的协程创建同一连接池连接事务错乱连接未绑定协程上下文不要手动持有连接走 Hyperf 连接池和事务调用7.3 结合实际排查的一个小技巧我给团队定过一个硬性规范任何跨协程共享的业务数据禁止通过公共静态属性传递必须走参数传递或 Context。并且代码评审时凡是在 Repository/Service 层看到static属性都会打回重写。这条规范看起来简单但能避免掉大部分协程数据错乱事故。另外排查数据错乱时最好临时在可疑地点加上协程 ID 调用堆栈的日志use Hyperf\Coroutine\Coroutine; use Hyperf\Context\Context; $currentCoId Coroutine::id(); $userId Context::get(auth.user_id); logger()-warning(可疑数据, [ co_id $currentCoId, user_id $userId, trace debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5), ]);当问题频率不高时这样的日志能帮你抓住复现现场快速定位是哪个协程、哪段代码引起的串扰。8. 总结经验上下文隔离的使用守则个人实践下来协程上下文的使用遵守这几条能少踩很多坑第一凡请求级数据一律入 Context。用户 ID、租户 ID、traceId、请求体解析结果、语言环境、权限标记这些生命周期等于一次请求的数据全部用 Context 存读取永远不要用静态属性或者进程级单例的属性去保存。第二子协程数据传递显式传参是首选。只有当子协程数量特别多、传参列表很长时才考虑Context::copy()。闭包 use 传参简单直观代码可读性最强出问题也最好查。第三不要自己手动管理协程生命周期。用框架的go()、Coroutine::create()、parallel()就够了它们会帮你注册上下文清理。裸用 Swoole 原生函数创建协程遇到上下文残留和内存泄漏排查成本非常高。第四上下文里的数据尽量是不可变对象或简单值。如果往 Context 里塞一个可变对象多个协程即使空间独立但如果对象内部引用了共享资源比如连接池对象依然可能出现并发问题。要确保存入的东西在协程隔离上是真隔离的。第五上线前做并发压力测试。协程数据错乱问题在低并发下很难复现但在高并发、IO 密集场景下特别容易暴露。用 Swoole 压测工具或者并发请求脚本多跑几轮能提前发现静态变量残留的问题。因为协程上下文这个机制太重要我现在写 Hyperf 业务代码时几乎每一层都在用中间件存用户态、Service 层存取业务上下文、日志组件绑 traceId、连接池管连接、消息队列消费时做链路标记。它就是 Hyperf 并发模型的地基理解透它等于理解了 Swoole 常驻服务的一半精髓。我踩过最大的坑就是最初没弄懂父子协程上下文隔离以为 set 了就能全局读到结果在并发推送消息时把用户 A 的消息塞给了用户 B。后来把这一整套机制吃透项目里就再没出现过协程数据串扰的线上事故。