news 2026/10/16 2:38:52

电商系统分布式架构改造:从单机到微服务的实战演进

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
电商系统分布式架构改造:从单机到微服务的实战演进

1. 从单机到多节点:这次改造最初要解决什么

把网站从单机搬到多节点,在外人看来好像是“上规模”的标志,可真正动手做这件事的人心里都清楚:分布式架构从来不是目的,而是被业务逼出来的必然选择。我这次负责改造的“某在线商城系统”,最初就部署在一台8核16G的云服务器上,数据库、应用服务、静态资源全部挤在一块。系统日均请求量大概在1200QPS左右,促销时段会冲到3000QPS,数据库CPU常年徘徊在80%以上,慢查询日志里一堆排序和聚合语句。每到营销活动开始前,团队的第一反应不是优化代码,而是先准备一台备用机,随时准备手动切换。

这种状态维持了差不多半年,大家心知肚明问题在积累:单台机器一旦宕机,全站不可用;数据库磁盘占用接近85%,扩容只能靠加磁盘;应用重启一次要三分钟,期间请求全部超时。真正让我下决心做分布式改造的,不是某次故障,而是一次普通的商品详情页接口抖动——那次接口平均响应时间从200ms一路涨到3秒,排查到最后发现是数据库连接池被一个批量导出任务占满。这种“互相拖累”的问题在单机架构里无解,因为你没有办法让订单服务和商品服务各自隔离资源。于是分布式改造被正式提上日程。

1.1 流量画像与容量瓶颈

做改造之前,团队先花了一周时间做流量画像。我把nginx的access log拉出来,按接口维度做了聚合分析,结果很有意思:真正消耗数据库资源的,并不是用户最常点的首页和详情页,而是后台管理系统里的报表查询和商品批量导入功能。这些功能一天可能只被调用几百次,但每次查询都是全表扫描,或者批量写入几千条记录,数据库的CPU和IO瞬间被打满。也就是说,单机架构下“慢接口拖垮快接口”的现象,本质上不是性能问题,而是资源隔离问题。

我们还统计了核心链路的依赖关系:一次完整的下单操作,需要调用用户服务、库存服务、订单服务、支付回调,以及发送短信通知和更新会员积分。这个链路在单机架构里是多个模块之间的本地方法调用,看起来很快,但每次调用都共享同一个进程内的线程池和数据库连接池。一旦某个模块出现阻塞,整个tomcat的工作线程就会被占满,其他业务的响应时间全部跟着遭殃。容量瓶颈的计算也很直观:8核机器上tomcat默认最大线程数是200,假设每个请求平均占用线程300ms,那这台机器理论上的极限吞吐就是200除以0.3秒,约等于666QPS。而我们平时的流量就已经是1200QPS,早已超出理论极限,之所以还“能用”,是因为很多请求在静态资源层就被缓存掉了,真正打到应用层的只有一小部分。

1.2 分布式架构的设计目标(和边界)

做技术方案时,我给自己定了三条边界,后来回头看,这三条边界救了这个项目好几次。第一,不追求大而全的微服务化,而是把业务拆成六个可以独立部署的服务,原则是“能不拆就不拆,拆了就一定要独立数据库”;第二,所有涉及钱和库存的操作,必须保证强一致,其他场景允许最终一致;第三,改造过程要保持系统可回滚,也就是说新旧架构必须共存至少一个版本周期,不能搞“一步到位”的切换。

这三条边界在很大程度上决定了后续的架构选型。例如六边形仓库里的用户、商品、订单、库存、营销、支付六个服务,每拆出一个服务,都对应一张可以独立扩容的数据库。网关层引入统一路由后,前端无感知,服务层通过注册中心互相发现,调用关系从硬编码的HTTP地址变成了服务名级别的路由。改造分两个阶段走完:第一阶段先把用户、商品、订单三个核心服务拆分出来,数据库从单库拆成三个独立库,同时接入缓存和消息队列解决跨服务一致性问题;第二阶段再把库存、营销、支付服务拆出来,并补充了全链路的可观测性体系和故障演练机制。

