三年前第一次被业务方逼着做迁移场景到现在还记得。某套Java系统要从一批快退休的服务器搬到新环境我拿着停机方案去对时间窗口业务方直接回了一句“你们最多有五分钟而且不能丢任何一笔订单。”五分钟够什么够一次失败的上线回滚。JVM刚启动到一半Spring上下文还没加载完五分钟就没了就算运气好启动成功连接池、注册中心和堆外缓存全是冷的线上马上出现超时报警。也就是从那天起我开始认真研究另一条路——不重启、不停机直接在Azure上做Java应用热迁移让整个替换过程像手机换电池一样旧电池取下来新电池装上去手机始终没有关机。这篇就是我落地这套方案的完整记录包括路径选型、实例替换、数据层在线切换以及那些模拟环境里永远测不出来的坑。1. 被“重启”绑住的迁移到底卡在哪理解热迁移的底层逻辑1.1 传统迁移为什么必须关机很多人一提到迁移第一反应就是“找个停机窗口把服务停了数据拷过去再启动”。这套流程之所以根深蒂固不是因为大家偷懒而是因为它确实解决了好几个硬问题数据一致性服务不停止业务数据就会继续变化静态拷贝一定产生裂缝。停机后数据库不再接受写请求快照才是稳定的。应用启动时长Java应用启动慢连上数据库、初始化线程池、预热缓存都需要时间。停机迁移把这段时间完整体现在业务损失里。入口切换的不确定服务停了把流量切过去域名解析、负载均衡后端更新、防火墙白名单每一环都可能出问题。问题在于这套逻辑建立在“单机思维”上。它假设一台服务器就是一个不可拆分的整体程序、数据、网络入口全在一起所以动任何一个部分都必须全停。但到了云上这个假设已经不再成立。基础设施变成了可编程的资源池实例、存储、网络入口、负载均衡器全部解耦完全可以分批替换。你缺的只是把“整体停机”翻译成“组件热替换”的方法。1.2 “换电池”式迁移的真相替换不是搬运手机换电池的比喻很贴切但很多人理解反了。换电池时手机并没有把运行中的程序“转移”到新电池里而是直接插上了一块新电池——程序还在原来的手机上跑供电来源被悄悄替换掉了。服务器热迁移的真相也是这样你不需要把旧机器上的进程“搬运”过去而是让新机器把旧机器替换掉替换过程发生在某个看不见的平面里——流量平面之下。落到具体架构上就是把迁移拆成三层来分别处理计算实例层旧的Java进程所在的虚拟机或容器替换为新的。这层是可替换的因为应用已经没有必须“住”在特定机器上的理由。入口流量层域名、负载均衡、网关是最外层它们始终不变。用户访问的地址还是那个地址只是后端节点一批一批换了。数据层数据库、缓存、文件存储不能被“换电池”式替换但可以并行运行——旧库继续写新库同步追追平之后切换。三层都各自解决停机时间就会被压缩到“几乎为零”。严格说瞬间切换数据库写流量时可能还会有毫秒级抖动但只要连接池配置得当业务侧完全可以无感。这里要区分一个容易混淆的概念Azure平台本身有一套虚拟机的“实时迁移”机制宿主机的维护操作会把VM热迁移到另一台健康节点应用进程不会重启。但那个场景是Azure平台自动触发的你不需要主动控制也控制不了迁移时机。当你要主动把Java应用从一套环境迁移到另一套环境时真正能落地的是应用层的滚动替换和数据的在线追平这也是下文的主线。2. 动手之前先盘三笔账实例、入口与数据到底能不能分开搬2.1 实例层你的Java应用是“可替换”的吗第一个要回答的问题不是“怎么迁移”而是“现在的应用配不配被热迁移”。很多Java服务表面上看是部署在VM上的单体进程实际上偷偷在本地内存里存了大量状态。最典型的几类本地Session用户登录状态只存在一个实例的内存里流量一旦切到新实例用户立刻掉登录。本地缓存Caffeine、甚至裸HashMap做热点数据缓存。实例要下线时这些缓存无法带走迁移后新实例要重新预热期间性能下降。本地临时文件上传的图片、导出的Excel、生成的报表全都写进了实例的磁盘。这个实例一旦销毁文件就跟着没了。进程内定时任务定时任务在每台机器上都跑集群大了就会出现重复执行。热迁移的前提就是把这些东西从“本地”挪到“共享”。Session放到Redis缓存也放到Redis文件上传改为直接写Azure Storage定时任务加上分布式锁或干脆通过消息队列触发。这一步不是迁移工作而是迁移前的“拆迁摸底”把墙壁里的电线理清楚才知道哪根可以带电操作。一个很实用的自检清单停掉一台实例看另外几台是否还能正常服务。如果停掉就出问题说明应用有单实例依赖先把这一步改掉再谈热迁移。2.2 入口层流量打进哪个门这扇门最好别换入口层的目标很简单对外地址一成不变后端实例悄悄换新。迁移时最忌讳的是连入口一起换。域名换了DNS生效时间不可控公网IP换了防火墙规则要重新配证书换了客户端会有一堆信任报警。这些跟“换电池”完全是背道而驰。Azure上的常见入口结构大致两类负载均衡器Load Balancer四层转发适合TCP/UDP流量后端直接挂VM或AKS节点。热迁移时后端池里的旧实例移除、新实例加入前端IP不变。应用网关Application Gateway七层转发可以按路径分流、做会话保持、甚至按权重灰度。这类网关更适合做渐进式替换你可以先放10%的流量到新环境观察稳定后逐步放量。无论哪种原则都是先把入口与后端解耦。迁移过程中前端配置的变动越少越好——最好是零变动。2.3 数据层真正的“断电”风险在这里计算实例的替换说到底只是“把旧机器关机”数据层的迁移才是热迁移里最细腻的部分。数据库如果和你一起停机那热迁移就不存在了。所以数据层只能走“并行运行、在线追平”的路线旧库和新库同时工作新库通过复制机制不断追上旧库的增量等两个库的数据差距缩小到可以忽略时再把写流量切过去。可如果只是单机数据库数据一边写入一边变化怎么追都追不平。解决方案是靠数据库自带的复制能力MySQL的主从同步、PostgreSQL的流复制、SQL Server的AlwaysOn可用性组。这些机制天然支持你先建立一个副本然后再逐步靠近主库。除此之外还要考虑文件存储和消息队列。文件不能散落在VM磁盘上要提前迁移到对象存储消息队列如果有积压需要先消费干净或做了消息重放才能放心切换。这三条线库数据、文件数据、消息数据各自独立又互相牵连是热迁移里最消耗精力的部分。3. 实战主线路Java应用在Azure上如何做到实例替换不中断3.1 路径选型为什么我选了容器化AKS滚动更新接触热迁移之后我对比过三条可以落地的路线路线原理适用场景局限Azure虚拟机实时迁移平台在宿主机维护时自动把VM搬到健康节点进程不重启宿主机硬件维护、Azure平台事件迁移时机不可控主动的业务迁移帮不上忙自助模板新VM替换预先制作好镜像/启动脚本启动新VM后手动切换流量简单应用、一次性迁移手动操作环节多替换过程中容易出错容器化AKS滚动更新应用打成容器镜像部署到AKS集群用滚动更新策略逐步替换实例Java应用长期演进、以后要持续做版本升级需要一次性容器化改造有学习成本我最终选了第三条。理由很直接热迁移不应该只是一次性的“搬家”而应该成为常态化的“换电池”能力。容器化之后每次发版都是一次小规模的热迁移滚动更新就成了发布流水线的一部分而不是某个凌晨的大工程。虚拟机那条路不是不能做但它的热迁移能力是从宿主机层面获得的应用自己并不知道。当整个项目要从旧架构迁到新架构时还是要靠应用层的替换能力。3.2 第一步把Java应用容器化关键在于“慢启动”容忍Java应用容器化本身不算难难点在于Kubernetes默认的探针逻辑对Java很不友好。默认情况下Pod启动后立刻执行readinessProbe而一个Spring Boot应用冷启动可能要20到40秒。探针如果配置成“启动后5秒探测一次失败3次就标记为不健康”那么新Pod大概率永远进不了Ready状态滚动更新直接被卡死。我使用的配置是两层探针startupProbe负责容忍慢启动readinessProbe负责流量开关livenessProbe负责内核健康。startupProbe的要点是把窗口放宽让JVM有充足时间热身startupProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 30这个配置的意图是给应用最多150秒的启动窗口。Spring Boot的应用如果启用了Actuator直接探测/actuator/health是最省事的方案因为健康检查端点会反映数据库连接、消息队列连接这些就绪状态。就绪探针要更严格一些它决定流量是否进入这个实例readinessProbe: httpGet: path: /actuator/health port: 8080 periodSeconds: 5 failureThreshold: 3这里的取舍是启动期间允许失败多次一旦Ready之后就不该再连续失败。如果数据库短暂抖动readiness探针会把流量摘除等恢复后再挂回去这就是应用层的自动“断电保护”。3.3 第二步滚动更新参数——maxSurge和maxUnavailable怎么配容器化完成之后热迁移的“换电池”动作靠的是Deployment的滚动更新策略。核心参数就两个maxSurge更新时最多可以多出多少个新实例。maxUnavailable更新时最多允许多少个旧实例不可用。我个人的标准配置是spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0含义拆开来看滚动更新时先额外创建一个新实例等新实例完全Ready再杀掉一个旧实例。因为maxUnavailable是0所以整个过程中可服务的实例数永远不会低于3用户请求不会落到空档期。这很像换电池时的动作先把新电池的触点贴上去确认电量正常再拆下旧电池。手机的供电从未中断只是电源来源变了。实际操作中还会遇到一个细节当新实例数量达到maxSurge上限时更新会停下来等待直到某个旧实例被优雅终止。这时候表面上看“滚动更新的进度不走了”其实是在等JVM安全退出。如果你发现更新卡在Waiting不要急着杀Pod先看旧实例是否在拖时间。3.4 第三步流量切验证——怎么证明迁移真的“无感”热迁移做完不能只看“Pod全换了”还要证明业务真的没断。我的验证套路是三层叠加第一层靠日志。旧实例停止接收新请求、新实例开始接收请求这个交接点应该在日志里体现为连续不断的请求流而不是一个时间断档。查看时区对齐前后的日志如果存在超过一两秒的空白说明连接切换出现了缺口。第二层靠持续压测。迁移过程中用一个固定循环持续向入口发请求观察成功率和时延。不要只发个几十次至少要跑到分钟级。有一个更聪明的做法是把压测请求也写入业务表迁移完成后核对数量这样连数据一致性一起验证了。第三层靠连接池观察。在数据库端或Redis端看连接来源的变化旧实例的连接数逐渐下降新实例的连接数逐渐上升。如果旧实例不断有连接涌入说明流量还在往旧节点送——负载均衡或客户端的连接复用可能还没失效。三层验证都通过才能说这次“换电池”是成功的。4. 数据层在线迁移让“电池更换”时手机不掉电4.1 数据库的在线迁移从主备同步到增量追平数据库在线迁移的过程本质上是一个缩短“新旧差距”的过程。我在Java应用迁移中采用的流程是全量初始化把旧库的结构和数据导入新库。数据量大时要分批处理MySQL可以先用mysqldump导出一个基准快照再在Restore完成后立刻开启增量同步。开启增量同步在旧库上开启binlog或复制槽新库通过数据库自带复制机制持续接收旧库的增量变更。这里的关键是从全量完成的那一秒起旧库上的每一个写操作都必须能同步到新库。追平与校验观察复制延迟逐渐逼近0。当新旧库数据完全追平后对关键表做行数和校验和对比确认没有数据裂缝。切换写流量修改Java应用的数据库连接配置把write请求指向新库。这一步的动作要小、要快同时准备好回滚开关——如果切换后出现异常立刻把连接切回旧库。这个方案能成立的前提是旧库本身要支持在线复制。Azure Database for MySQL、PostgreSQL这类托管服务基本都内置了这些能力物理机自建库则需要自己先搭好复制链路。切换写流量的瞬间最容易出问题的是连接池。Java侧建议把连接池的maximumPoolSize调大一些预留切换瞬间的峰值连接需求。另一个实用技巧是切换前先执行一段预热SQL让新库的缓冲池和查询计划先热起来避免第一个请求把温冷数据全打在磁盘上。4.2 Java会话状态从“本地内存”到“共享缓存”这块工作听起来不高大上但没做好的话“热迁移”会变成“用户集体掉线事故”。JavaWeb应用最传统的Session实现是Session存储在JVM内存里靠容器管理。实例重启或销毁Session就消失了。线上看到的现象是迁移完成后一批用户被踢回登录页购物车清空业务方直接崩溃大哭。标准解法是在应用里集成Spring Session把Session存储接到Azure Cache for Redis上dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency加上依赖后再在配置里指向Redis连接Spring Boot应用甚至不用改一行业务代码Session就自动存进了Redis。这样任何一个实例处理请求时都能从Redis里读到同一个Session状态。如果你担心Redis本身成为新的单点那就开启Redis的高可用副本。热迁移要解决的是“实例替换”问题但顺手也要把“状态共享”问题解决了——不然迁移完等待你的不是欢呼而是雪崩。4.3 文件与对象存储从磁盘搬到BlobJava应用最常见的“隐性状态”就是文件用户上传的图片、导出报表、临时文件。这类数据如果要热迁移必须先从实例本地磁盘迁到一个所有实例都能访问的存储上。我习惯用的是Azure Blob Storage。应用改造后的逻辑是上传文件先写入Blob再把Blob的URL存进数据库下载时直接从Blob获取。迁移时比较稳妥的做法是“双写一段时间”新文件写Blob旧文件先留带本地磁盘通过定时任务把历史文件逐步搬过去。等所有文件都搬完再把应用的读写逻辑完全切到Blob。这个平滑迁移的动作能有效避免“大迁移期间业务文件读写异常”的并发风险。如果你是要把一套包含大量历史文件的老系统迁入热迁移体系建议先评估文件总量。要是只有几百GB一次性并行迁移就行要是以TB计就要设计增量扫描和校验的重传机制。5. 亲测容易翻车的四个时刻与完整排查链路5.1 新实例一直“启动中”滚动更新像卡住了一样第一次做热迁移演练我盯着AKS的Pod列表看了十分钟新Pod一直处于Running但未Ready滚动更新完全没动静。排查链路是这样的先用kubectl describe pod看事件发现探针一直在失败。然后抓探针返回的具体内容发现健康检查路径返回500——因为应用虽然起来了但它在启动时尝试连接一个旧环境才有的内部服务连接失败导致Spring上下文初始化中断。这里的根因不是“探针太严格”而是应用启动依赖外部服务外部服务不可达时应用没有自动降级。这个性质的依赖正是热迁移的天敌。修复有两层首先在配置上给startupProbe足够的容忍度其次在代码里把启动时对非关键依赖的检查改为异步重试不阻塞主流程。健康检查端点只反馈“这个实例能否接收流量”而不要反馈“所有下游都健在”这种全局状态——那是另一套监控系统的职责。5.2 流量切完连接断了一两秒第一次自以为成功的热迁移在线压测时发现一个刺眼的断档旧实例被销毁的瞬间一小批请求超时持续约1到2秒。翻日志发现流量已经全部指向新实例但旧实例还在处理一些存量的HTTP请求。Kubernetes默认给Pod的优雅终止窗口是30秒可Java应用没有明确配合收到SIGTERM信号后当前正在处理的请求会被直接打断而不是等它跑完再退出。解法是两层配合一是Spring Boot开启优雅停机server.shutdowngraceful二是为Pod配置preStop钩子给存量请求留出排空时间lifecycle: preStop: exec: command: [sh, -c, sleep 15]preStop的sleep不是浪费它做的是“等负载均衡器把新连接全部切到新实例同时让旧实例把手上的存量请求处理完”。如果你用的是异步消费或消息处理型应用terminationGracePeriodSeconds还要相应加长。5.3 客户端还在往旧节点发请求连接复用带来的误导还有一次更隐蔽的问题迁移完成后所有Pod都已经是新版本了但监控里仍然显示有一小撮请求打到旧节点的IP上。原因有两层。第一层是Azure负载均衡器的后端池更新不是瞬时的旧节点从后端池移除后已建立的连接会被保留一段时间第二层是客户端侧的连接复用——长连接一旦建立它不会因为你后端换了节点就自动重建。这种问题很难靠服务端单方面彻底消除。缓解手段包括把负载均衡的空闲连接超时调短一些让长连接定期断开重建同时在客户端侧实现幂等重试遇到连接重置时自动重新请求。这个坑提醒我热迁移的“无感”需要整个链路配合客户端也要具备基础的容错能力。5.4 长连接WebSocket/SSE场景下“换电池”的比喻要打个折如果Java应用里有WebSocket长连接或者SSE服务端推送热迁移没法做到无缝。因为长连接持有的是具体的实例连接实例一旦销毁连接必然中断。这不是部署策略能解决的问题而是架构层面的边界条件。我对这类业务的处理方式是如果推送信号重要就设计重连和确认机制——客户端断线后自动重连服务端保留最近的消息列表重连成功后补齐遗漏。给用户的解释也尽量诚实这是一次秒级重连而不是业务中断。认识到热迁移的边界比假装“一切无感”要重要得多。6. 把热迁移练成常规操作三个实战习惯热迁移做了几个项目之后我总结出三个实战习惯每个都来自实打实的教训。一是先做迁移演练再谈正式迁移。演练不只是测步骤更是在发布窗口之外把坑都踩一遍。我的做法是把整条迁移链路容器构建、镜像推送、滚动更新、数据库切换、验证脚本固化成一条流水线每个月跑一次。真正需要迁移时只是按同一个按钮再跑一遍而已。二是为回滚留好后门。滚动更新的回滚很方便kubectl rollout undo就能回到上一个ReplicaSet相当于换下来的旧电池还能原样装回去。数据库的切换必须每一步都记录binlog位点和切换时间点万一新库异常能在秒级把连接切回旧库。热迁移不是赌博先想好怎么输才能放心去赢。三是在迁移前把“替换清单”写细。每次热迁移真正让人失眠的不是Pod本身而是一堆看似无关的依赖项监控告警绑定的IP、日志采集器的配置、定时任务的调度规则、甚至运维脚本里写死的服务器列表。把这些全部列出来逐个核对再复杂的环境也能保持清晰。这份清单我一直在维护每次迁移就把它更新一版成了团队里最值钱的文档之一。