OrdersModule和BillingModule,两个功能模块,都在providers数组里注册了CacheService,都在构造函数里注入它,都调用cache.increment('hits')。在预发布环境里,计数器永远不会超过每个模块各自产生的数值——OrdersModule报告40次,BillingModule报告12次,而仪表盘上显示的总数从定义上就是错的,因为压根不存在一个可以被统计错的"单一计数器"。

CacheService被标注了@Injectable(),没人设置过scope。按你读过的所有文档定义,它应该是个单例。它确实是——只是不是你默认理解的那种单例

打开网易新闻 查看精彩图片

问题出在注册位置,不在类本身

两个Service都在构造函数里注入了CacheService,都相信"单例"在依赖注入框架里的常规含义:一个实例,谁请求谁共享。这个信任本身合理——但在这里是错的,因为有个容易被扫过的细节:CacheService同时出现在两个不同模块的providers数组里。

Nest不会问"这个类在整个应用里实例化过吗?"它问的是"这个token在这个模块的注入器里注册过吗?"OrdersModule和BillingModule互不导入,也没有共享一个导出CacheService的公共模块,所以Nest把两处注册当成两个独立注册——于是构建出两个独立实例。每个Service拿到的都是一个真实、可用、完全单例的CacheService。只是它们不是同一个。

核心心智模型:注入器树,不是全局映射表

NestJS的DI容器不是一张从类到实例的全局映射表。它是一棵注入器树,每个模块一个注入器,每个注入器只认识自己模块注册过的provider——要么直接写在providers数组里,要么通过import间接引入。这个模型解释了为什么"单例"在这里失效:单例的范围是模块级,不是应用级。

要拿到真正的全局单例,标准做法是创建一个共享模块,把CacheService放进它的providers和exports,然后让OrdersModule和BillingModule都import这个共享模块。这样两个模块的注入器会向上查找到同一个注册,得到同一个实例。这个改动很小,但行为差异是本质性的——一个计数器,还是两个计数器,取决于你把它注册在哪一层。