我见过不少团队做分布式改造,上来就买一堆中间件,把服务拆得特别细,结果部署、排查问题、数据一致性的成本反而比单体更高。这次我的做法是先从“资源隔离”的角度切入,先用Spring Cloud Alibaba体系搭注册中心和配置中心,服务间调用先用OpenFeign加Sentinel限流,数据库拆分用ShardingSphere的分库分表能力,异步场景用RocketMQ。这套组合的好处是文档多、社区活跃、团队学习成本可控,而且所有组件都可以从单机模式平滑切换成集群模式,为后续扩展留了余地。

2. 整体架构设计:以应用网关和注册中心为骨架

改造后的整体架构,对外只暴露一个统一入口,也就是API网关。所有的前端请求、APP请求、第三方开放平台请求,统一先打到网关层,由网关负责鉴权、限流、路由转发,再分发到后端的各个微服务。这样做的好处一眼就能看出来:你不用在每个服务里都写一遍鉴权和限流逻辑,安全策略的修改只需要在网关层完成。网关后面是六个微服务,它们之间通过注册中心互相发现,不依赖固定的IP地址。每个服务都有独立的数据库,服务之间不允许直接访问对方的数据库表,数据交互必须走接口或者消息队列。

这套架构的核心骨架,其实就是两个东西:网关决定了流量怎么进来,注册中心决定了服务之间怎么找到彼此。以订单服务和库存服务为例,在下单链路上订单服务需要扣减库存,它不需要知道库存服务的IP和端口,只需要向注册中心发起一次服务发现调用,拿到一个可用的库存服务实例列表,然后通过负载均衡策略选一台发起请求。当库存服务从2个实例扩展到5个实例时,订单服务侧不需要做任何配置变更,这种弹性能力是单机架构完全不具备的。

2.1 服务拆分:按业务域而不是按代码包

服务拆分看起来是个技术活,其实第一步是业务梳理。我们当时画了一张业务域地图,把整个商城系统的功能分为用户域、商品域、订单域、库存域、营销域、支付域。分域的原则不是按代码分层,而是按“数据归属”来分:这个域的数据,最终应该由且仅由这个域的服务来读写。举个例子,用户的收货地址在用户服务里,订单表里只存地址快照,不通过外键去关联用户表中的地址记录;订单表保存订单金额冗余字段,不下发到客户端后每次实时计算;商品服务维护价格和库存表,但订单服务只有在发起下单扣减库存时才调用库存服务接口。

拆完之后,六个服务的责任边界非常清晰,团队内部的开发归属也符合这个划分。但拆服务最怕的是“逻辑深层依赖”,比如下单时要同步调用用户服务查会员等级,算出折扣后再创建订单,这个跨服务调用一旦失败,订单就创建不了。我们最后把会员等级折扣做成了“可降级”:下单时先读缓存中的会员等级,缓存读不到就返回默认折扣,不和用户服务的实时数据强绑定,保证核心下单链路不受影响。这种取舍很关键——分布式的核心追求不是把所有东西都做成强一致,而是让最重要的链路保持稳定。

2.2 注册中心与配置中心选型

服务拆完之后,第一个要解决的就是“服务之间怎么互相找到”。我最早考虑过直接用nginx做静态服务列表管理,但微服务化之后实例数量会动态变化,手动维护nginx upstream不现实。后来对比了Eureka、Consul和Nacos,最终选了Nacos。理由有三点:一是Nacos同时支持服务注册发现和配置管理,一个组件解决两个问题;二是它支持从Spring Cloud Alibaba体系里零成本集成,团队不用额外学习一套新API;三是它在AP模式下的表现适合我们的业务场景,服务注册中心允许短暂的数据不一致,但必须保证高可用。

配置中心这一点容易被新手忽略,但对后续运维非常重要。我们把每个服务的数据库连接池大小、缓存超时时间、限流阈值、开关配置全部迁移到Nacos配置中心,通过配置的@RefreshScope注解支持动态刷新。这样在线上出现问题时,不需要重新发版,直接修改配置就能调整参数。比如有次某个服务的数据库连接池设置得过大,导致数据库连接数被打满,我直接通过配置中心把连接池从100调成50,几秒钟就生效,避免了紧急发版。

