news 2026/8/25 21:48:33

别把 ThreadLocal 写进 Serializer:一次 SaaS 多租户 Jackson 上下文污染排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
别把 ThreadLocal 写进 Serializer:一次 SaaS 多租户 Jackson 上下文污染排查

别把 ThreadLocal 写进 Serializer:一次 SaaS 多租户 Jackson 上下文污染排查

摘要

这次问题的表象很像前端金额计算错误:

页面看到的订单金额:70 数据库里的订单金额:70.89 支付校验报错:页面提交金额与订单实际金额不一致

继续查下去会发现,数据库价格没有错,后端订单计算也没有错,真正丢掉0.89的地方发生在 HTTP 响应 JSON 序列化阶段。

根因是一个公共 JacksonContextualSerializercreateContextual()阶段读取了当前请求里的租户金额精度,并把这个请求级配置保存到了 serializer 实例字段里。Jackson 又会缓存上下文化后的属性序列化器,于是某个租户第一次触发某个 DTO 字段序列化时使用的精度,可能被后续租户继续复用。

一句话概括:

ThreadLocal 本来隔离了请求,但代码把 ThreadLocal 的值复制进了 JVM 级缓存对象。

这不是典型的“商户 A 查到了商户 B 的数据”,而是另一类多租户问题:租户配置污染

1. 问题现象:页面金额和订单金额对不上

某个前台交易场景里,支付时连续出现金额不一致:

expected total = 70.89 current total = 70

页面上看到的商品价格可以抽象成:

商品 A:27 商品 B:43 页面合计:70

但数据库里的订单明细是:

商品 A:27.0000 商品 B:43.8900 订单总额:70.8900

第一反应通常会怀疑几类问题:

  • 前端是不是把金额转成整数了?
  • 后端是不是重新算订单时丢了小数?
  • 数据库字段是不是 scale 不够?
  • 某个价格等级、促销价、手工改价是不是覆盖了原价?

这些方向都要查,但不能先入为主。

这类问题最稳的排查方式是把金额沿链路切开:

数据库原始值 -> Service 内存值 -> 响应 Model 值 -> JSON 响应值 -> 前端展示值 -> 下单与支付校验值

最终定位到的变化点是:

响应 Model 中仍然是 43.89 JSON 返回给前端时变成了 43

也就是说,问题不在数据库、不在订单计算,也不在前端传参,而是在 Jackson 写 JSON 的阶段发生了精度截断。

2. 先分清:数据穿透和配置污染不是一回事

多租户系统里,一听到“串租户”,很多人会马上想到数据隔离漏洞。

比如:

当前请求 merchantId = B SQL 却查到了 merchantId = A 的商品

这当然是严重问题,属于数据租户穿透。

但这次不是这种情况。

真实发生的是:

商品数据:当前租户自己的 43.89 金额精度:错误沿用了另一个租户的 0 位精度 最终 JSON:43

也就是说,当前租户没有看到其他租户的商品数据,但当前租户的金额展示规则被别的租户污染了。

这同样是多租户隔离问题,只是泄漏对象不是业务数据,而是租户配置。

在 SaaS 系统里,租户配置也会影响业务正确性。金额精度、数量精度、时区、语言、税制、币种、权限策略、打印模板,都不能随意串。

3. 数据到底在哪一步变了

排查时先不要急着看框架源码,先证明业务数据在哪里开始失真。

可以把这次链路抽象成:

商品基础价 = 43.8900 ↓ 定价服务返回 Amount(43.89) ↓ 响应模型 setSellingPrice(43.89) ↓ Jackson 序列化 JSON ↓ selling_price = 43 ↓ 前端计算 27 + 43 = 70 ↓ 后端订单仍按 27 + 43.89 = 70.89 校验 ↓ 支付失败

这个链路非常关键。

如果没有证明 Model 到 JSON 这一步发生变化,很容易在价格服务、订单服务、支付服务里绕很久,甚至误判为“下单金额和支付金额口径不统一”。

但一旦确认:

Java 对象里是 43.89 JSON 里是 43

问题范围就收敛到了 Jackson、自定义 serializer、全局 ObjectMapper 和请求上下文。

4. Jackson 的生命周期陷阱

大多数 Spring Boot 应用里,HTTP 响应 JSON 都会经过一个全局ObjectMapper

它通常是单例:

一个 JVM -> 一个主要 ObjectMapper -> 处理所有请求 -> 服务所有租户

为了统一金额、数量、日期、枚举、Long 等输出格式,项目里经常会注册自定义 serializer。

问题出在一个自定义金额 serializer 上。

它实现了类似ContextualSerializer的能力。这个接口本身没有问题,Jackson 也没有问题。它的用途是:根据字段上的稳定元数据,为某个属性创建专用 serializer。

