ARTICLE DETAIL

资讯详情

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

Arthas实战:用OGNL不重启修改线上Java对象属性值

Arthas实战:用OGNL不重启修改线上Java对象属性值 某天下午线上订单网关突然出现一批支付按钮置灰的工单。排查下来是状态机回调顺序写错导致内存缓存里的订单实例被改成了CANCELED而数据库里的状态其实还是PAID。数据库是对的页面读的是内存缓存缓存里那个对象的状态字段错了。这个时候你能怎么办重启集群十几个节点要排水、预热二十分钟内用户持续受影响。重新发版窗口在晚上。其实如果你手头有 Arthas一条 OGNL 表达式就能把这个错误字段改回来整个过程不重启、不摘流量、不发布。这篇文章我就围绕arthas 使用 ognl 修改线上动态对象实例的属性值这条主线把原理、实操、坑和边界一次讲透。这类操作属于线上应急里的手感活JVM 调优工具链里JPS、jstat、jmap、jstack、jconsole、visualvm 基本只能看唯独 Arthas 能真正动手改。它通过 Attach 机制挂到目标 JVM 上不需要改动应用代码也不需要重启服务。下面我会先用一个完整的场景带你走通整个过程再逐个拆解背后的原理和容易踩的坑。1. 什么时候需要登上生产改一个活对象而不是重启服务1.1 典型的应急修正场景先说清楚线上对象属性出了问题第一反应不应该是用 Arthas 改而是先判断这个问题能不能靠重启、发版、或者数据修复来自愈。我遇到过真正值得用 OGNL 去改内存对象的场景基本是下面几类数据库和缓存状态不一致且短时间不会自动恢复。比如上面那个订单例子DB 里是PAID内存对象是CANCELED下一次缓存刷新可能要等半小时甚至没有刷新机制。想验证一个假设但不想为了验证专门发一个版本。比如怀疑某个字段影响路由策略直接把内存里字段改一下看行为是否符合预期符合了再组织正式修复。正式修复需要时间先用临时手段止血。代码修好了但测试、评审、发版窗口都还没到在这个窗口期内把内存里的错误值纠正过来能显著降低业务影响面。服务重启成本极高。本地缓存需要长时间预热、内存里有正在跑的任务队列、连接池建立要恢复等等重启一次影响面比改一个字段大得多。1.2 Arthas 在同族诊断工具里的特殊位置很多做 JVM 排查的人手里都有一串工具jps 看进程、jstat 看 GC 和类加载、jmap dump 堆、jstack 看线程栈、jconsole 和 visualvm 做图形化观察。这些工具的核心能力是观测和取证看问题在哪然后把信息带出来分析。它们不太擅长做一件事在 JVM 运行过程中直接修改内存里某个对象的属性值。Arthas 不一样。它不仅能看还能改。ognl命令就是它用来操作运行期对象的一把钥匙。你可以在表达式里导航对象图、调用方法、读写字段甚至可以替换容器里的对象。再加上watch、trace、jad、sc这些命令配合线上排障的效率完全不是一个量级。不过这里必须提醒一句能改不等于可以随便改。修改线上内存对象是介入性操作一定要先明确对象是谁、字段原名是什么、当前值改前是什么、改后要变成什么。下面几节我会把这些准备工作一起讲。2. OGNL 在 Arthas 里到底怎么工作表达式语言和命令参数2.1 OGNL 能做什么为什么是它OGNL 的全称是 Object-Graph Navigation Language对象图导航语言。它最擅长的事情就是沿着对象引用一步步摸进去然后在摸到的位置做读写操作。Java 世界里类似的选择还有 SpEL、MVEL但 Arthas 官方选择的是 OGNL生态成熟、对静态字段和方法的访问写得非常自然。我在实际使用中最常用的 OGNL 语法其实不多一张表就能说清楚语法含义示例obj.field/obj.getField()访问属性或调用 getterorder.statuscom.foo.BarCONST访问静态字段Systemoutcom.foo.Barmethod()调用静态方法SpringUtilsgetBean(orderService)list[0]、map[key]索引访问orderCache.get(ORD_001)expr1, expr2, expr3逗号表达式整体结果是最后一段的值#svc xxx, #svc.doSth()expr value赋值order.status PAID#var expr给表达式内的临时变量赋值#order map.get(k)OGNL 的赋值是引用级的。也就是说order.status PAID这句话是把你内存中这个order实例的status字段从一个 String 引用改成另一个 String 引用。所有后续读取这个对象字段的代码看到的值都会变。这个机制和替换整个对象在容器里的引用是完全不同的两件事后面我会专门讲这个区别。2.2 Arthas ognl 命令的三个关键参数Arthas 的ognl命令常用参数有三个看起来简单每一个都对应一类坑。-c classLoaderHash指定表达式里出现的类由哪个类加载器加载。不指定时Arthas 默认用系统的 ClassLoader大概率找不到你的业务类。--classLoaderClass 类名按类加载器的类名指定比 hash 可读性更好但要求该类加载器在 JVM 里有唯一实例。-x 展开层数控制结果对象输出的深度。不指定时对象嵌套打印会非常简略看到一个Order3f2a55根本不知道里面字段是什么。关于-x有个经验不知道对象深度时先用-x 2看一层不够再加。展开太大时输出会刷屏而且如果对象图里有环形引用输出会很长。Arthas 在打印对象图时会做循环引用保护但过大的展开依然会影响终端可读性。我在生产上一般从 2 开始最多到 4。另外一个基础概念-c后面跟的 hash 不是类的 hash是类加载器的 hash。sc -d输出里的classLoaderHash字段才是我们要的值。3. 从拿到类到拿到实例的两条主线OGNL 表达式的本质是从已知对象出发沿路径导航。所以最大的问题不是怎么写赋值而是第一个对象从哪来。我总结了两种最稳定的获取路径分别对应不同的对象形态。3.1 静态入口拿单例对象的最稳定姿势绝大多数 Spring 项目里业务对象是一个单例 Bean。只要项目里有能够拿到 Spring 容器的静态工具类比如SpringUtils、ApplicationContextHelper问题就解决一大半。如果没有这个工具类常见项目里也经常有人把 ApplicationContext 存到静态字段里你可以先看jad反编译确认。拿到 Bean 之后后面怎么走取决于对象在 Bean 内部是怎么存的。比如订单服务内部有个orderCacheMap就可以这样导航ognl -c hash -x 2 #svc com.demo.spring.SpringUtilsgetBean(orderServiceImpl), #svc.orderCache.get(ORD_20240115001).status这里com.demo.spring.SpringUtilsgetBean(...)是 OGNL 访问静态方法的写法。先调出 Bean赋值给临时变量#svc后面继续用#svc导航读取它的私有字段orderCache。提示OGNL 可以直接访问私有字段在 Arthas 里这个能力默认是可用的。但生产上能走 getter/setter 就优先走 getter/setter尽量不依赖对私有字段的直接操作。不同版本对 private 字段访问的限制不完全一致碰到IllegalAccessException就换个公开方法路径。3.2 运行期上下文线程里摸出当前请求对象有些对象不是单例而是挂在请求链路上比如ThreadLocal里的当前登录用户、请求上下文里挂的临时状态。这种对象的获取更贴近运行现场典型的表达式长这样ognl -c hash -x 3 #user com.demo.auth.UserContextHOLDER.get(), #user.getCart().getItemList().size()如果项目里用的是RequestContextHolder之类的 Spring 上下文也可以从里面摸出当前请求和 Request 级属性。但我要提醒ThreadLocal 对象只有在实际请求线程里才有值你在 Arthas 终端里执行 ognl 时执行线程并不是业务线程所以如果是请求线程的数据往往会拿到 null。这时候不能死磕 OGNL要配合watch在业务线程执行时去抓。watch是一个在线改方法入参对象的变通手段。比如有一个方法OrderService.updateOrder(Order order)你确定这个方法会被业务请求触发可以在它执行时把入参对象的状态改掉watch com.demo.order.OrderService updateOrder params[0].setStatus(PAID) -x 2 -n 1-n 1表示命中一次后就停止避免每次业务调用都触发修改。这个方式比直接 OGNL 更贴近现场适合对象没挂在静态容器上的场景。3.3 字段名和类名不要靠猜这是个说起来丢人但非常容易犯的错误。很多人在写 OGNL 表达式之前不确认字段真实名字结果因为 Java 字段命名习惯的差异orderCache还是orderCacheMap、status还是orderStatus表达式报错或者改了没效果。正确做法是先反编译sc -d com.demo.order.OrderService jad com.demo.order.OrderServiceImplsc -d拿到类加载器 hash 和源码位置jad反编译出真实字段名。类名也尽量用实现类因为接口里看不到字段实现OGNL 导航按接口对象的运行时实际类型走但也别在并不确定时依赖接口直接拿实现类来做-c和表达式最稳。4. 实操记录修改线上订单缓存对象状态值的完整过程这一节我写一个我实际处理过、也最适合演示的场景。目标很简单一个订单服务把订单实例缓存在内存 Map 里由于状态机回调顺序错误某个订单被改成了CANCELED数据库是正确的PAID。我们要在不重启、不发版的情况下把这个内存对象的状态改回PAID。4.1 第一步attach 到目标 JVM确认目标类和加载器先在服务器上启动 Arthasjava -jar arthas-boot.jar 目标进程pid进入 Arthas 交互终端后用sc找到订单服务实现类的信息sc -d com.demo.order.service.OrderServiceImpl注意看输出里的classLoaderHash比如是1be6f5c3。这一步非常重要写 OGNL 表达式时-c参数就靠它。如果输出的 hash 有多个说明同一个类被多个 ClassLoader 加载你需要确认业务线程用的是哪个。这个话题我会在踩坑部分展开。4.2 第二步先读当前值留底任何修改操作动手前一定要先读原值。这一步既是确认问题也是给自己留一条改回去的后路。假设项目里有一个静态工具类SpringUtils订单 Bean 的名字是orderServiceImpl订单缓存在实现类里叫orderCache订单对象里状态字段叫status那么读取表达式可以这样写ognl -c 1be6f5c3 -x 2 #svc com.demo.spring.SpringUtilsgetBean(orderServiceImpl), #svc.orderCache.get(ORD_20240115001).status执行结果String[CANCELED]拿到CANCELED确认和工单表象一致。如果项目里没有 SpringUtils表达式可以尝试从 Spring 的 WebApplicationContext 拿ognl -c 1be6f5c3 -x 2 org.springframework.web.context.support.WebApplicationContextUtilsgetWebApplicationContext(null).getBean(orderServiceImpl)但这个表达式对容器类型有依赖不一定每次都能成。最好还是先确认有没有现成的静态入口没有就考虑用第三个方式也就是watch抓现场。4.3 第三步写 OGNL 表达式修改字段确认原值无误后执行修改。这里我建议优先调用setStatus方法来改而不是直接给status赋值ognl -c 1be6f5c3 -x 2 #svc com.demo.spring.SpringUtilsgetBean(orderServiceImpl), #svc.orderCache.get(ORD_20240115001).setStatus(PAID), #svc.orderCache.get(ORD_20240115001).status表达式最后一段是status字段的读取逗号表达式的最终返回值是最后一段的结果。这样命令输出会直接告诉我们改完以后的值String[PAID]如果你确认该对象没有对应的 setter字段也不是 final那也可以直接赋值ognl -c 1be6f5c3 -x 2 #svc com.demo.spring.SpringUtilsgetBean(orderServiceImpl), #svc.orderCache.get(ORD_20240115001).status PAID赋值表达式的返回值就是被赋进去的值所以输出同样能看到PAID。4.4 第四步回读验证与观察业务行为改完以后不要只看命令输出就觉得自己完成了。我习惯做两次验证。第一次是再次读取字段ognl -c 1be6f5c3 -x 2 #svc com.demo.spring.SpringUtilsgetBean(orderServiceImpl), #svc.orderCache.get(ORD_20240115001).status确认还是PAID。第二次是观察业务方法的返回是否符合预期。用watch看getOrder方法返回给调用方的对象状态watch com.demo.order.service.OrderServiceImpl getOrder returnObj returnObj.status PAID -x 2 -n 3然后让测试或真实用户触发一次查询看到watch命中并且结果为true说明下游拿到的对象确实已经是正确状态。4.5 记录操作信息给自己留后路生产环境做任何介入性操作都要有记录。我在操作完成后会在本地日志文件里记一行时间2025-xx-xx 14:32:10 实例10.10.x.x / pid 12345 目标类com.demo.order.service.OrderServiceImpl 操作前ORD_20240115001.statusCANCELED 操作后ORD_20240115001.statusPAID 表达式ognl -c 1be6f5c3 ...如果后续需要恢复照着原值再执行一次逆向赋值就行。这个习惯能帮你避免很多尴尬场面。5. 改线上对象时最容易踩的几个坑5.1 类加载器选错表达式永远 ClassNotFoundException这是最常见、也最让人抓狂的一类问题。ognl表达式里出现的com.demo.spring.SpringUtilsgetBean(...)需要被目标类加载器正确加载。如果你直接运行ognl -x 2 #svc com.demo.spring.SpringUtilsgetBean(orderServiceImpl)往往得到java.lang.ClassNotFoundException。原因就是没有用-c指定类加载器Arthas 默认的加载器看不到 Spring 容器里的业务类。解决思路是先sc -d拿到业务实现类的classLoaderHash或者用classloader命令把 JVM 里所有类加载器打出来找到业务应用自己的那个。如果项目里类加载器特别多推荐用--classLoaderClass按类名指定比如ognl --classLoaderClass org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader -x 2 #svc ...要注意的一点是-c和--classLoaderClass指向的加载器要和你 Java 代码里实际用到的加载器一致。Spring Boot 的嵌套 jar 场景下应用类通常由TomcatEmbeddedWebappClassLoader或其子类加载不是系统 AppClassLoader。选错了表达式里所有类都解析不到。5.2 String.equals 能过但 不过别忽略字符串引用语义改 String 字段有个很隐蔽的问题。Java 里的 String 有两种来源字面量和new出来的运行时对象。OGNL 表达式中写PAID是一个新的 String 实例。你把status字段引用指向这个新实例后如果业务代码其他地方用比较字符串结果可能和预期不一致。比如业务代码里这么写if (order.getStatus() PAID) { ... }正常情况下这个判断可能恰好因为字面量常量池而成立但当你通过 OGNL 把status指向一个运行时新建的PAID字符串时这个判断就变成 false 了。更稳妥的做法是尽量调用业务代码自己的 setter让字段通过业务统一路径变更不要绕过逻辑直接赋值。如果你只能直接赋值改完以后一定要用真实业务路径验证而不是只看字段值。5.3 Shell 转义和表达式引号冲突写 OGNL 表达式时最常见的语法事故就是 Shell 和表达式在引号上打架。正常情况下 bash 里用单引号包整个表达式最安全但 OGNL 的字符串字面量也需要单引号这就冲突了。我推荐的写法是外部用双引号内部 OGNL 字符串用单引号。比如ognl -c 1be6f5c3 -x 2 #svc com.demo.spring.SpringUtilsgetBean(orderServiceImpl), #svc.orderCache.get(ORD_20240115001).status如果表达式里有$、反引号这类 bash 会解析的字符记得加反斜杠转义。如果你实在不想处理转义还可以把表达式写进一个文件用脚本读出来执行但生产环境交互式操作下双引号包裹是效率最高的方案。5.4 改了又弹回去说明还有别的代码在定时刷新OGNL 改的是 JVM 堆里一个对象引用。如果改完以后过几分钟再看字段又变回老值了说明这个字段的写入点不止一处可能有定时任务、消息消费回调、分布式缓存预加载在持续刷新它。这个时候 OGNL 只能帮你缓一阵不能解决根因。遇到这种情况我会用trace或stack命令定位到底是谁在写这个字段。先找到所有调用setStatus或类似方法的地方stack com.demo.order.service.OrderServiceImpl setStatus然后观察是哪个线程路径触发的。定位到源头以后再评估是不是要发版修复、修正配置或者调整消息顺序。不要陷入改了又改的死循环。5.5 替换 Map 里的引用不等于修改线程手里的对象这是我在实际排障中印象很深的一个误区。有些同学遇到缓存对象不对不是改原对象字段而是用 OGNL 往 Map 里 put 一个新对象ognl -c 1be6f5c3 -x 2 #svc.orderCache.put(ORD_20240115001, newOrder)这个语法在 OGNL 里能执行但通常没用。因为业务线程很可能早就通过orderCache.get(...)拿到旧对象引用攥在自己线程栈上继续使用。你替换的是 Map 里的引用别人手里的引用还是原来那个对象。所以只要可能优先修改原对象实例上的字段或调用原对象实例上的方法而不是替换整个对象引用。这也是文章标题里动态对象实例的深层含义——我们改的是实例本身是正在被多方引用的那个东西。5.6 多实例服务的一致性现在很少有单机单实例的部署。OGNL 只能改你 attach 的那一个 JVM 里的对象。线上十个节点如果你只改了一个流量一旦路由到其他节点问题依然存在。我的处理原则是如果对象是 per-instance 本地缓存需要逐台操作或者接受部分机器先恢复正常的过渡态。如果对象依赖集中式存储Redis、DB那本地缓存本身就该有失效机制OGNL 只是应急兜底。操作前先确认流量入口和实例列表避免改漏。6. 生产环境使用 Arthas 操作线上实例的边界与习惯6.1 什么样的字段适合直接改什么情况要叫停虽然 OGNL 能改很多东西但不是所有字段都该改。我给自己定过一条判断清单适合改的字段非 final 的普通业务状态字段比如状态机里的 status、路由策略里的开关位。有明确 setter 且 setter 没有复杂副作用调用 setter 比直接赋值安全。改错了可以恢复原值已经读出来留在终端记录里逆向赋回去就能还原。不适合改的字段final 字段即使 OGNL 反射设置成功JIT 编译后某些路径可能仍然读取优化后的常量行为不可靠。参与资金计算、对账、风控判定的核心字段只改内存不持久化一旦服务重启或流量切走问题立刻回来。这种必须走数据订正流程。频繁被写入的并发热点字段你改完一个瞬间另一个线程可能已经基于旧值做了判断事务性无法保证。定时任务会自动恢复的字段前面说过改了也白改。另外一个原则是OGNL 修改只能作为临时止血手段。改完内存之后问题根因还在代码里。好的做法是借着这次操作争取到的时间窗口立刻安排正式修复而不是把临时手段当长期方案使用。6.2 操作纪律和安全收尾生产环境用 Arthas我建议至少做到这几点先读原值再改现值。原值要留在可审计的记录里不要只留在终端滚屏里。尽量在低峰期操作。OGNL 运行时会有短暂开销表达式越复杂开销越大不要在核心链路高峰时段做复杂导航。操作完及时退出。Arthas 挂到 JVM 上本身是一种开销而且有安全风险。用完执行stop或shutdown退出不要让诊断进程一直挂着。注意端口权限。Arthas 默认会开放 telnet 和 http 端口生产环境要确认这些端口不对公网开放。操作记录留痕。把操作时间、实例、命令、原值、现值记到工单或者日志里哪怕只有一行也能避免后续背锅和排查混乱。6.3 我的一点个人体会Arthas 的 OGNL 好用但我一直把它当成排障工具箱里的最后一张牌。比重启快比发版轻但它有一个天然局限它只影响 JVM 堆内存里的对象不解决数据持久化不解决代码缺陷也不解决架构设计的问题。我见过不少人在生产环境频繁用 OGNL 改来改去最后反而掩盖了真正需要重构的问题。所以每次我改完一个线上对象都会在回顾排障记录时问自己同一个问题为什么这个字段会被写成错的值如果答案是状态机顺序、缓存策略、并发冲突这些结构性原因那么接下来的第一优先级就不再是再写一条 ognl 表达式而是去推动代码层面的修复和回归测试。OGNL 是止血的绷带不能当治疗药物用。最后分享一个小经验日常可以花十几分钟在测试环境刻意练习一下sc -dognl组合甚至故意把类加载器参数写错一次体验一下 ClassNotFoundException 的具体报错长什么样。很多坑第一次踩是事故第二次踩就是经验了。
返回列表