2.3 统一网关的出入口设计

网关选型时对比了Spring Cloud Gateway和Zuul,最终用了Spring Cloud Gateway。它基于WebFlux响应式编程模型,底层是Netty,性能和资源占用都比Zuul 1.x好不少。我在这台网关上做了几件固定的事:全局的鉴权过滤器、灰度发布的路由规则、全局限流。集中式鉴权就是把token校验、签名校验、黑白名单集中到网关层,下游服务默认信任网关传过来的用户身份信息,不再重复解析token。这样服务之间的调用要想伪造身份,难度会高很多,因为内部调用统一通过服务名和签名机制认证。

限流这一块我用了Sentinel的网关限流模块,规则存到Nacos里动态下发。针对不同的API路径配置了不同的阈值,比如登录接口限流每秒钟100次,商品详情接口限流每秒钟2000次,下单接口每秒钟500次。超过阈值的请求直接返回“系统繁忙”的友好提示,避免流量瞬间打到后端服务导致雪崩。灰度发布则是通过网关路由规则实现的:把一部分用户请求按userId的hash值路由到新版本服务,等观察指标稳定后再逐步放量到100%。这套机制后来在商品服务的重构上线中发挥了巨大作用,新版本和旧版本共存了整整一周,直到监控数据没有出现任何异常,才把旧版本下线。

3. 数据层改造:分库分表、缓存与一致性

服务拆分之后,最难的其实是数据库。单机架构里所有表都放在同一个库,服务拆分后必须跟着把数据拆开。拿订单服务来说,订单表、订单明细表、支付流水表这几张表一开始拆出去的时候,数据量很快到了千万级别,单表查询明显变慢。我们在订单服务里引入了ShardingSphere做分库分表,按order_id的哈希值分成四个库、每库四张表。这个拆分粒度是综合考虑后确定的:按当前数据量增速估算,两年内单表数据量不会超过500万,查询性能可控;分片键用的是order_id,这样同一个订单的明细一定会落在同一张表里,避免了跨分片的join操作。

但分库分表也带来一个约束:所有查询条件必须以分片键为前提。比如“查询某个用户最近十笔订单”,如果按order_id分片就查不了,因为不知道每个订单的order_id,只能去所有分片广播查询。为了解决这个问题,我们额外维护了一张“用户与订单的映射索引表”,这张表以user_id为分片键,查询用户订单时先查映射表拿到要查询的分片列表,再精确到分片查询。这是分库分表后的一个经典套路:要么冗余数据,要么维护映射索引,没有免费的午餐。

3.1 数据库分片与读写分离

除了分库分表,读写分离也是异步数据流中很重要的一环。主库负责写,从库负责读,通过MySQL主从复制同步数据。我们把用户服务、订单服务的读流量切到从库之后,主库的压力下降了一大半。但读写分离也有延迟问题——用户支付成功后,订单状态在主库更新,前端页面马上刷新,如果这时候页面读的是从库,可能读到旧数据,表现为“订单还是待支付状态”。这个问题的典型解法叫“写后读一致性”,做法是:从库延迟超过阈值时,读请求降级到主库;或者在关键状态流转之后设置一个短暂的路由开关,让该用户接下来几秒的读请求强制走主库。

关于分片策略,这里有个值得记下来的细节:不要等到单表数据量破千万才开始分片,分片越晚成本越高。我们的经验是当单表预估超过300万行时,就要考虑拆分了,因为数据迁移、应用改造、索引重建的窗口期会随着数据量指数级增长。而且在做分片设计时,一定要先想清楚核心查询场景,分片键一旦定下来很难改,每次改分片键都要做全量数据迁移。如果业务查询维度是多样化的,宁可增加冗余表,也不要试图用一个分片键解决所有查询。

3.2 缓存策略与热点处理

