尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

别把 ThreadLocal 写进 Serializer:一次 SaaS 多租户 Jackson 上下文污染排查 别把 ThreadLocal 写进 Serializer一次 SaaS 多租户 Jackson 上下文污染排查摘要这次问题的表象很像前端金额计算错误页面看到的订单金额70 数据库里的订单金额70.89 支付校验报错页面提交金额与订单实际金额不一致继续查下去会发现数据库价格没有错后端订单计算也没有错真正丢掉0.89的地方发生在 HTTP 响应 JSON 序列化阶段。根因是一个公共 JacksonContextualSerializer在createContextual()阶段读取了当前请求里的租户金额精度并把这个请求级配置保存到了 serializer 实例字段里。Jackson 又会缓存上下文化后的属性序列化器于是某个租户第一次触发某个 DTO 字段序列化时使用的精度可能被后续租户继续复用。一句话概括ThreadLocal 本来隔离了请求但代码把 ThreadLocal 的值复制进了 JVM 级缓存对象。这不是典型的“商户 A 查到了商户 B 的数据”而是另一类多租户问题租户配置污染。1. 问题现象页面金额和订单金额对不上某个前台交易场景里支付时连续出现金额不一致expected total 70.89 current total 70页面上看到的商品价格可以抽象成商品 A27 商品 B43 页面合计70但数据库里的订单明细是商品 A27.0000 商品 B43.8900 订单总额70.8900第一反应通常会怀疑几类问题前端是不是把金额转成整数了后端是不是重新算订单时丢了小数数据库字段是不是 scale 不够某个价格等级、促销价、手工改价是不是覆盖了原价这些方向都要查但不能先入为主。这类问题最稳的排查方式是把金额沿链路切开数据库原始值 - Service 内存值 - 响应 Model 值 - JSON 响应值 - 前端展示值 - 下单与支付校验值最终定位到的变化点是响应 Model 中仍然是 43.89 JSON 返回给前端时变成了 43也就是说问题不在数据库、不在订单计算也不在前端传参而是在 Jackson 写 JSON 的阶段发生了精度截断。2. 先分清数据穿透和配置污染不是一回事多租户系统里一听到“串租户”很多人会马上想到数据隔离漏洞。比如当前请求 merchantId B SQL 却查到了 merchantId A 的商品这当然是严重问题属于数据租户穿透。但这次不是这种情况。真实发生的是商品数据当前租户自己的 43.89 金额精度错误沿用了另一个租户的 0 位精度 最终 JSON43也就是说当前租户没有看到其他租户的商品数据但当前租户的金额展示规则被别的租户污染了。这同样是多租户隔离问题只是泄漏对象不是业务数据而是租户配置。在 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){RequestContextcontextRequestContextHolder.current();IntegermoneyScalecontext.getMoneyScale();IntegerroundingModecontext.getRoundingMode();returnnewAmountSerializer(property.getName(),moneyScale,roundingMode);}然后在真正序列化时publicvoidserialize(Amountvalue,JsonGeneratorgen,SerializerProviderprovider){Amountoutputvalue.setScale(this.moneyScale,this.roundingMode);gen.writeString(output.toPlainString());}这段代码最大的问题不是ThreadLocal也不是ObjectMapper而是生命周期错配。它把请求级 moneyScale保存进了Jackson 缓存的 serializer 实例字段于是就变成请求 Ascale 0 ↓ 首次触发某个 DTO 字段上下文化 ↓ 该字段 serializer 保存 scale 0 ↓ 请求 Bscale 3 ↓ 继续复用上一次缓存的 serializer ↓ 43.89 仍按 scale 0 输出为 43示意图如下6. 为什么不是所有接口同时出问题这个问题很容易让人困惑为什么菜单接口错了另一个详情接口又是对的 为什么同一个商品在 A 页面丢小数在 B 页面又正常原因是 Jackson 的上下文化 serializer 通常挂在“响应模型 属性”上。可以理解成MenuItemDTO.sellingPrice - 一个属性 serializer SkuDTO.sellingPrice - 另一个属性 serializer OrderItemDTO.unitPrice - 又一个属性 serializer它们虽然底层类型都是AmountJSON 字段也都像金额但在 Jackson 缓存里不是同一个属性 writer。因此可能出现菜单模型 sellingPrice 第一次由 scale0 的租户触发 - 后续一直输出整数 SKU 模型 sellingPrice 第一次由 scale3 的租户触发 - 后续保留三位 订单模型 unitPrice 第一次由 scale2 的租户触发 - 后续保留两位这也解释了为什么问题在多节点环境里更飘。每个 JVM 都有自己的 Jackson 缓存节点 A某字段首次由 0 位精度租户触发 节点 B同一字段首次由 3 位精度租户触发 节点 C同一字段首次在无租户上下文下触发于是相同接口在不同节点上可能表现不同。重启看似能恢复是因为重启清掉了 JVM 内存缓存。但重启后哪个租户先访问又会重新决定缓存状态。重启能缓解不代表根因消失。7. 怎么证明是首次上下文化污染单租户测试很难发现这个问题。因为只用一个租户测无论 serializer 是否错误缓存请求精度结果都可能一致。真正有价值的是顺序实验。使用同一个ObjectMapper、同一个响应模型、同一个金额字段构造两个租户上下文租户 Ascale 0 租户 Bscale 3实验一A - B如果结果是A 输出 43 B 也输出 43说明 B 没有使用自己的 3 位精度。实验二B - A如果结果是B 输出 43.89 A 也输出 43.89说明结果由第一次触发上下文化的租户决定。再做一个跨模型实验MenuItemDTO.sellingPrice 首次 scale0 - 输出 43 SkuDTO.sellingPrice 首次 scale3 - 输出 43.89 MenuItemDTO.sellingPrice 再次 scale3 - 仍输出 43这样基本可以证明三件事问题不是数据库精度。问题不是前端展示。同一个模型属性的 serializer 被首次请求污染并被后续请求复用。8. 修复目标缓存结构不缓存租户正确修复不是关闭 Jackson 缓存。Jackson 缓存本身是正常优化问题是缓存对象里放了动态请求状态。修复目标应该是serializer 可以被缓存 serializer 只能缓存稳定字段语义 租户精度必须在每次 serialize 时从当前请求读取也就是publicJsonSerializer?createContextual(SerializerProviderprovider,BeanPropertyproperty){returnnewAmountSerializer(property.getName());}真正写 JSON 时再读取当前请求publicvoidserialize(Amountvalue,JsonGeneratorgen,SerializerProviderprovider){RequestContextcontextRequestContextHolder.current();Integerscalecontext.getMoneyScale();IntegerroundingModecontext.getRoundingMode();Amountoutputvalue.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 顺序隔离scale0 - scale3 scale3 - scale0结果必须只由当前请求决定不能由第一次请求决定。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线程 Ascale0 线程 Bscale3同时反复序列化同一个模型字段结果不得交叉。这个测试能避免“顺序测试通过并发下仍有共享状态”的问题。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 / 并发实验做证伪多租户系统里任何从ThreadLocal、SecurityContext、LocaleContext、请求头或租户配置中心读取的数据都要先问一句这个值会不会被我放进了一个活得更久的对象里如果答案是会那就已经埋下了上下文污染的种子。本文为作者原创首发于掘金CSDN 为同步发布版本。
返回列表