Java单体应用集群化改造:从Session共享到分布式锁的核心实践
如果你写过几年Java一定遇到过这样一个场景某个服务单机跑得好好的突然某天线上出问题CPU飙高、接口超时、凌晨的定时任务越堆越多架构师扫了一眼监控说加机器吧。于是你开始接触集群这个词。但集群到底是什么是把代码复制到几台服务器上就行了吗Session怎么办定时任务会不会重复跑分布式锁怎么搞这些问题很多Java开发者在第一次接触集群时都一头雾水甚至踩了不少坑才慢慢摸清门道。这篇文章不打算堆概念而是从Java开发者的视角把集群的核心概念、组成层次、常用方案和实战中容易踩的坑讲透帮你从听过这个词到真正理解集群是怎么一回事。1. 为什么Java单体应用跑着跑着就需要集群了1.1 单机瓶颈的三个真实信号我在不少项目里都见过类似的过程一开始一个Spring Boot应用部署在一台4核8G的服务器上数据库单独一台Redis有就用没有就靠本地缓存。这个阶段能撑几百个并发日活几万完全没问题。但业务量涨起来之后最先出现的是这三个信号。第一个信号是CPU和内存的周期性尖峰。尤其在每天早上或者整点这样的业务高峰接口响应时间从几十毫秒飙到几秒监控图上出现明显的毛刺。这时候大部分问题的根因是单台机器的资源已经到顶了你再怎么优化代码、调JVM参数物理上限就摆在那里。第二个信号是发布和重启越来越痛苦。单机应用意味着每一次发版都要停服或者接受一段几十秒到几分钟的不可用窗口。业务不忙的时候还好一旦用户量上来这几十秒的抖动就会被放大成大量请求失败和用户投诉。第三个信号是故障半径太大。数据库一抖、磁盘一满、云厂商宿主机故障整个系统直接瘫掉。单机模式下宕机不是概率问题而是时间问题并且每一次宕机都是全站故障。这三个信号出现任何一个其实都在告诉你单机架构的扩展性和可用性已经见底了接下来要做的不是继续抠单机的性能而是引入集群。1.2 集群到底解决了什么不只有性能很多人以为集群就是为了扛并发实际上它解决的是两个层面的问题。第一是水平扩展一台机器不够就两台两台不够就四台通过增加节点数量来提升整体处理能力而不是靠换更强的单机。第二是高可用任何一个节点挂掉其他节点能把流量接过来对用户来说服务不中断或只中断很短时间。这两个目标本质上对应了集群的两个核心能力负载均衡和故障转移。负载均衡保证每个节点的压力差不多避免一台忙死一台闲死故障转移保证某个节点失联后流量能被重新分配到健康节点上而不是全部打到那台已经挂掉的机器上。从Java开发者的角度看集群还带来一个隐含要求你的应用代码必须能在多台机器上同时跑且行为一致。这就牵扯出无状态设计、分布式锁、会话共享等一系列问题。很多人觉得集群难其实难的不是把应用部署到多台机器而是让应用在多机并行的语义下依然正确工作。2. 集群的核心构成从流量入口到数据层的每一层2.1 接入层负载均衡器选型与分发策略一个典型的Java集群从用户请求进来开始要经过好几层。最外层是负载均衡器它的职责只有一个把请求分发到后端的多个应用节点上。负载均衡分两种一种工作在四层主要看IP和端口性能高比如LVS这种另一种工作在七层能解析HTTP协议根据URL、Header、Cookie做更细粒度的分发最常见的代表就是Nginx。Java项目里大部分场景用的都是七层负载均衡因为它能做到同一个用户的请求尽量分到同一台机器这种会话保持能力也能针对不同的URI路径做不同的转发策略。选型上没什么绝对标准就我接触的项目来说流量不大、团队运维能力一般直接用Nginx就够了配置简单文档多出了问题容易排查如果到了需要自动伸缩、动态感知后端节点的阶段可以考虑接入注册中心配合负载均衡器动态更新节点列表而不是每次扩缩容都手动改Nginx配置。分发策略值得多说一句。常见的有轮询、加权轮询、最少连接数、IP_Hash等。加权轮询适合后端节点性能不均的场景新机器配置高就多分点流量IP_Hash能保证同一IP的请求固定到同一节点但节点增减时会导致大量重映射所以一般不建议在节点频繁变动的集群里硬用IP_Hash来做会话保持。更现代的做法是让应用层无状态负载均衡层只用最简单的轮询。2.2 应用层无状态化改造是Java集群的前提应用节点是整个集群里最核心也最需要改造的一层。很多Java单体应用默认是有状态的最典型的表现就是Session存本地、缓存存本地、文件也存本地。一旦部署到多台机器问题立刻暴露。无状态化改造说白了就一句话节点之间不保存任何只有自己能读到的运行时数据。用户的登录状态放到Redis里或者改用JWT这类自带状态的令牌缓存全部走Redis或分布式缓存上传的文件放到对象存储或统一的文件服务里临时数据可以放本地但不能当作唯一数据源。这样做的好处是任何一台节点挂掉其他节点可以无缝接管它的流量因为所有需要共享的数据都不在节点本地。改造的过程不复杂但需要很强的纪律性。我在代码评审里见过很多次顺手把数据存到了本地Map里的情况单机模式完全没问题集群模式下这行代码就是一颗定时炸弹。集群并不是一种部署方式而是一种对代码质量的强制要求。2.3 数据层缓存、数据库与最终一致性集群的范围不只是应用节点数据层同样要做出相应设计。Java服务端最常用的数据层组合是Redis加关系型数据库在集群模式下这两者的组织和单机时代完全不同。Redis这边单机Redis最多提供几十G的内存读写能力也有限。集群模式下一般会引入Redis Cluster或者主从加哨兵架构。区别在于主从加哨兵解决的是高可用——主挂了自动切换从节点Redis Cluster解决的是容量和吞吐——数据按槽位分片到多个节点上每个节点只存一部分整体容量可以水平扩展。实际项目里如果数据量不大主从加哨兵已经完全够用只有数据量到达几十G甚至上百G或者单机读写已经明显成为瓶颈时才需要考虑Cluster分片。数据库这边最常用的扩展路径是先读写分离再分库分表。但我要泼一盆冷水读写分离解决的是读压力分库分表解决的是单表数据量过大它们都不是解决写并发问题的银弹。更合理的思路是先尽量削峰把热点写请求通过异步队列削平把读请求通过缓存挡住让数据库只承受它该承受的流量。Java生态里做这个事有很成熟的中件间比如各种消息队列和分库分表组件但设计原则是一致的不要让请求直接打到单库单表上也不要指望一个中间件能解决所有数据问题。3. Java生态里实现集群最常用的几组方案3.1 基于Spring Cloud或Dubbo的服务集群Java后端做集群绕不开服务化框架。当前主流的两条路线一条是Spring Cloud全家桶一条是Dubbo。两条路线对集群的支持方式不太一样但核心思路都是多个服务实例启动后把自己注册到一个注册中心调用方通过注册中心拿到可用实例列表再通过客户端负载均衡选择一个实例发起调用。Spring Cloud这条线上常见的做法是服务用Spring Cloud服务注册与发现组件完成实例注册和心跳上报调用方用OpenFeign或RestTemplate配合负载均衡组件实现服务间的调用。这套体系的好处是跟Spring Boot集成得特别好开发者几乎不用写额外代码声明几个注解就能让服务具备集群能力。Dubbo走的是另外一套思路它本身就是一个高性能的RPC框架服务注册、负载均衡、容灾降级这些能力都内置在框架里更强调服务治理。对于偏内部、调用关系复杂的系统Dubbo的路由和治理规则更灵活压测表现也更稳。选哪条路线主要看团队技术栈和项目阶段但有一点是通用的别等到集群规模很大了再引入服务框架那时候改造的代价极高。3.2 注册中心与心跳集群成员如何互相感知注册中心是集群的通讯录。每个Java服务实例启动后把自己IP、端口、服务名注册上去然后定时发送心跳。注册中心负责维护这个通讯录一旦发现某个实例心跳超时就把它踢掉让调用方不再把流量发过去。我把注册中心和负载均衡的关系说清楚负载均衡解决请求分给谁注册中心解决谁还活着。没有注册中心的时候Nginx只能靠静态的upstream配置来转发节点挂了它感知不到请求照样打过去造成大量失败。有了注册中心负载均衡器或客户端负载均衡组件能动态获取可用节点列表节点挂了自动摘除节点新增自动加入整个过程不需要人工干预。这里有一个Java开发者常忽略的点服务注册是异步的服务发现也有延迟。一个实例刚启动可能还没来得及注册完成就开始接流量一个实例宕机注册中心意识到它挂了也需要好几个心跳周期。所以在做优雅上下线和快速故障恢复时不能理想化地认为注册中心是实时同步的需要在代码里容忍一定的调用失败并做重试。3.3 消息队列在集群中的解耦作用很多Java系统做集群是先从消息队列开始的。原因很简单集群化之前系统内部是同步调用链一个请求要经过A服务、B服务、C服务任何一环慢整个请求就慢。集群化之后这个问题的本质没有变但出故障的节点数量多了很多任何一个节点抖动都可能拖垮整条链路。消息队列解决的是这个问题把同步调用改成异步事件。订单服务创建订单后发一条消息到队列里库存服务监听这个消息去扣库存积分服务监听同一个消息去加积分。下游服务挂了消息先在队列里攒着等它恢复后再消费不会影响主流程。从集群视角看消息队列还天然起到削峰填谷的作用。秒杀这类瞬时高并发的场景如果所有请求直接打到数据库集群上再大的集群也扛不住但如果请求先经过队列排队后端服务按自己的消费能力慢慢处理后端集群的压力就是可控的。代价是实时性变差、链路变复杂、排查问题更费劲。所以我的经验是能同步完成的简单操作尽量同步只有那些耗时、可异步、能容忍延迟的操作才值得引入消息队列。集群不是要把所有东西都拆成消息而是把必须耦合的东西尽量解耦。4. 集群踩坑实录从单机变集群后最容易炸的四个点4.1 会话问题Session共享怎么处理我亲眼见过一个系统单体转集群后第二天线上就出了事故用户登录成功后下一页又变成未登录反复掉线运维一脸懵。根因就是Session存在了每个Tomcat节点的本地内存里用户第一次请求打到A节点第二次请求被负载均衡分到B节点B节点上根本找不到对应的Session。处理这个问题有三种主流方案。第一种是把Session存到Redis里用Spring Session框架几行配置就能搞定这也是我最推荐的方式简单、可靠、可扩展。第二种是用Token方案服务端不存Session用户登录后签发一个带签名和过期时间的JWT每次请求带上这个Token服务端验签即可。这种方式天然支持集群但Token一旦签发很难主动失效在需要强制踢人的业务场景里不太方便。第三种是粘滞会话让负载均衡器把同一用户的请求始终分到同一台节点但这会导致节点负载不均而且节点挂了之后会话照样丢失。如果你在项目里做技术选型我的建议很直接新项目优先Token方案老项目改造优先Spring Session Redis。别在粘滞会话上花太多精力它只是掩盖问题不是解决问题。4.2 分布式锁synchronized在集群里失效单机时代JVM里一个synchronized关键字就能保证同一个时间只有一个线程执行某段代码。但集群环境里A节点和B节点是两个完全独立的JVMA节点的锁对B节点没有任何约束。这时候如果两个节点同时执行扣库存、发券、转账这类操作就会出现超卖、重复发放等问题。集群环境下的锁需要借助外部组件来实现业内常用的有三种基于Redis的分布式锁底层是SETNX加上过期时间配合Lua脚本保证原子性基于Zookeeper的分布式锁利用临时顺序节点实现锁的公平性和自动释放基于数据库的分布式锁靠唯一索引或者乐观锁版本号来控制。从Java开发者的实际体验看Redis实现分布式锁最容易上手性能也最好但要注意设置合适的过期时间防止锁持有者宕机导致死锁Zookeeper实现更安全但引入Zookeeper也要多承担一个运维组件的成本。我要强调一点分布式锁不是万能的。很多并发问题与其靠锁不如从设计上规避。比如扣库存可以用Redis的原子操作预扣再用异步消息最终落库再比如生成订单号与其加锁不如直接使用分布式ID方案。锁是应对冲突的兜底手段减少冲突才是上策。4.3 定时任务重复执行如何保证只跑一次定时任务在单体时代跑得好好的一到集群环境就成了事故高发区。原因很明显集群里每个节点都会启动Spring定时任务同一个任务在多个节点上同时执行数据被重复处理短信被重复发送报表被重复生成。解决问题的大方向是选主让所有节点抢一个分布式锁抢到的那个节点才真正执行任务其他节点跳过。实现方式也有多种可以用分布式锁把整个执行过程包住也可以用专门的任务调度框架比如Quartz集群模式或者ElasticJob这类方案它们内部实现了分片和选举机制能让多个节点协同处理同一批任务而互不重复。如果项目规模不大最简单的方式就是借助已有的Redis分布式锁给每个任务定义一个唯一的锁key任务执行前先尝试加锁加锁成功且未超时再执行。但要注意锁的过期时间必须大于任务的最长执行时间否则任务还没跑完锁就释放了另一个节点也会抢到锁重复执行的问题又回来了。我一般会把过期时间设置成任务正常执行时间的两倍以上并在任务内部加上幂等判断双保险。4.4 全局唯一ID多节点生成不再可靠数据库自增ID在单机时代用得很舒服但集群化之后多个应用节点在往同一张表插数据时如果还用数据库自增不但会有性能瓶颈还会出现不同节点缓存的自增范围不一致的问题。更重要的是很多业务要求在应用层就生成唯一ID因为要作为消息体、日志追踪ID向下游传递。我常用的方案有这几类基于Redis的INCR原子自增简单直观但依赖Redis的可用性而且性能受限于Redis单实例基于数据库号段模式批量获取一段ID缓存到本地性能好但需要维护号段表基于雪花算法的分布式ID利用时间戳加机器号加序列号生成一个64位的长整型不依赖外部组件本地生成性能极高也是目前Java项目里应用最广的方案。雪花算法本身有一个需要注意的地方它依赖机器号来区分不同节点如果机器号配置重复生成的ID就可能冲突。另外系统时间被回拨时雪花算法生成的ID可能重复好的实现里都会有时钟回拨保护。我在生产环境见过有人直接用网上的雪花算法Demo结果没处理时钟回拨和机器号配置上线后出现ID重复真是一个教训。分布式ID这种基础组件宁可保守一点也不要贪图简单。5. 一个模拟项目从0到1演进为集群的完整复盘5.1 起步阶段单机架构的合理之处以我参与过的一个模拟互联网业务系统为例早期团队很少业务量也没起来架构非常朴素单体Spring Boot应用一台数据库一台Redis。这个阶段如果非要上集群、上微服务反而会增加复杂度拖慢迭代速度。所以我认为单机架构在特定阶段是完全合理的上不上集群要看业务真实诉求而不是为了简历上有亮点强行技术堆叠。当时系统的特点是并发量低用户量少功能迭代频繁团队需要快速验证业务模式。这种情况下单机架构的缺点——扩展性差、可用性低——并没有实际发生因为流量还没大到把机器打满机器也基本没宕过。盲目引入集群的代价却是实实在在的部署复杂度上升、问题排查链路变长、开发调试变慢。所以这里想传达的一个核心观点是先分清业务需要集群还是你觉得应该上集群。5.2 扩容阶段先加机器还是先改代码业务增长到某个节点单机确实撑不住了这时候第一步不是直接加机器而是先做无状态化改造。我们当时的做法是先把Session改成Redis存储再把本地缓存改成Redis缓存把上传文件迁移到统一的对象存储服务。这几步改造完成后应用本身就不存任何关键状态了这时加机器才有意义。否则你加了三台机器Session不同步、缓存不一致系统反而比单机时候更不稳定。无状态化改造完成之后才轮到部署层面的集群化。我们加入了Nginx做统一入口后端同时部署多个应用实例再引入注册中心做服务发现。这一步做完系统已经能从一两台机器平滑扩展到十几台。整个过程我最大的体会是先让代码具备多实例运行的能力再用集群把多实例串起来顺序不能反。顺序反了大概率会经历线上事故的洗礼。5.3 验证与压测集群不是搭完就算完事集群搭建完成之后最容易被忽略的是验证和压测。很多人把服务部署到多台机器上看了几眼监控就觉得集群搞定了实际上隐藏问题很多。我们当时做了三件事第一模拟单节点宕机看流量是否自动切换到其他节点这段时间接口失败率有多高第二压测验证扩展性对比一台机器和四台机器的吞吐量确认瓶颈不在负载均衡器或数据库第三做混沌测试的准备步骤随机杀掉某个节点的应用进程观察系统整体是否仍然可用。压测时发现的一个典型问题值得分享四台应用节点部署好之后吞吐量并没有变成单机的四倍而是只提升了不到两倍。排查下来发现数据库连接池配得过大四台机器把数据库连接占满数据库成了新的瓶颈。后来我们加了数据库连接数限制配合读写分离整体吞吐才真正提上来。这件事一直提醒我集群扩容是整条链路的事应用节点的数量只是其中一个变量数据层的承载能力往往才是真正的天花板。6. 别把集群神话集群与分布式的边界在哪6.1 集群和分布式本质区别很多资料把集群和分布式混着说但严格来说它们是两个不同层面的概念。集群解决的是同一个应用的多个副本协同工作问题强调同一个服务、多份部署、共同对外服务分布式解决的是一个大系统被拆成多个独立的子系统各自部署、相互调用强调一个业务被拆成多个角色每个角色单独演进。你可以这样理解集群像是多请了几个人做同一件事大家听同一个号令谁倒下别人补上分布式像是把一件事拆成好几个工序不同的人负责不同的工序前后协作完成整个流程。实际系统中两者经常并存一个分布式系统里的某个服务自身可能会做集群部署。对Java开发者来说理解这个区别的意义在于决策。如果你当前的问题是并发高、可用性差先考虑集群如果你是业务模块太复杂、团队协作效率低、需要独立发布那才考虑往分布式架构演进。倒过来的话系统会被拆得稀碎但并发和可用性问题一个没解决。6.2 什么时候不该上集群写到最后我想说点反主流的话。不是所有Java项目都适合上集群也不是所有问题都值得用集群解决。以下几个场景我建议你先别急着上集群。第一业务量还没起来单机资源利用率很低这时候上集群纯属增加运维成本。Java应用部署一套和部署三套配置、监控、日志采集、发布流程都要跟着变这些隐性成本不比买一台服务器低。第二团队没有专职运维或对基础设施掌控力弱集群化之后出了问题排查难度成倍上升没有足够的能力兜底不如先把单机做扎实。第三系统里有大量难以改造的有状态逻辑比如依赖本地文件、本地定时任务里保存了运行状态这种系统强行集群化会制造更多问题优先要做的是改造而不是加机器。如果你判断下来确实应该上集群那就按这篇文章的顺序来先想清楚集群解决什么问题再梳理你的代码是不是无状态然后选好负载均衡和注册中心最后在数据层做出适配并且一定做好踩坑的心理准备。集群从来不是终点它只是一个让Java系统能走得更远的起点后面还有分布式事务、链路追踪、容器编排这些更深的坑在等着。