数据库压力降下来,很大一部分功劳要给缓存。我们用的是Redis集群,三个主节点三个从节点,哨兵模式自动故障转移。缓存的使用有几个层次:最基本的是商品详情缓存,key是商品ID,value是组装好的商品数据JSON,过期时间设置为30分钟加随机扰动,防止缓存雪崩;再往上一层是“热点商品缓存”,系统根据最近五分钟的访问量自动识别热点商品,对热点商品延长缓存时间到两小时;还有一层是“防击穿缓存”,对于查询结果为空的数据设置一个短时间的空值缓存,防止不存在的数据每次查询都穿透到数据库。

缓存与数据库的一致性,我采用了一个被验证过多次的模式:Cache Aside Pattern,也就是先更新数据库,再删除缓存。为什么不是先更新缓存再更新数据库?因为并发环境下非常容易出问题。比如两个线程同时更新同一个商品价格,线程A更新数据库价格为100,线程B更新数据库价格为200,如果先更新缓存,可能出现缓存最终保留的是旧值的情况。而“先更新数据库、再删除缓存”配合缓存的懒加载策略,即使删缓存失败,最多就是下一次请求多查一次数据库,把正确的结果重新加载进缓存,一致性风险要小得多。这里需要注意,删除缓存时如果有并发读请求刚好在删之前把旧数据写回了缓存,就可能出现一段时间的不一致,缓解手段是给缓存设置一个较短的过期时间兜底。

3.3 分布式事务的取舍

订单服务扣减库存、创建订单、生成支付流水,这三个操作涉及到订单库和库存库两个数据源,单机上的本地事务已经无法保障跨库的原子性。我们的做法是:引入RocketMQ的事务消息,把“创建订单”和“扣减库存”拆成两个步骤。订单服务先发送一条半消息,同时执行本地事务创建订单;本地事务成功后,提交半消息,库存服务消费这条消息执行扣减库存;扣减失败时通过消息重试反复尝试。

这套机制的核心原理是:把分布式事务变成一个本地事务加一个消息的可靠投递。本地事务保证订单数据一定是正确写的;RocketMQ的定时重试机制保证扣减库存的消息最终会被消费。有一个地方要特别小心:库存服务的消息处理逻辑必须是幂等的,因为消息可能在网络超时后被重复投递。我们的做法是库存扣减表里增加了“消息去重表”,用唯一键(订单号加消息ID)做约束,重复扣减时抓住数据库的唯一约束报错,直接返回成功,不再真正扣减库存。这个细节如果不注意,线上很容易出现“订单只下一个,库存却扣了两遍”的事故。

分布式事务的原则,在这套架构里是“能不用就不用”。大多数业务场景根本不需要强一致,比如用户下单支付之后,营销积分可以延迟增加;订单发货之后,物流状态的更新延迟几秒完全能接受。只有库存、支付流水这类直接影响资金和资源的操作,才需要事务消息来保证最终一致。把“强一致”的范围缩得越小,整个系统的吞吐能力和稳定性就越好,这个观点我在和不少同行交流时反复验证过。

4. 异步化:消息队列把链路“拉平”

订单创建之后要做的事情非常多:发短信通知、推送App消息、更新会员积分、生成财务报表、同步搜索引擎索引。如果在下单接口里把这些操作全部同步做完,响应时间会变得不可接受。所以我们在下单成功之后,只发送一条“订单创建成功”的消息到RocketMQ,让下游的这些模块各自订阅消费,把本来串行执行的链路变成并行异步执行。这样下单接口的实际耗时只包含订单入库和扣减库存两部分,接口响应时间从原来的800ms降到了150ms左右。

异步化还有一个额外好处:系统的峰值处理能力不再受限于最慢的那个环节。举个例子,某次大促活动瞬间涌入大量下单请求,短信服务所在的第三方通道响应很慢,如果走同步调用,下单接口就得一直等待短信通道的响应;改造成异步之后,短信服务对慢通道的影响被隔离在下游,不会阻塞主流程。消息队列在这里起的是一个“削峰填谷”的作用,把瞬间的流量峰值消化在队列里,让下游消费方按照自己的处理能力平缓地消费。