稳定元数据包括:

字段名 字段类型 字段注解 格式化注解

例如:

OrderItemDTO.sellingPrice 是金额字段 OrderItemDTO.quantity 是数量字段

这些信息可以在createContextual()阶段读取并缓存。

但请求级信息不应该在这里缓存,例如:

当前租户 当前用户 当前语言 当前时区 当前金额精度 当前权限

因为createContextual()返回的 serializer 可能跟着 BeanSerializer 一起被 Jackson 长期缓存。它不是每次请求都重新创建。

5. 根因:请求级配置被保存进了缓存对象

错误结构可以抽象成这样:

publicJsonSerializer<?>createContextual(SerializerProviderprovider,BeanPropertyproperty){RequestContextcontext=RequestContextHolder.current();IntegermoneyScale=context.getMoneyScale();IntegerroundingMode=context.getRoundingMode();returnnewAmountSerializer(property.getName(),moneyScale,roundingMode);}

然后在真正序列化时:

publicvoidserialize(Amountvalue,JsonGeneratorgen,SerializerProviderprovider){Amountoutput=value.setScale(this.moneyScale,this.roundingMode);gen.writeString(output.toPlainString());}

这段代码最大的问题不是ThreadLocal,也不是ObjectMapper,而是生命周期错配。

它把:

请求级 moneyScale

保存进了:

Jackson 缓存的 serializer 实例字段

于是就变成:

请求 A:scale = 0 ↓ 首次触发某个 DTO 字段上下文化 ↓ 该字段 serializer 保存 scale = 0 ↓ 请求 B:scale = 3 ↓ 继续复用上一次缓存的 serializer ↓ 43.89 仍按 scale = 0 输出为 43

示意图如下:

6. 为什么不是所有接口同时出问题

这个问题很容易让人困惑:

为什么菜单接口错了,另一个详情接口又是对的? 为什么同一个商品在 A 页面丢小数,在 B 页面又正常?

原因是 Jackson 的上下文化 serializer 通常挂在“响应模型 + 属性”上。

可以理解成:

MenuItemDTO.sellingPrice -> 一个属性 serializer SkuDTO.sellingPrice -> 另一个属性 serializer OrderItemDTO.unitPrice -> 又一个属性 serializer

它们虽然底层类型都是Amount,JSON 字段也都像金额,但在 Jackson 缓存里不是同一个属性 writer。

因此可能出现:

菜单模型 sellingPrice 第一次由 scale=0 的租户触发 -> 后续一直输出整数 SKU 模型 sellingPrice 第一次由 scale=3 的租户触发 -> 后续保留三位 订单模型 unitPrice 第一次由 scale=2 的租户触发 -> 后续保留两位

这也解释了为什么问题在多节点环境里更飘。

每个 JVM 都有自己的 Jackson 缓存:

节点 A:某字段首次由 0 位精度租户触发 节点 B:同一字段首次由 3 位精度租户触发 节点 C:同一字段首次在无租户上下文下触发

于是相同接口,在不同节点上可能表现不同。

重启看似能恢复,是因为重启清掉了 JVM 内存缓存。但重启后哪个租户先访问,又会重新决定缓存状态。

重启能缓解,不代表根因消失。

7. 怎么证明是首次上下文化污染

单租户测试很难发现这个问题。

因为只用一个租户测,无论 serializer 是否错误缓存请求精度,结果都可能一致。

真正有价值的是顺序实验。

使用同一个ObjectMapper、同一个响应模型、同一个金额字段,构造两个租户上下文:

租户 A:scale = 0 租户 B:scale = 3

实验一:

A -> B

如果结果是:

A 输出 43 B 也输出 43

说明 B 没有使用自己的 3 位精度。

实验二:

B -> A

如果结果是:

B 输出 43.89 A 也输出 43.89

说明结果由第一次触发上下文化的租户决定。

再做一个跨模型实验:

MenuItemDTO.sellingPrice 首次 scale=0 -> 输出 43 SkuDTO.sellingPrice 首次 scale=3 -> 输出 43.89 MenuItemDTO.sellingPrice 再次 scale=3 -> 仍输出 43

这样基本可以证明三件事:

  1. 问题不是数据库精度。
  2. 问题不是前端展示。
  3. 同一个模型属性的 serializer 被首次请求污染,并被后续请求复用。

8. 修复目标:缓存结构,不缓存租户

正确修复不是关闭 Jackson 缓存。

Jackson 缓存本身是正常优化,问题是缓存对象里放了动态请求状态。

修复目标应该是:

serializer 可以被缓存 serializer 只能缓存稳定字段语义 租户精度必须在每次 serialize 时从当前请求读取

也就是:

