从纸面上看,这只是一份随手写的学习记录,标着“p5-13”,没有前言也没有总结。但翻过这几年,再回头看这些页码,我意识到这几页恰好覆盖了分布式系统入门最关键的转折点:从会用框架,到能理解框架为什么这么设计。很多人学分布式,是Spring Cloud一上来就搭注册中心、配Feign、调熔断,但遇到线上问题还是一头雾水。而这份笔记之所以到现在还有参考价值,是因为它没急着写结论,而是把每个组件的出现原因、失败场景和选型逻辑都记了一遍。
所以这篇就不按教科书顺序来讲了,我按自己踩坑的顺序重新梳理一遍:为什么系统会走向分布式、分布式最难的两致性问题、工作中高频使用的任务调度与锁、监控容器化部署的坑,以及最后一条适合普通后端的学习路径。如果你也在学分布式,或者正被分布式事务、分布式锁、xxl-job这类问题困扰,这篇应该能帮你省下不少排查时间。
1. 从单体到分布式:拆的不是服务,是问题的边界
笔记的第一部分没有直接写技术选型,而是提了一个问题:什么时候必须要上分布式?这个问题的答案,比分布式的解决方案本身更重要。
1.1 一个系统变慢,先别急着拆
有个很常见的现象,团队一遇到性能瓶颈,第一反应就是把系统拆成微服务。但拆完以后,接口从一次本地调用变成三次远程调用,慢的问题不仅没解决,反而多了网络开销和分布式事务。
我当时记下的判断标准很简单,就三条:
- 数据库连接数不够了,应用层怎么加机器都白搭,瓶颈在底层存储。
- 单一应用的内存或磁盘容量到了物理上限,比如单机MySQL存不下、单机ES索引撑爆。
- 团队协作成本已经大于技术成本,代码合并冲突比写代码还频繁。
顺序很重要。第一个要解决的是存储层的问题,第二个才考虑缓存、读写分离或分库分表,最后才是微服务拆分。笔记里我给自己画过一个链表式的依赖图,先理顺哪个模块必须调用哪个模块,哪些调用是循环的,哪些数据必须强一致,哪些最终一致就够了。这一步没做,后面无论用什么样的分布式框架都会觉得别扭。
1.2 CAP不是让你三选二,是让你别自欺欺人
每个分布式话题几乎都会扯到CAP,但这套理论的价值如果只被简写成“三选二”,反而会误导人。
CAP说的是,当发生网络分区(P)时,你必须在一致性和可用性之间做取舍。注意,它说的是发生分区时。系统正常运行时,你可以同时有一致性和可用性,只有在节点之间联系不上的极端情况下,才被迫做选择。所以问题不是“三选二”,而是“P一定会发生,那一刻你更接受所有节点暂停服务,还是允许部分节点短暂返回旧数据?”
我当时做的一个简单类比是收银系统:如果总店和分店网络断了,分店是继续卖东西(AP),还是一律停止收银,等网络恢复后对账(CP)?选AP的,顾客体验好,但可能出现两边都卖出了同一件库存只有一件的商品;选CP的,不会超卖,但断网期间一分钱生意都没了。
笔记里给自己列了一张对比表,后来面试和设计都直接用来查:
| 场景 | 更看重 | 常见做法 |
|---|---|---|
| 订单支付扣库存 | 不超卖 | 数据库行锁、分布式锁、事务消息配合 |
| 用户签到、点赞量 | 最终能对上 | 先记本地,再异步汇总,允许短时偏差 |
| 商品详情页缓存 | 别打爆数据库 | Cache Aside,删缓存或延迟双删 |
| 配置中心、注册中心 | 只要有一份可用就要能读 | AP优先,容忍短暂不一致 |
| 跨行转账 | 绝不能错 | 最终一致,带事务状态表和重试 |
在线交易系统里,强一致的场景其实比想象中少得多。大部分业务要的是最终一致,关键是你用什么机制去达到最终一致,以及在这段时间里如果出错了怎么补偿。
1.3 数据拆分,才是分布式最扎实的第一步
很多人以为上了注册中心就算分布式了,实际上数据层面还是单库单表,那样的分布式只解决了“计算压力”,没解决“存储压力”。
笔记里关于拆库的思考值得记录下来:
- 垂直拆分:按业务域分库,比如用户库、订单库、商品库。简单直接,但跨库的join没了,报表查询变得更复杂。
- 水平拆分:按某个维度路由数据,最常见的键是用户ID或订单ID。hash取模的方式路由均匀但扩展时要迁移数据;一致性hash迁移量小,但可能出现数据倾斜。
- 冷热拆分:把访问频率低的历史数据归档到独立的库或表。
当时我在测试环境用ShardingSphere做了一次订单表水平拆分的验证,印象比较深的一个问题是“跨分片的聚合”。比如统计某用户所有订单金额,按用户ID分片就很简单,但如果按订单ID分片,一个用户的订单散在多张表里,聚合只能通过中间件层做内存归并。
所以后来养成的一个习惯是:先想清楚主要查询维度再决定分片键。分片键选错,后面所有跨分片查询都是在给自己挖坑。这个思考顺序也体现在了我后续所有分布式方案里:先定义问题边界,再去考虑用框架。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分布式事务和分布式锁:分布式里最硬的骨头
笔记里这块内容潦草但反复改过很多遍,因为一开始太天真,以为有一个万能方案能解决所有一致性问题,后来才明白分布式事务没有银弹,只有不同约束下的取舍。
2.1 订单与库存:我模拟过的三种分布式事务方案
我在学习的时候搭了一个最简单的订单-库存场景:创建订单,扣减库存,如果库存不足则下单失败。单体时代,一个本地事务加个行锁就完事,秒杀也顶得住。但在服务拆分成订单服务和库存服务之后,一次下单要跨服务调用,本地事务已经无法覆盖两个库。
先后试过三种方案:
方案一,两阶段提交(2PC)。 通过事务协调器先让所有参与者执行预提交,都成功后协调器再通知全局提交。理论很完美,实践时发现,如果协调器宕机,参与者会一直处于阻塞状态。后来试着引入了事务管理器的高可用,但协调器本身的故障恢复逻辑极其复杂,对网络超时也很敏感。
方案二,TCC。 Try阶段锁定库存,Confirm阶段真正扣减,Cancel阶段释放。优点是业务控制粒度细,能把“预留库存”这种操作显式表达出来;缺点是要为每个操作写三个方法,还得自己处理幂等。订单场景里如果Create订单成功后库存服务Cancel失败,要靠最大努力通知来捞,整体开发量不小。
方案三,本地消息表加消息队列最终一致。 订单服务在自己库里写订单数据的同时写一条“待扣库存消息”,放进本地事务表;然后异步把这条消息发给MQ;库存服务消费消息,扣减库存,成功后调用订单服务的回调接口更新状态;如果消费失败,则由定时任务不断重试本地消息表中未确认的消息。
第三种方案最受团队认可,它没有让两个服务僵在同一个事务里,而是通过消息这个中间载体解耦,配合重试和幂等,只要最终状态一致就可以接受。实际操作下来,代码会多出一定复杂度,但这种复杂度是肉眼可见的,出了问题也知道去哪排查,比黑盒的2PC要稳定得多。
2.2 Redis分布式锁:从SETNX到Redisson看门狗
锁的问题是另外一个故事。单机时用ReentrantLock或者synchronized就够了,服务拆成多实例以后,进程内的锁互相不可见,就会出现“两个订单服务实例同时操作同一个用户的数据”这种并发问题。
我会优先推荐先看Redis分布式锁。第一版代码很多教程都有,用Redis的SETNX命令,SET key value NX PX 30000。存在两个经典问题:
- 锁没有加owner标识,A线程的锁超时自动释放后,B线程拿到锁,此时A线程恰好执行完删除锁,就删掉了B的锁。所以value必须带上唯一标识(UUID),删除前先比对。
- 锁的超时时间很难设置得精确。设短了,业务没执行完锁就释放;设长了,持有锁的节点宕机,其他节点要等很久。
后来换成了Redisson,它默认的RLock带看门狗机制,会自动续期。拿到锁后,如果业务没执行完,锁的有效期会自动延长,默认每10秒续到30秒。这个机制解决的是“业务执行时间不确定”的问题。我当时还专门模拟过持有锁的节点GC停顿超过30秒,看门狗线程自己被阻塞无法续期,锁最终还是被别人拿走了。所以Redisson也只会降低概率,不会根除问题,真正对并发要求极高的场景,还需要借助数据库行锁或ZooKeeper的顺序节点做兜底。
笔记最后一行写的是:分布式锁不是解决并发问题的首选方案,很多并发问题可以通过幂等设计和乐观锁在数据层解决。
2.3 一道分布式锁面试题,能问出什么
热搜词里有“分布式锁面试题”,我就把自己梳理过的脉络贴出来,方便对照查漏:
| 考点 | 期望能听到的回答 |
|---|---|
| 为什么要分布式锁 | 多实例并发修改共享资源,进程内锁不可见 |
| Redis SETNX有哪些坑 | 死锁(忘记释放)、误删别人锁(无owner)、超时不准(TTL设多少) |
| 怎么优化 | 唯一value、Lua脚本保证“判断-删除”的原子性、看门狗自动续期 |
| 还有哪些实现 | ZooKeeper临时顺序节点,ZNode删除后自动触发等待唤醒 |
| 生产环境选型建议 | 允许秒级失效、追求性能用Redis;可靠性优先考虑ZooKeeper或etcd |
| 锁的实际定位 | 能少用尽量少用,优先考虑数据行锁、乐观锁、幂等 |
面试题的价值不是背答案,而是验证你有没有真的把某类问题想清楚。我自己答过一次从Redis锁讲到ZooKeeper锁再到最终一致的完整链路,对方追问了一个问题:如果ZooKeeper本身的会话因为GC长时间中断,锁会怎样?这里就反应过来,ZK锁的“客户端断开自动删节点”看起来优雅,但客户端只是长时间停顿,会话未超时前锁依然存在,业务执行还是可能并发。这就是很多问题的共性——没有任何一个分布式组件能替你兜住所有底。
3. 定时任务、缓存与分布式ID:日常开发绕不开的三件套
如果说分布式事务是“高难度动作”,那分布式任务调度、缓存一致性和ID生成算是日常开发的“基础功”,每个团队都会碰到。
3.1 定时任务单机跑没问题,多机跑就出大事
最典型的例子:每天凌晨要给一批过期订单做关单处理。单机用一个@Scheduled方法跑就行,但上了多实例之后,每个实例的定时任务都会触发,同一批订单会被处理好几次。即使代码里做了状态判断,也容易出现重复短信通知、重复调用第三方接口这样的副作用。
我当时整理了三个解决方向:
- 只让一台机器跑任务,其他机器不跑。方式包括通过配置文件开关控制,或者使用xxl-job这类中心化的任务调度平台。
- 不用锁整个任务,改为每台机器分片处理。任务调度平台把数据ID按实例数量取模,每台只处理自己分到的那部分。数据量大的场景,这比“抢一把大锁+单机执行”要高效很多。
- 用分布式锁保证同一时刻只有一个实例执行某个任务,但前提是任务本身不能跑太久,也不能容忍锁偶尔失效。
推荐xxl-job的原因很现实:它支持控制台动态配置任务、调整执行时间,不需要重启服务;任务可以分配到指定执行器节点;失败告警、重试和日志都有现成界面。虽然本身也是一套需要维护的组件,但复杂度相对低,很多中小团队支撑得起。
3.2 Redis缓存一致性:不是删了就行
缓存和数据库的一致性问题,我做过几次实验才确认哪种方案最稳定。
最终在实践中一直使用的是Cache Aside模式:
- 读:先读缓存,缓存没有则读数据库,回填缓存。
- 写:先写数据库,然后删除缓存。
- 删除失败,可以延迟后重试删除,或者在消息里携带被删除的key,由消费端重试。
这里有一个关键点:为什么更新数据库时不直接更新缓存,而是删除缓存?因为更新缓存的代价可能很高,比如缓存里的值由订单表和商品表联合计算得出,更新一次订单数据就把联合结果也重算了,性能反而差。删除缓存让下一次读走到数据库再回填,更简单也更稳定。
另一个常见问题是先删缓存再写数据库:
code复制线程A:删除缓存
线程B:读取缓存未命中,读旧数据写入缓存
线程A:写入数据库新值
结果缓存里留下来的是旧数据,还把刚写好的新库数据挡住了。正确顺序是先写库、再删缓存。而且Redis的删除命令可以用Lua脚本来封装,配合setnx前缀做幂等,可以判断“如果当前缓存项是我生成的那个版本才删除”,避免误删其他调用方重新填进去的新缓存。
总结下来,没什么高深的原理,就是“把旧数据从缓存里请出去,让下次读取时去数据库拿最新的数据”。但很多人一开始弄错了顺序,就会出现线上缓存里一直是旧值的诡异问题。
3.3 分布式ID为什么需要,以及雪花算法的坑
数据库单表自增主键在分库分表之后就不再适用了,因为不同分片各自独立生成主键,会重复。就算把多张表的自增步长错开,也只能解决单点写入,没法应对高并发下的全局唯一ID需求。
常见的分布式ID方案:
- Redis
INCR:实现简单,但全局只有一个计数器,可用性和性能取决于Redis,且ID是连续的,容易被猜到业务量。 - UUID:本地生成,完全不依赖外部组件,但36位字符串过长且无序,作为数据库索引会导致B+树大量随机写。
- 雪花算法(Snowflake):64位long型,高位是时间戳,中间是机器ID,低位是序列号,趋势递增且性能高。
- 美团Leaf、百度UidGenerator:对雪花算法做了优化,比如用zookeeper管理workId或借用数据库号段。
雪花算法的细节看起来简单,操作时会踩坑。它一共64位:1位符号位、41位毫秒时间戳、10位机器编码、12位序列号。41位时间戳大约可用69年,看起来很多,但有的实现里初始纪元如果从2020年开始算,最多到2089年就溢出。
更常见的坑在于机器ID分配。如果同一台宿主机上两个实例没配置不同的workerId,生成的ID就可能重复。容器化部署时这个尤其容易被忽略,因为看起来是两套容器,但取到的宿主机IP可能是同一个。后来我习惯将workerId通过环境变量注入,或者用数据库号段方式分配,才彻底规避掉。
4. 从“本地能跑”到“容器里稳定跑”:监控、任务调度和集群部署的实战记录
标题里写着p5到p13,这部分其实记录的是一次完整实践:把分布式监控cat服务端部署到容器上、用xxl-job搭定时任务、以及把Hadoop从伪分布式换成完全分布式集群。这些内容看起来像是“部署笔记”,但它们在分布式入门的跨度上是必不可少的——你要真正理解“分布式不只是一堆进程,而是一整套基础设施”。
4.1 CAT服务端容器化:配置和观测的匹配过程
CAT是大众点评开源的应用监控平台,从客户端采集调用链数据,上报到服务端聚合展示。在把它部署到容器时,我遇到过几个比较典型的适应性问题:
容器重启后,CAT默认写到本地磁盘的data目录会丢失。如果只是测试环境问题不大,生产上日志会散落在僵死容器里无法找回。所以需要挂载持久化存储,把data和logs目录放到外部存储上。
CAT集群需要集群内节点信息一致,它会依赖一个server.xml里的路由配置。容器部署时,我一开始只在一个节点上改了配置,另一个节点还是旧配置,客户端上报就被随机打到了旧节点上,监控数据出现断层。后来我把路由信息统一放进配置中心下发,或者通过环境变量注入节点列表,才让各节点保持一致。
作为一个分布式系统,CAT自身也需要避免被监控流量压垮,最大的问题是客户端上报频率过高、单个事务消息体过大。当时调整过CAT客户端的采样率,线上基本按1%采集,部分错误日志100%上报。这个思路可以套到很多监控组件上:可观测性是有成本的,什么都全量采集,最后真正要看的关键链路反而被海量日志淹没。
4.2 XXL-Job接入容器化架构的一些建议
xxl-job的接入并不复杂:调度中心(admin)可以单独部署,执行器以jar包或嵌入业务服务方式注册上来。
当时在Spring Cloud架构里接入xxl-job时发现几个容易忽略的地方:
- 执行器的AppName需要和调度中心配置一致,否则执行器不会上线。这个不一致问题很隐蔽,配置中心会根据不同环境替换AppName,如果环境变量没覆盖到位,开发环境能跑,生产环境却找不到执行器。
- 调度中心与执行器之间的网络要双向可通。一些公司安全组只允许业务出口访问,调度中心反过来回调执行器就会被挡。更稳妥的方式,让执行器主动向调度中心发起心跳和触发结果回调,而不是依赖调度中心主动连执行器。
- 分片广播与动态实例变化存在时间差。当一个实例停机后,调度中心要过几个心跳周期才能确认实例下线。在实例做滚动发布时,正在运行的任务会产生短暂的双跑窗口。因此在设计任务处理逻辑时,一定要保证幂等,宁可重复执行,也要避免重复执行造成脏数据。
从这个角度看,任务调度平台解决的是“谁能跑、什么时候跑、跑完怎么通知”,但任务执行本身的幂等、重入、超时控制仍然是开发者自己的责任。
4.3 Hadoop从伪分布式到完全分布式:回归“分布式”这个词的本质
可能很多人和我一样,接触的第一个“分布式系统”其实是Hadoop。但伪分布式模式(所有进程都在同一台机器上的不同JVM里)更像是一个教学演示,真正跑完全分布式集群后,才会理解分布式系统的节点间通信、数据冗余和故障转移是怎么回事。
当时我在自己电脑上准备了三台虚拟机,分别是hadoop-master、hadoop-worker-1、hadoop-worker-2。核心配置大概是:
core-site.xml里配置NameNode地址为master主机名;hdfs-site.xml把副本数设成3,并使用dfs.namenode.name.dir、dfs.datanode.data.dir分别指定元数据和数据的存储路径;yarn-site.xml里配置resourcemanager主机;mapred-site.xml把框架设为yarn。
启动顺序也值得留意:先在master上执行hdfs namenode -format,再执行start-dfs.sh和start-yarn.sh。如果格式化后发现某个DataNode没有注册进来,多半是DataNode的clusterID跟NameNode不一致,需要删除DataNode上的数据目录后重新格式化,或者直接把NameNode的clusterID同步过去。
那段时间对“分布式”这个抽象概念最有体感的一刻,是手动kill掉一个DataNode进程后,发现HDFS依然能正常读,并且在控制台看到副本自动补全。从“只在书上看过N个副本”到“真正看到数据自己搬家”,这种体验比任何理论都让人印象深刻。后来再学Kafka、Redis集群时,理解副本同步和故障转移都轻松了很多。
4.4 测试环境分配和资源有限时,哪些坑可以提前避
既然聊到集群搭建,再多说几句虚机资源有限时布局,避免新人反复折腾。我自己经历过在8G内存的笔记本上强行搭三节点虚拟机,卡得想砸电脑。后来找到相对顺手的搭配:
- 用Docker容器代替虚拟机模拟节点,镜像体积小、内存占用低,启动也快。多套Kafka或Hadoop测试集群都适合用docker-compose编排。
- 用三台性能普通的Linux服务器或云主机即可,不需要高配,重点是节点间网络能连通。
- 端口映射要提前规划,否则后面加节点时互相冲突。
这只是为了学习验证。如果要做真正的性能压测,容器环境与宿主机之间的网络开销、磁盘IO隔离不能忽略,至少要在物理机或同等配置的虚拟机上跑,数据才有参考意义。
5. 学习分布式的务实路线:一条不需要一开始就读源码的路
到了最后,我来梳理一下我自己的学习路线,也是踩过坑之后我推荐给同事的一套路径。我不太建议一上来就看那些万字长文的Paxos/Raft论文,对新手来说收获不大。
可以用一个“螺旋上升”的路线:
- 先用单体实现一个业务系统,比如带订单和库存的简易商城。把接口、数据库都跑通。
- 把订单服务和库存服务拆成两个独立进程,用HTTP或RPC通信。你会第一次意识到网络调用会超时,超时后又该怎么办。
- 引入消息队列,解决服务间的异步削峰和最终一致。观察消息丢失、重复消费的问题。
- 引入Redis做缓存,实践缓存穿透、雪崩、击穿,再把分布式锁加进来处理并发。
- 引入xxl-job解决定时任务在集群中的重复执行问题,想办法保证任务幂等。
- 最后去搭建一套完整的集群环境,从Hadoop、Kafka这类基础组件开始,加深对数据分发和副本机制的印象。
如果有精力,可以选一个方向横向加深,比如专门研究分布式事务,把Seata、RocketMQ事务消息、本地消息表全部对比一遍;或者专注调度平台源码阅读,理解xxl-job的触发机制是推还是拉。
从个人成长的角度,我比较建议再往前一步,去读一读注册中心相关的服务发现原理。很多人会用Nacos、Eureka、Consul,但遇到服务节点变化不够快、客户端缓存不刷新时会无从下手。如果能看懂客户端主动拉取加服务端推送的模型,再配合心跳机制和本地缓存的理解,很多线上问题都会在自己脑子里完成初步定位。
笔记页还夹了一张便签,上面写着一句话:“分布式系统里,你永远无法只靠Add Nodes解决混乱,你的架构需要考虑到节点会失败、网络会延迟、消息会乱序。”这几页笔记写得很朴素,但每一条都对应了一次次的线上抖动和理论学习。分布式是那种理论和实践特别割裂又特别互补的方向,如果这份梳理能让你在某条路上少走一小段弯路,那这几页笔记就值了。