4.1 异步削峰场景怎么选消息队列

市面上常见的消息队列有Kafka、RocketMQ、RabbitMQ、Pulsar,我们最终选了RocketMQ。选择理由很少,但每条都关键:一个是它支持事务消息,这是我们实现分布式事务的依赖;另一个是它的消息消费支持“顺序消息”,同一个订单的消息可以被严格按顺序消费(先扣库存再创建订单这种顺序敏感的场景用得上);还有一个是它的延迟消息机制,可以方便地实现“下单后30分钟未支付自动关闭”这类业务逻辑,不需要自己写定时任务。

Kafka确实吞吐量更高,但它的吞吐亮点主要体现在日志类数据的高效处理,事务消息和顺序消息的实现不如RocketMQ成熟。RabbitMQ则更轻量,但它在消息堆积、高并发消费场景下的表现,不如RocketMQ这种以队列模型为中心的中间件。选型没有绝对的“最好”,关键是和你当前的业务形态匹配。我们当时的业务是电商系统,事务和顺序性需求强烈,RocketMQ几乎是顺理成章的选择。

4.2 消息可靠性与幂等消费

消息队列用起来简单,但真正生产环境里最容易出问题的恰恰是消息的可靠性。消息丢失有三种情况:生产端发送时丢失、消息队列存储时丢失、消费端处理时丢失。RocketMQ默认提供了多副本机制,Broker的数据在多个节点之间同步复制,单节点宕机不会导致消息丢失;生产端我们设置了同步发送模式,也就是producer.send()方法会等待Broker的确认,这个确认可能超时或失败,所以生产端还需要对发送失败的MQ消息做补偿——把失败的消息写入一张本地消息表,由定时任务扫描重发。

消费端丢失消息的场景,最典型的就是“先改数据库,再提交消费位点”,如果业务代码执行过程中抛异常,消息消费失败后RocketMQ会默认重试16次。这里有一个特别容易踩的坑:消费端业务逻辑必须做幂等控制,因为消息消费失败重试时,同一消息可能会被消费多次。比如“更新会员积分”这个消息,如果第一次执行成功后,正好在提交消费位点之前应用宕机了,重启后RocketMQ会再次投递这条消息,积分就可能被加两次。我们的做法是在消费端落一张“消息消费记录”表,以消息ID做唯一键,每次消费先查这个记录,存在就直接返回成功。

4.3 异步化后的链路追踪

服务变成异步之后,排查问题的难度陡增。以前是A调用B,出问题只要看A和B的日志;现在是A发消息、B异步消费、B可能再发消息给C,中间的调用链是“断”的。所以我们在第二个阶段接入了全链路追踪系统,技术选型是SkyWalking。每个请求进来时生成一个全局的traceId,通过HTTP头传递给下游的所有同步调用;异步场景下,生产者在发送消息时把traceId塞到消息的扩展字段里,消费者拿到消息后把traceId提取出来,继续挂在新的线程上下文里,这样一条业务链路无论是同步还是异步,都能在SkyWalking的拓扑图上完整串起来。

链路追踪上线后,我们找到了很多以前根本无法定位的性能问题。比如有次用户反馈“下单成功但等了很久才收到短信”,通过链路追踪很快看到,消息从MQ发出后,短信服务在读取模板配置时有两次多余的数据库查询,每次耗时约300ms,消费一条消息的总耗时将近800ms。把这些慢查询优化掉之后,短信的到达时间从秒级提升到毫秒级。链路追踪不是为“追时髦”上的,它直接决定了一个分布式系统能不能被有效运维。

5. 容量计算、部署与故障演练

分布式架构搭建完成之后,下一步就是确定每个服务应该部署多少实例、每台机器的配置调到什么水平。很多团队上分布式系统后的第一个问题不是功能跑不通,而是资源规划不合理:要么过度配置导致成本翻倍,要么配置不足导致上线就被流量打垮。我沿用了一套容量预估的方法:先确定核心接口的QPS目标,再根据单实例能够处理的QPS反推实例数量。这里面有个前提,每个服务在压测环境里要测出自己的单实例极限值,不能靠猜。