publicJsonSerializer<?>createContextual(SerializerProviderprovider,BeanPropertyproperty){returnnewAmountSerializer(property.getName());}

真正写 JSON 时再读取当前请求:

publicvoidserialize(Amountvalue,JsonGeneratorgen,SerializerProviderprovider){RequestContextcontext=RequestContextHolder.current();Integerscale=context.getMoneyScale();IntegerroundingMode=context.getRoundingMode();Amountoutput=value.setScale(scale,roundingMode);gen.writeString(output.stripTrailingZeros().toPlainString());}

生命周期关系恢复为:

JVM 级 serializer -> fieldName = sellingPrice -> fieldType = MONEY 请求 A ThreadLocal -> scale = 0 -> 本次输出 43 请求 B ThreadLocal -> scale = 3 -> 本次输出 43.89

注意这里不是说所有代码都必须在serialize()里读 ThreadLocal。核心原则是:影响输出结果的动态变量,不能被提前复制进长期缓存对象。

9. 为什么不选择这些方案

9.1 在 Controller 里手工格式化

这只能修当前接口。

其他Amount字段还会继续暴露,而且金额规则会散落到业务层,后续很容易出现接口之间口径不一致。

金额输出是横切规则,应该收敛在统一序列化层。

9.2 每个请求创建 ObjectMapper

这样确实能绕开缓存污染,但成本高,也容易遗漏全局 Jackson 配置。

而且它没有修复错误的生命周期设计,只是把问题藏起来。

9.3 每次请求清空 Jackson 缓存

这是非常危险的做法。

全局共享对象上频繁清缓存,会影响所有请求,引入并发竞争和性能抖动。并且清完以后,下一次请求仍然可能再次污染。

9.4 关闭 Jackson 缓存

缓存不是根因。

正确做法是让缓存对象保持无租户状态,而不是对抗框架正常优化机制。

9.5 按 tenantId 缓存 serializer

这看起来像是补全缓存键,但代价并不小:

  • 商户数量增长会带来缓存容量问题。
  • 租户配置变更后还要处理失效。
  • 序列化器会被绑定到租户,复杂度继续上升。

更简单的方式是:只缓存字段结构,动态配置留在请求上下文里。

10. 回归测试应该怎么补

这类问题不能只测“某一个租户能不能返回正确金额”。

至少要覆盖这些维度。

10.1 顺序隔离

scale=0 -> scale=3 scale=3 -> scale=0

结果必须只由当前请求决定,不能由第一次请求决定。

10.2 上下文隔离

无上下文 -> 商户上下文 非商户上下文 -> 商户上下文 商户 A -> 商户 B 同一商户修改精度前后

尤其要测试“无上下文首次访问”。很多系统里,启动预热、健康检查、异步任务、内部调用都可能触发序列化。

10.3 字段类型

price amount balance quantity 非金额语义的 Amount 字段

如果项目靠字段名推断金额语义,就要非常小心,不要为了修一个字段,把所有Amount都当金额处理。

长期更好的方案是显式标注字段语义,例如:

@AmountSemantic(MONEY)privateAmountserviceFee;@AmountSemantic(QUANTITY)privateAmountweighedQuantity;@AmountSemantic(RAW)privateAmountexchangeRate;

10.4 数值边界

null 0 整数 28.5 43.8900 负数 大金额 尾随零

金额排障里,“数值相等”和“展示形式相同”不是一回事。

例如:

数据库保留 28.500 JSON 输出 28.5

这可能是符合契约的。

但:

数据库 28.500 JSON 输出 28

如果当前租户配置不是 0 位精度,那就是信息损失。

10.5 并发

两个线程共享同一个ObjectMapper

线程 A:scale=0 线程 B:scale=3

同时反复序列化同一个模型字段,结果不得交叉。

这个测试能避免“顺序测试通过,并发下仍有共享状态”的问题。

11. 这次排查可以沉淀成几个原则

原则一:生命周期必须匹配

短生命周期数据不能保存到长生命周期共享对象。

常见危险组合包括:

RequestContext -> Spring 单例字段 ThreadLocal -> static 字段 当前用户 -> 全局缓存值 当前语言 -> 单例 formatter 状态 当前租户 -> Jackson serializer 实例字段

这类问题最麻烦的地方是:单测、单租户、单节点、本地启动都可能正常。

一到多租户、多节点、不同访问顺序,就会变成偶现。

原则二:缓存键必须覆盖所有影响结果的变量

如果输出取决于:

模型类型 + 字段 + 租户精度 + 舍入模式

缓存却只按:

模型类型 + 字段

缓存就是不完整的。

但这不意味着要把所有动态变量都塞进缓存键。

更好的选择是:

缓存稳定结构 动态变量运行期读取

原则三:多租户测试必须改变请求顺序

只测:

A -> A -> A