拿订单服务来说,压测结果显示一台4核8G的实例,在应用层加缓存、数据库走独立实例的前提下,单机极限是1000QPS左右。我们预估大促峰值是2000QPS,留出30%的冗余,目标容量就是2600QPS(2000乘以1.3),用2600除以1000,相当于需要3个实例。再加一个用于故障切换的备用实例,最终订单服务部署了4个实例,高峰期负载在60%左右,这个水位既能保证性能,又能给突发流量留出缓冲。集群内通过网关的负载均衡策略轮询访问,单实例宕机后流量会自动切到其他实例,整个过程用户无感知。

5.1 容量预估计算过程

我把商品服务的容量预估过程展开一下。商品详情接口是这个服务中最核心的读接口,平峰期QPS是1500,大促峰值会乘以4倍,到6000QPS。Redis缓存命中率在正常时候是90%,但大促刚开启的瞬间,大量第一次访问的请求会穿透缓存打到应用层,此时穿透率最高能到40%,也就是2400QPS打到应用。商品服务单实例压测极限是800QPS,那目标容量就是峰值2400乘以1.3,大约3120QPS,除以800等于4个实例,再加一个冗余,一共5个实例。

数据库的容量预估更看重连接数而不是QPS。每个应用实例默认数据库连接池是50,5个实例就是250个连接,数据库实例的最大连接数设置成300,预留一部分给后台管理工具和运维操作。连接数和QPS之间也要有关系:把每个接口的平均SQL执行时间统计出来,估算数据库并发执行的能力。我们的数据库采用一主两从架构,主库只承担写流量,读流量全部走从库,从库的CPU负载在高峰期控制在50%以内,这就是个比较安全的健康水位。

5.2 发布与回滚策略

分布式系统的发布比单机复杂得多,最大的变化是不能“下掉所有实例再上新版本”。我们采用的发布策略是滚动发布,每次只更新一个实例,等新实例健康检查通过后,再继续更新下一个实例,直到所有实例都完成版本切换。这就需要网关层的负载均衡支持主动摘除实例,发布开始时先把要更新的实例从负载均衡列表里摘掉,等更新完成并且健康检查通过后再加回来。这套流程已经写成了标准化的Jenkins流水线,开发只需要点击“构建”,系统会自动完成打包、镜像构建、滚动发布。

配置项和代码分开管理之后,回滚也变简单了。代码回滚就是重新构建上一个版本的镜像再滚动发布一次;配置回滚则直接在Nacos配置中心把配置改回去,不需要重发代码。但有两种情况需要特别注意:一是数据库结构变更不能跟随代码一起滚动发布,必须先执行数据库迁移脚本,再发布新版本代码,否则新代码启动时会因为缺字段报错;二是分布式环境下“回滚”要分成“应用代码回滚”和“数据补偿回滚”两步走,比如新版本多写了一张表,旧版本读不到,回滚后还要把数据补回去,这类数据补偿操作通常要提前写成SQL脚本或者补偿任务,避免人工手动执行误操作。

5.3 故障演练与复盘

架构上线前,团队组织了一轮故障演练,把几个最常见、最影响可用性的故障场景都过了一遍。演练清单包括:订单服务一个实例宕机、Redis集群某个主节点宕机、数据库主库宕机、消息队列Broker宕机、某一个下游服务响应突然变慢。每一步演练都要记录“从故障发生到业务恢复需要多少时间”以及“故障期间有多少请求受到影响”,这两项数据直接决定了系统的可用性水平。

演练暴露出两个问题。第一个是部分服务的连接池配置偏小,比如营销服务在正常负载下连接池只需要30个连接,但当它对应的下游服务变慢时,线程会长时间占用连接,导致其他请求拿不到连接,出现“连接池耗尽”的连锁故障。我们为此给每个核心服务的Feign客户端配置了独立的线程池,并且设置了合理的超时时间,例如连接超时1秒、读超时3秒,宁可快速失败让上游走降级逻辑,也不能无限期地占住线程等待。第二个问题是依赖了“Redis缓存自动过期再重建缓存”这个过程的客户端集中请求,一旦缓存同时过期,会导致瞬间全部穿透到数据库,我们在缓存层面加了一个分布式锁重建缓存,并给不同的缓存key设置了不同的过期时间,避免全局同时过期。