发现不了上下文污染。

应该测:

A -> B B -> A 无上下文 -> A 非商户上下文 -> A 并发 A + B

顺序变化是发现缓存污染最直接的办法。

原则四:重启能恢复,往往是时序型故障信号

如果一个问题重启后恢复,但过一段时间又出现,要警惕:

本地内存缓存 首次访问顺序 单例对象状态 预热过程 线程池复用

重启不是修复,只是把“第一次污染者”重新洗牌。

原则五:不要被方法名带偏

排查时经常会看到一些名字很像根因的方法,例如:

findByIdCacheable getContextCache loadTenantConfig

方法名只能提示方向,不能替代证据。

这次排查里,请求上下文初始化本身是正确的,错误发生在后续 serializer 把上下文复制进缓存对象。

如果只看名字,很容易把锅甩给“商户缓存”或“ThreadLocal 失效”。

12. 最后总结

这次问题不是 Jackson 不安全,也不是 ThreadLocal 天然有问题。

真正的问题是:

请求级租户配置 -> 被 createContextual 读取 -> 被保存到 serializer 实例字段 -> serializer 被 Jackson 长期缓存 -> 后续请求复用错误配置

排查这类问题,关键不是先找某个神奇参数,而是三步:

先确认数据在哪一步变 再核对对象生命周期 最后用 A -> B / B -> A / 并发实验做证伪

多租户系统里,任何从ThreadLocalSecurityContextLocaleContext、请求头或租户配置中心读取的数据,都要先问一句:

这个值会不会被我放进了一个活得更久的对象里?

如果答案是会,那就已经埋下了上下文污染的种子。

本文为作者原创,首发于掘金,CSDN 为同步发布版本。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/25 21:44:08

2026跨境电商避坑:TikTok Shop巴西站半托管爆发前夜,忽略“本土税务合规”的卖家正在被批量清退

新兴市场机遇背后的合规挑战&#xff1a;本土化运营的深度思考 随着社交电商在全球范围内的持续升温&#xff0c;以短视频内容为驱动的购物模式正在重塑零售格局。其中&#xff0c;南美市场&#xff0c;特别是巴西&#xff0c;因其庞大的人口基数、活跃的社交媒体用户和快速增长…

作者头像 李华
网站建设 2026/8/25 21:43:58

2026年五大免费MBTI测试平台入口与选择指南

先看结论 如果你希望找一个真正能直接免费测、中文语境更顺手、也不用先注册登录的平台&#xff0c;十六型MBTI会是更适合国内用户的第一入口。它的官网是 mbti-pro.cn&#xff0c;主打本土化题库和无广告体验&#xff0c;适合先做一次标准 MBTI 再决定是否深挖。 国内平台里&a…

作者头像 李华
网站建设 2026/8/25 21:42:55

GraphRAG深度认识:当大模型学会“理解关系”

引言&#xff1a;RAG的“天花板”与GraphRAG的破局检索增强生成&#xff08;RAG&#xff09;在过去两年里几乎成了每个AI应用的标配。通过从外部知识库中检索相关信息并注入大语言模型&#xff08;LLM&#xff09;的提示词中&#xff0c;RAG有效缓解了幻觉、知识陈旧和缺乏领域…

作者头像 李华
网站建设 2026/8/25 21:42:19

阿里云ECS服务器从零部署Docker完整指南:半小时搭建安全容器环境

最近在帮团队搭建新的开发测试环境&#xff0c;发现很多同学在从零开始部署 Docker 服务时&#xff0c;总会在云服务器购买、系统配置和 Docker 安装这几个环节卡住。网上的资料要么只讲 Docker 命令&#xff0c;要么只讲云服务器操作&#xff0c;缺少一个从“买服务器”到“跑…

作者头像 李华
网站建设 2026/8/25 21:41:42

2026深度拆解Python之父编程理念:从失败复盘到AI时代语言封神

Python能超越多数老牌编程语言&#xff0c;稳居AI、大数据领域第一梯队&#xff0c;核心并非语法简单&#xff0c;而是创始人范罗苏姆从早期项目失败中沉淀的轻量化、开放化、人性化编程理念&#xff0c;这套贯穿Python全程的设计逻辑&#xff0c;彻底解决了传统编程语言笨重、…

作者头像 李华
网站建设 2026/8/25 21:39:11

Docker部署Nginx全攻略:从入门到反向代理实战

大家好&#xff0c;我是CSDN的一名技术博主。在Web开发和运维的日常工作中&#xff0c;Nginx作为高性能的HTTP和反向代理服务器&#xff0c;其重要性不言而喻。然而&#xff0c;传统的安装配置方式往往涉及复杂的编译、依赖和环境隔离问题。Docker的出现&#xff0c;为我们提供…

作者头像 李华