6. 常见问题排查与避坑实录

分布式系统的运营维护,跟单体时代的最大区别就是:出问题时你面对的是一堆机器、几十个日志文件、无数条调用链,如果排查思路不清晰,很容易被表面现象误导。这里列几个我们在线上真实遇到的典型问题,每个都对应着一种排查方法和预防措施。

第一个问题:服务调用偶尔超时,但不是每次都会失败。排查后发现原因是服务实例在发布过程中,旧实例下线时还在处理中的请求被强制中断,新实例又还没有完全就绪,导致网关转发出了一小段空窗期。解决方法是给服务实例增加优雅停机机制,收到关闭信号后先通知注册中心下线自己的服务地址,再等待正在处理的请求完成(最多等待15秒),最后才关闭进程。网关侧则配置了重试策略,当一次请求转发失败时,自动重试下一个可用实例。但要特别小心的是,写操作的接口不能盲目重试,比如扣款接口重试可能导致重复扣款,所以重试策略只应用在查询类接口上。

第二个问题:缓存击穿导致数据库瞬时压力剧增。原因是某个热点商品在直播中被反复推荐,大量新用户第一次访问,缓存里没有数据,全部打到数据库。解决方法是热点数据识别和Singleflight机制。我们在商品服务里加了一个“热点发现”模块,每秒钟统计一次各商品的访问量,瞬时访问量超过阈值的商品自动进入热点缓存池,把过期时间延长到2小时。同时引入Singleflight机制,当多个并发请求同时查询同一个缓存key时,只有一个请求真正去数据库加载数据,其他请求等待第一个请求的结果返回。这样即便出现缓存没有命中,数据库的压力也只会增加一次查询,而不是N次。

第三个问题:消息队列消息堆积严重,消费端无论如何也追不上生产速度。排查后发现不是消费能力不够,而是消费端逻辑里有连接外部系统的阻塞操作。积分服务消费每条消息时要调用会员系统的接口查询等级,这个接口单次耗时在500ms以上,而且没有超时控制。后来我们把外呼操作改成异步批量调用,消费线程不再等待外部响应,堆积量在半小时内被消化掉了。这个话题想提示的是:消费端的逻辑一定要保持轻量,凡是涉及外部依赖的操作,优先做批量聚合或者异步化处理,消费速率才能稳步提升。

6.1 线上故障排查速查表

我把自己经验中觉得最值得记录的排查套路整理成了一张表,供做分布式系统的同行直接参考:

故障现象优先排查方向常用工具/手段
接口响应变慢但QPS未增长数据库慢查询、锁等待、连接池耗尽MySQL慢查询日志、Druid连接池监控、jstack
部分请求成功部分请求失败负载均衡是否摘除异常实例、网关重试设置Nacos服务列表、网关日志、健康检查接口
缓存命中率突然下降缓存是否集中过期、key是否被误删、新版本代码是否改keyRedis监控大盘、缓存命中率指标
消息堆积不消费消费者是否宕机、消费逻辑是否有外部阻塞、是否有死信RocketMQ控制台、消费者日志、消费位点监控
数据库CPU突然升高慢SQL、缺少索引、全表扫描慢查询日志、performance_schema、EXPLAIN
服务消费超时异常上游服务线程池是否满、下游是否阻塞、网络问题链路追踪Trace、Sentinel监控、ping链路延迟

排查时的一个原则,也是我在这个项目里学到的教训是“先看监控数据,再翻日志,最后才是改代码”。分布式系统的信息分散在各个节点里,没有监控数据做方向指引,直接把日志翻一遍往往会淹没在海量信息里。我们的监控大盘覆盖了每个服务的QPS、响应时间、错误率、线程池活跃数、数据库连接池活跃数,以及JVM的内存和GC情况。任何指标出现异常,先确定是哪个节点、哪个服务、哪个时间点开始异常的,再顺着链路追踪去看那个时间段的完整调用链,定位速度会快非常多。

6.2 分布式环境下线程与日志

分布式环境下有两个细节经常被忽略,但线上出故障时它们往往是关键。第一个是线程池的命名和隔离。在Spring Boot项目里,如果我们创建的线程池不显式地指定一个有意义的名字,线程池里的线程会全部叫pool-N-thread-M,出现线程问题排查时完全不知道哪个业务在用这些线程。我给每个线程池都设置了业务前缀,比如订单处理线程池命名成order-process-thread-,库存扣减线程池命名为stock-deduct-thread-,这样用jstack抓线程栈时一眼就能分辨出是哪个业务逻辑在占用线程。线程池还要做好隔离,不同优先级或不同重要程度的业务不要共用同一个线程池,否则一个慢业务会把另一个核心业务的线程池都占满,和最初单机里的“互相拖累”问题没有任何区别。

第二个是日志的标准化。分布式系统里日志不打好统一格式,排查问题基本上等于睁眼瞎。我们规定每个服务里所有的业务日志必须包含traceId、用户ID、业务ID这几个字段,日志级别严格区分:业务异常打WARN,系统异常打ERROR,框架错误打ERROR。之所以强调这个,是因为线上用户反馈问题时,客服往往只能拿到一个粗略的时间点和描述,如果能通过用户ID在日志系统里搜索到对应的全部链路日志,问题定位会从“大海捞针”变成“按图索骥”。日志系统我们还接入了日志平台做集中式采集,各服务以文件方式输出日志到本地,再由agent采集到日志平台,支持按关键词全文检索和按时间范围过滤。

6.3 避免过度设计:什么东西不需要分布式

最后必须说一点容易被人忽略的实话:不是所有功能都适合拆成微服务,也不是所有数据都需要放进缓存和消息队列。我们这个系统的几个后台管理的报表功能,本来也有人提议拆成独立服务加分布式缓存,被我拦下来了。原因是这些功能近乎纯内部工具,日访问量极低,对实时性要求也不高,拿单体架构里的定时任务加报表导出完全能解决。拆成独立服务后,不仅要额外部署计算资源和应对数据同步问题,还会因为“跨服务查询数据”引入数据接口的排期和调试成本,完全是得不偿失。

分布式架构的美妙之处在于“伸缩”而不是“拆分”,如果只是为了让架构图好看就无限划分,最终只会增加运维负担。我的建议是:首先,每个服务都要能独立演进,如果两个服务永远是一起发布、一起扩容,那它们本来就应该是一个服务;其次,每个服务的数据必须能独立读取,如果一个服务的数据一定要通过另一个服务的接口才能查到,这个边界是失败的;最后,优先考虑集群化部署现有单体,通过负载均衡和缓存把它变成分布式,而不是一上来就把代码拆成微服务。这套“够用就好”的思路,是我在多次踩坑之后逐渐形成的。

在我个人的实际体验里,分布式架构带来的最大变化不是在技术上,而是在团队协作和故障处理的方式上。以前一个系统几个人一起改,互相影响的概率很大;现在服务边界清楚了,每个小组负责一块,发布也不互相牵制了。这类改造项目往往不是一劳永逸的,后续你可以在这个基础上继续做容灾多活、弹性伸缩、甚至Serverless化,但底子是否打得结实,取决于改造初期对业务边界的理解和那些“不做分布式”的克制。希望这篇记录能给你提供一些可复用的判断依据。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 2:36:50

S905L3B 机顶盒装 Armbian:完整改造实操

S905L3B 机顶盒装 Armbian:完整改造实操 【免费下载链接】amlogic-s9xxx-armbian Supports running Armbian on Amlogic, Allwinner, and Rockchip devices. Support a311d, s922x, s905x3, s905x2, s912, s905d, s905x, s905w, s905, s905l, rk3588, rk3568, rk339…

作者头像 李华
网站建设 2026/10/16 2:32:05

机器学习中向量与标量运算的常见陷阱与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华