微服务架构理论与原则


微服务架构理论与原则

何为微服务架构?

它是一种架构风格,旨在将一个大型、复杂的单体应用拆解成一组小型的、独立的服务。每个服务都围绕着特定的业务能力来构建,并且可以被独立地开发、测试、部署和扩展

它的核心思想可以总结为以下几点:

  1. 服务即组件 (Services as Components): 与传统的代码库(Library)组件不同,微服务是通过网络调用(如 REST API、gRPC)来交互的组件,这使得它可以跨语言、跨技术栈。
  2. 围绕业务能力组织 (Organized around Business Capabilities): 每个服务都应该是一个高内聚的业务单元,比如订单服务、用户服务、库存服务。这与传统按技术分层(如UI层、业务逻辑层、数据层)的团队组织方式截然不同。
  3. 去中心化 (Decentralized):
    • 技术去中心化: 每个服务可以选择最适合自身业务场景的技术栈(语言、数据库等),而不是被统一的技术栈锁定。
    • 数据去中心化: 每个服务拥有自己独立的数据库,避免了单体应用中“一个数据库走天下”的模式,保证了服务的自治性。
    • 治理去中心化: 团队可以自治地决定开发节奏、部署周期和技术选型,实现了真正的“You build it, you run it”。
  4. 基础设施自动化 (Infrastructure Automation): 由于服务数量众多,必须依赖高度自动化的CI/CD流水线、自动化测试和云原生基础设施(如容器化、服务编排)来高效地管理和部署。

它解决了什么问题,又带来了哪些新的挑战?

微服务架构主要是为了解决单体架构 (Monolithic Architecture) 在大规模、快速迭代的复杂应用场景下所暴露出的痛点:

  1. 降低了系统复杂性 (Reduced Complexity): 单体应用随着业务增长会变得异常臃肿,代码耦合严重,新员工难以理解,修改一处代码可能引发全局性的问题。微服务将复杂性分散到各个独立的服务中,每个服务的逻辑都相对简单、清晰。
  2. 提高了开发和部署效率 (Improved Development and Deployment Speed):
    • 单体架构中,任何微小的改动都需要重新编译、测试和部署整个应用,流程漫长且风险高。
    • 在微服务架构中,团队可以独立修改、测试和部署自己的服务,无需等待其他团队。这使得CI/CD能够真正落地,大大加快了功能的上线速度。
  3. 增强了技术异构性和灵活性 (Enabled Technological Heterogeneity): 单体应用一旦选定技术栈,后期很难更换。微服务允许不同的服务根据自身特点选择最合适的技术,比如计算密集型服务可以用Go或C++,而业务逻辑密集的可以用Java或Python。这使得团队可以拥抱新技术,而不是被历史技术债所束缚。
  4. 提升了系统的可扩展性 (Enhanced Scalability): 单体应用只能作为一个整体进行扩展,如果只有某个功能模块(如“秒杀”)成为瓶颈,我们也不得不复制整个应用,造成资源浪费。微服务允许我们只针对那些真正需要扩展的服务进行独立的、水平的扩展。
  5. 提高了容错性 (Increased Fault Isolation): 在单体应用中,一个模块的内存泄漏或bug可能导致整个应用程序崩溃。在微服务中,如果一个服务出现故障,只要有适当的熔断、降级机制,就不会影响到其他服务的核心功能,系统的“爆炸半径”被有效控制。

微服务架构并非银弹,它在解决老问题的同时,也引入了一系列源于分布式系统的复杂性和新挑战:

  1. 服务拆分原则的挑战 (Challenge of Service Decomposition):
    • 如何拆?: 这是实施微服务的第一个也是最难的挑战。如果拆分粒度过大,就退化成了“分布式单体”,没有享受到微服务的优势;如果粒度过小,服务间交互的开销会急剧增加,运维成本极高。
    • 拆分原则: 业界常用的拆分原则是基于领域驱动设计 (Domain-Driven Design, DDD) 中的限界上下文 (Bounded Context)。目标是做到“高内聚、低耦合”,即把关联紧密的业务逻辑和数据放在一个服务内,减少服务间的依赖。
  2. 服务治理的复杂性 (Complexity of Service Governance):
    • 服务发现 (Service Discovery): 服务A如何知道服务B的网络地址?当服务B动态扩缩容时,地址会变化。需要一个注册中心(如 Nacos, Consul, Eureka)来统一管理服务实例的地址。
    • 负载均衡 (Load Balancing): 当一个服务有多个实例时,如何将请求均匀地分发到这些实例上?这需要客户端或服务端的负载均衡策略。
    • 熔断、限流、降级 (Resilience Patterns): 网络是不可靠的,下游服务可能会变慢或宕机。我们需要引入像 Hystrix 或 Sentinel 这样的库来实现:
      • 熔断 (Circuit Breaking): 暂时切断对故障服务的调用,避免资源耗尽和连锁反应。
      • 限流 (Rate Limiting): 防止突发流量冲垮服务。
      • 降级 (Degradation): 在非核心服务不可用时,返回一个默认值或执行备用逻辑,保证核心业务不受影响。
  3. 分布式事务的难题 (Dilemma of Distributed Transactions):
    • 由于每个服务都有自己的数据库,一次跨多个服务的业务操作(如电商下单:创建订单、扣减库存、增加积分)无法通过传统的本地ACID事务来保证一致性。
    • 解决方案: 通常放弃强一致性,转而追求最终一致性 (Eventual Consistency)。常见的模式包括:
      • SAGA模式: 将长事务拆分为多个本地事务,每个本地事务都有一个对应的补偿操作。如果某个步骤失败,就依次调用前面已成功步骤的补偿操作来回滚。
      • TCC (Try-Confirm-Cancel): 对每个服务都实现 Try、Confirm、Cancel 三个接口,进行两阶段提交。
      • 基于消息队列的最终一致性: 这是最常见的模式。下单服务完成自己的本地事务后,向消息队列(如 Kafka, RocketMQ)发送一个“订单已创建”的事件,库存服务和积分服务订阅该事件并执行各自的逻辑。这种方式实现了服务间的解耦,但需要处理消息丢失、重复消费等问题。
  4. 监控和运维的挑战 (Challenges in Monitoring and Operations):
    • 日志分散: 一个用户请求可能会流经多个服务,每个服务都有自己的日志文件。排查问题时,需要将这些分散的日志聚合起来。通常使用 ELK (Elasticsearch, Logstash, Kibana) 或 EFK 技术栈来做集中式日志管理
    • 调用链追踪 (Distributed Tracing): 为了清晰地看到一个请求在分布式系统中的完整路径、耗时和依赖关系,我们需要引入像 Jaeger, ZipkinSkyWalking 这样的分布式追踪系统。
    • 度量聚合 (Metrics Aggregation): 需要一个统一的平台(如 Prometheus + Grafana)来聚合所有服务的健康状况、性能指标(CPU、内存、QPS、延迟等),并进行实时监控和告警。
    • 部署复杂: 管理几十上百个服务的部署、配置和版本控制,是一项巨大的挑战,必须依赖强大的自动化部署工具(CI/CD)和容器编排平台(如 Kubernetes)

服务拆分原则、服务治理、分布式事务、监控等

服务拆分原则 (Service Decomposition Principles)

我们遵循的核心原则是**“高内聚,低耦合”,具体落地的指导思想主要来源于领域驱动设计 (Domain-Driven Design, DDD)**。

  1. 基于限界上下文 (Bounded Context) 进行拆分
    • 什么是限界上下文?:DDD中的一个核心概念,它是一个业务边界。在这个边界内,领域模型(比如一个“商品”对象)有其唯一的、无歧义的含义。跨越这个边界,同样的词汇可能有完全不同的含义。
    • 举例:在“电商”这个大领域里,“商品(Product)”这个词在不同上下文有不同含义:
      • 商品中心(或叫后台管理上下文)里,它关心的是商品的SPU、SKU、规格、描述、图片等静态属性。
      • 交易中心(或叫订单上下文)里,它关心的是下单那一刻的商品快照价格、名称、购买数量。
      • 仓储中心(或叫库存上下文)里,它只关心商品的SKU ID和库存数量。
    • 如何拆分:我们就应该将这三个上下文分别拆分为三个独立的微服务:商品服务订单服务库存服务。每个服务维护自己上下文内的模型和数据,边界非常清晰。
  2. 其他辅助拆分原则
    • 按业务能力拆分 (Decompose by Business Capability):这是DDD思想的一种简化应用。识别出公司或产品有哪些核心的业务能力,比如用户管理、支付能力、通知能力等,然后将每个能力封装成一个服务。
    • 关注变更频率:将那些经常一起变更的业务逻辑放在同一个服务里,而将变更频率不同的逻辑分离开。
    • 关注团队结构(康威定律):系统的架构往往会反映出开发这个系统的组织的沟通结构。我们可以反向利用这个定律,将一个独立的、小型的开发团队(比如一个Squad)对应到一个或几个微服务上,让他们可以独立负责、快速迭代。

关于拆分粒度

  • 粒度过大:服务内部逻辑复杂,耦合严重,开发效率低,接近单体。
  • 粒度过小:服务数量爆炸式增长,服务间交互的网络开销和运维成本剧增,分布式事务问题变得更棘手。
  • 经验法则:一个好的起点是,一个服务应该可以在两周内被一个小型团队完全重写。如果一个服务大到没人敢动,那它可能太大了。

服务治理 (Service Governance)

服务治理就是解决分布式环境下服务生命周期管理的一系列问题。

它主要包含以下几个方面:

  1. 服务发现 (Service Discovery)
    • 问题:在云原生和弹性伸缩的环境下,服务的IP地址和端口是动态变化的。服务A不能把服务B的地址写死在配置文件里。
    • 解决方案:引入注册中心 (Registry Center)
      • 服务注册:服务B启动时,会把自己的IP、端口等信息“注册”到注册中心,并定期发送心跳来表明自己还“活着”。
      • 服务发现:服务A想调用服务B时,它会去注册中心“查询”服务B的地址列表,然后通过负载均衡策略选择一个实例进行调用。
    • 主流工具:Nacos, Consul, Eureka, Zookeeper。
  2. 服务容错 (Resilience)
    • 问题:分布式系统中,网络延迟、服务宕机是常态。一个核心原则是:任何一次网络调用都可能失败。我们必须防止单个服务的故障导致整个系统的连锁崩溃(即“雪崩效应”)。
    • 解决方案(三板斧)
      • 熔断 (Circuit Breaking):当服务A发现调用服务B的失败率超过一定阈值时,会自动“跳闸”,在接下来的一段时间内,所有对服务B的调用都会立即失败并返回一个降级结果,而不会再发起网络请求。这给了服务B恢复的时间,也保护了服务A的资源不被耗尽在无用的等待上。
      • 降级 (Degradation):当服务B不可用时,服务A不应该卡死或崩溃,而是执行一个“备用逻辑”。比如,电商网站的商品推荐服务挂了,我们可以暂时不显示推荐栏,或者显示一个静态的、预先准备好的推荐列表。核心是保证主流程可用。
      • 限流 (Rate Limiting):为了防止突发流量(如秒杀、恶意攻击)冲垮某个服务,我们需要在服务的入口处限制其在单位时间内能够处理的请求数量。常见的限流算法有令牌桶、漏桶算法。
    • 主流工具:Sentinel, Hystrix, Resilience4j。
  3. 负载均衡 (Load Balancing)
    • 问题:一个服务通常有多个实例,客户端如何决定调用哪一个?
    • 解决方案
      • 服务端负载均衡:如使用Nginx、F5等硬件或软件,请求先到达负载均衡器,再由它分发给后端的服务实例。
      • 客户端负载均衡:客户端(服务A)从注册中心获取到服务B的所有实例列表后,在客户端内部根据某种策略(如轮询、随机、加权响应时间)选择一个实例来调用。Ribbon就是典型的客户端负载均衡器。

分布式事务 (Distributed Transactions)

示例问题:一个操作需要同时修改订单和库存两个数据库,你怎么保证数据的一致性?

由于每个服务都有自己独立的数据库,我们无法使用传统关系型数据库的ACID事务来保证跨多个服务操作的原子性。我们必须接受一个事实:在分布式系统中,强一致性非常昂贵且难以实现,我们通常追求的是最终一致性 (Eventual Consistency)

这意味着系统允许在短时间内存在数据不一致的状态,但最终会通过某种机制自我修复,达到一致。常见的实现模式有:

  1. SAGA模式
    • 核心思想:将一个长的分布式事务,拆分成多个由各个服务执行的本地事务。每个本地事务都有一个对应的补偿事务 (Compensating Transaction)
    • 执行流程:顺序执行每个本地事务。如果某个事务失败,SAGA协调器会依次调用前面所有已成功事务的“补偿事务”,以回滚整个过程。
    • 举例(下单)
      1. Try: 创建订单 (T1) → 扣减库存 (T2) → 扣减积分 (T3)
      2. Compensation: 取消订单 (C1) ← 增加库存 (C2) ← 增加积分 (C3)
      3. 如果T3失败,则依次执行C2和C1。
    • 优点:业务流程清晰,易于理解。
    • 缺点:补偿逻辑开发复杂,且不保证隔离性(在回滚前,其他请求可能看到中间状态)。
  2. 基于消息队列 (MQ) 的最终一致性
    • 核心思想:这是业界最流行的方式。服务之间不直接RPC调用,而是通过异步消息来解耦。
    • 执行流程
      1. 订单服务执行本地事务,成功创建订单。
      2. 同一个本地事务中,向一个“本地消息表”插入一条消息(如“订单已创建”)。
      3. 一个独立的任务会定时扫描这个本地消息表,将消息投递到消息队列(如Kafka, RocketMQ)。(这一步是为了保证“业务操作”和“发消息”的原子性)
      4. 库存服务和积分服务订阅该消息,收到后执行各自的本地事务。
    • 优点:服务间高度解耦,吞吐量高。
    • 缺点:依赖消息队列的可靠性,需要处理消息重复消费、消息乱序等问题,整个业务流程被异步化,调试和追踪更复杂。
  3. TCC (Try-Confirm-Cancel) 模式
    • 核心思想:一种两阶段提交的变种。需要业务方为每个操作都实现三个接口:
      • Try:预留资源阶段。比如冻结库存、冻结积分。
      • Confirm:确认执行阶段。如果所有服务的Try都成功了,则依次调用所有服务的Confirm,真正完成业务操作(扣减库存、扣减积分)。
      • Cancel:取消执行阶段。如果任何一个服务的Try失败了,则依次调用所有服务的Cancel,释放预留的资源。
    • 优点:一致性比SAGA强,接近ACID。
    • 缺点:对业务的侵入性非常强,开发成本极高,很少在实际中大规模使用。

监控 (Monitoring)

当微服务系统出了问题,怎么快速定位是哪个服务、哪行代码的错?

微服务的监控比单体复杂得多,因为问题可能出在任何一个服务或它们之间的网络调用上。我们需要建立一个立体的、全方位的可观测性 (Observability) 平台,它主要由三驾马车组成:

  1. 集中式日志 (Logging)
    • 做什么ELK/EFK技术栈,将所有微服务的日志(应用日志、系统日志、访问日志等)都采集、聚合到一个统一的平台。
    • 关键点:必须在请求的入口处生成一个全局唯一的Trace ID,并让它在整个调用链中透传下去。这样,我们才能在Kibana中通过一个Trace ID串联起一个请求在所有服务中的日志,形成完整的“故事线”。
  2. 分布式追踪 (Tracing)
    • 做什么:它专注于记录一个请求的端到端旅程。它能清晰地展示出:请求流经了哪些服务?每个服务处理了多长时间?服务间的依赖关系是怎样的?
    • 如何实现:通过在服务调用时注入和传递上下文信息(如Trace ID, Span ID),并将这些信息上报给追踪系统。
    • 可视化效果:通常会生成一个火焰图 (Flame Graph),让我们可以非常直观地看到整个调用的瓶颈在哪里。
    • 主流工具:Jaeger, SkyWalking, Zipkin。它们都遵循了OpenTracing规范。
  3. 度量聚合 (Metrics)
    • 做什么:它关注的是可聚合的数值型数据。比如:
      • 系统层面:CPU使用率、内存、磁盘IO、网络流量。
      • 应用层面:QPS(每秒请求数)、RT(响应时间)、错误率、JVM状态(GC次数、堆内存)等。
    • 如何实现:服务通过一个标准接口(Endpoint)暴露自己的度量数据,监控系统定期来抓取(Pull)这些数据,并存入时间序列数据库。
    • 可视化与告警:使用图表(Dashboard)来展示这些指标的趋势,并设置告警规则(如“P99响应时间连续5分钟超过500ms”),当规则被触发时,通过短信、电话、钉钉等方式通知开发人员。
    • 主流工具Prometheus (采集与存储) + Grafana (可视化与告警) 是目前的事实标准。

CAP理论BASE理论

CAP 理论 (CAP Theorem)

CAP 理论由 Eric Brewer 教授提出,它指出,在一个分布式计算系统中,你不可能同时完美地满足以下三个特性,最多只能满足其中两个

  1. C - 一致性 (Consistency)
    • 定义:指强一致性。当一次写操作成功后,任何后续的读请求都必须能读到这个最新的数据。无论客户端连接到哪个节点,看到的数据都应该是一致的。
    • 通俗理解:所有节点在同一时间拥有相同的数据副本。就像一个银行账户,你在ATM机存入100元后,你的手机银行App应该立刻显示更新后的余额,而不是旧的。
  2. A - 可用性 (Availability)
    • 定义:系统中的任何一个健康的节点,都必须能够在有限的时间内响应客户端的每一个请求,不能出现超时或错误。
    • 通俗理解:系统7x24小时服务,永不宕机。只要我向系统发出请求,它就必须给我一个响应(但不保证这个响应里的数据是最新的)。
  3. P - 分区容错性 (Partition Tolerance)
    • 定义:分布式系统中的节点被部署在不同的网络区域(机房、地理位置),节点间的网络通信可能会中断,形成“网络分区”。分区容错性指,即使网络分区发生了,导致部分节点之间无法通信,整个系统仍然能够继续对外提供服务。
    • 核心要点:在今天的互联网分布式系统中,网络故障是常态,而不是例外。因此,P 是一个必须满足的前提条件,而不是一个可选项。架构师是无法选择放弃P的。

真正的取舍:当网络分区 (P) 发生时,你必须在 C 和 A 之间做出选择。

  • 选择 CP (放弃 A):为了保证数据一致性,当网络分区发生时,主节点(拥有最新数据)无法将数据同步给其他节点。为了避免其他节点返回旧的、不一致的数据,系统可能会选择拒绝来自这些节点的读写请求。这时,系统就失去了部分可用性。
  • 选择 AP (放弃 C):为了保证可用性,当网络分区发生时,即使节点之间无法通信,每个节点仍然可以独立地响应客户端请求。但这可能导致一个节点接受了新的写请求,而其他节点毫不知情,从而造成了节点间的数据不一致。

BASE 理论 (BASE Theory)

BASE 理论是 CAP 理论中 AP 方案的延伸和工程实践产物。它不是像数据库ACID那样追求强一致性,而是提出了一套适应大规模分布式系统的、以可用性为中心的指导思想。

  1. BA - 基本可用 (Basically Available)
    • 定义:系统允许损失部分可用性。这体现在两方面:
      • 响应时间损失:正常情况下0.5秒返回,现在因为系统故障,可能需要2-3秒。
      • 功能损失:在电商大促时,为了保证核心的交易流程,可以暂时关闭一些非核心功能,比如商品评论、积分系统等(即服务降级)。
  2. S - 软状态 (Soft State)
    • 定义:允许系统中的数据存在中间状态,并且这个状态不影响系统的整体可用性。这个中间状态是相对于“硬状态”(数据必须时刻保持一致)而言的。
  3. E - 最终一致性 (Eventually Consistent)
    • 定义:这是BASE理论的核心。系统不要求数据时刻保持强一致,但承诺在经过“一段时间”后,数据最终会达到一致的状态。这个“一段时间”被称为“不一致性窗口”。

总结:BASE 理论的核心思想是,我们为了获得更高的系统可用性和扩展性,愿意牺牲强一致性,转而接受数据在一段时间内的不一致。


幂等性是什么?

定义:在数学和计算机科学中,幂等性(Idempotency)指一个操作执行一次执行N次所产生的**结果(或对资源造成的影响)**是完全相同的。

在RESTful API(即HTTP协议)的上下文中,幂等性是设计健壮、可预测的API的关键。它的主要作用是支持在网络不稳定的情况下进行安全的“重试”

当客户端发送一个请求后,可能会因为网络超时等原因没有收到服务器的响应。这时客户端就陷入了困境:请求是丢失了,还是服务器已经处理了只是响应丢失了?

  • 如果操作是幂等的,客户端可以毫无顾忌地重新发送同一个请求,而不用担心会产生意料之外的副作用(比如重复创建数据)。
  • 如果操作是非幂等的,客户端就不能轻易重试,否则可能导致严重问题(比如重复下单、重复扣款)。

HTTP方法与幂等性

不同的HTTP动词(Verb)天生就具有不同的幂等性特征:

HTTP 方法 动作 是否幂等? 解释
GET 查询资源 GET /users/123 执行多少次,都只是获取该用户的信息,不会改变服务器状态。
HEAD 获取资源元信息 和GET一样,是只读操作。
OPTIONS 获取支持的方法 同样是只读操作。
PUT 完整更新/替换资源 PUT /users/123 并附带完整的用户信息,执行一次是将用户123更新为新数据,执行N次结果还是一样。
DELETE 删除资源 DELETE /users/123 执行一次是删除该用户,执行N次,结果仍然是“该用户不存在”(虽然第二次以后可能返回404,但服务器的最终状态是一致的)。
POST 创建子资源 POST /users 执行一次是创建一个新用户(如ID为124),再执行一次会创建另一个新用户(如ID为125)。
PATCH 部分更新资源 不一定 PATCH 的幂等性取决于操作本身。PATCH /users/123 更新用户名为 “Tom” 是幂等的。但 PATCH /posts/1/likes 执行“点赞数+1”的操作就不是幂等的。

幂等性的错误设计

一、非幂等的 PUT 接口是严重的设计问题

如果一个 PUT 接口,执行一次和多次的状态不一样,那绝对是这个接口设计有严重的问题

这不仅仅是一个“不好的实践”,而是直接违反了 HTTP 协议的规范 (RFC 7231)

规范中对 PUT 方法的定义是:用请求中的负载(payload)完整替换目标资源的状态。

正确的设计:
客户端发送 PUT /users/123 请求,请求体为 {"name": "Alice", "email": "alice@example.com"}

  • 第一次执行: 服务器在数据库中找到 ID 为 123 的用户,将其所有字段更新为请求体中的数据。如果用户不存在,则创建该用户。
  • 第二次执行: 服务器再次找到 ID 为 123 的用户,并再次将其所有字段更新为请求体中的数据。

无论执行多少次,ID 为 123 的用户的最终状态都是 {name: "Alice", email: "alice@example.com"}。这就是幂等性。


错误的设计 (Non-idempotent PUT):
假设有一个设计师错误地用 PUT 来实现“增加用户积分”的操作。
客户端发送 PUT /users/123/points 请求,请求体为 {"points_to_add": 10}

  • 第一次执行: 服务器为用户123增加了10个积分。
  • 第二次执行: 服务器再次为用户123增加了10个积分。

这就破坏了幂等性,并且会带来灾难性后果。如果客户端因为网络超时而重试了这个请求,就会导致用户的积分被错误地增加了两次。


二、在 GET 请求中修改数据

场景:有一个获取文章阅读量的GET接口,那么每次请求这个接口时,接口方法内部就会修改数据库的阅读量,使其加一。

这种做法违反了GET方法最核心的两个原则:安全性 (Safety)幂等性 (Idempotency)

1. 违反了安全性原则 ❌

在HTTP协议中,一个方法如果是**“安全”的,意味着执行它不会对服务器上的资源状态产生任何改变(或称“副作用”)**。GETHEAD方法被规定为必须是安全的。

  • 你的场景GET接口修改了数据库中的阅读量,这显然是一个写操作,改变了资源的状态。
  • 后果:这破坏了HTTP规范和所有Web基础设施(浏览器、代理、爬虫等)对GET请求的基本假设。就像你去图书馆的目录电脑上查一本书的位置,结果你每查一次,电脑就把这本书换到一个新书架上一样,这是混乱且不可预测的。

2. 违反了幂等性原则 ❌

我们已经知道,幂等性指执行N次和执行1次的效果相同。安全的GET方法天然是幂等的。但你这个GET接口因为每次都会+1,所以执行N次会导致阅读量增加N,这显然不是幂等的。

3. 导致了实际的技术灾难:缓存失效与数据污染 💣

这是在工程实践中最致命的问题。

  • 缓存机制:浏览器、CDN、反向代理(如Nginx)等都会默认GET请求是安全的,并积极地缓存其响应结果以提高性能。
  • 失效场景
    1. 第一个用户访问 GET /articles/123。服务器返回文章内容和"views": 101,同时数据库中的阅读量变为101。
    2. 这个响应被CDN缓存了(比如缓存5分钟)。
    3. 在接下来的5分钟内,又有100个用户访问了同一个URL。
    4. 这100次请求全部被CDN直接响应,返回的都是缓存的{"views": 101}。它们根本不会到达你的服务器
    5. 结果:真实阅读量应该是201,但数据库里仍然是101,显示给用户的也一直是101。你的计数功能完全失效了。

4. 引发不可控的调用

  • 搜索引擎爬虫 (Crawlers):像Googlebot这样的网络爬虫会通过GET请求来抓取和索引网页内容。它们会把你的文章阅读量刷得虚高。
  • 浏览器预加载 (Prefetching):一些现代浏览器为了提升用户体验,可能会在后台预先加载用户可能会点击的链接。这些预加载的GET请求也会意外地增加阅读量。

如何保证接口的幂等性?有哪些实现方案?

1. Token 机制(令牌机制)

这是一种非常通用的方案,尤其适用于防止表单重复提交等场景。它将一次完整的业务请求分为了两个步骤:

  1. 第一步:客户端获取Token
    • 在进入业务操作页面(比如确认订单页面)时,客户端需要先向服务器发起一个“前置请求”,以获取一个本次操作专用的、唯一的token
    • 服务器生成这个token后,会将其存储在后端的存储系统里(通常是Redis,因为它速度快且支持设置过期时间)。
  2. 第二步:客户端携带Token发起业务请求
    • 当用户点击“提交订单”按钮时,客户端会将上一步获取的token随同业务数据一起发送给服务器。
    • 服务器收到请求后,会去Redis中查找这个token
      • 如果找到了:说明这是第一次请求。服务器会立即删除这个token,然后继续执行业务逻辑。
      • 如果找不到了:说明这个token已经被用过(或者已过期),服务器就直接拒绝本次请求,返回一个“请勿重复提交”的提示。

流程图:

  • 优点:
    • 逻辑通用,可以封装成一个通用服务,不与具体业务逻辑耦合。
  • 缺点/挑战:
    • 需要额外的一次请求来获取token,增加了网络交互。
    • 原子性问题:在高并发下,“检查token”和“删除token”这两个操作必须是原子性的。否则,两个请求可能同时检查到token存在,然后都去执行业务。通常使用Redis的SET key value NX EX seconds命令或Lua脚本来保证原子性。

2. 唯一ID机制(客户端生成)

这种方案将生成唯一标识的责任交给了调用方(客户端),简化了服务端的逻辑。

  • 实现流程:
    1. 调用方在发起请求前,先自行生成一个全局唯一的ID(request_ididempotency_key),通常使用UUID算法生成。
    2. 将这个request_id放在请求的Header或Body中,发送给服务器。
    3. 服务器收到请求后,首先根据这个request_id去查询一个存储系统(如Redis或数据库)。
      • 如果记录不存在:说明是新请求。服务器将这个request_id作为key存入存储系统(可以设置一个过期时间),然后执行业务逻辑。
      • 如果记录已存在:说明是重复请求,直接丢弃,并返回上一次的处理结果(如果需要的话)。
  • 优点:
    • 实现简单,将唯一性生成的压力分散到了客户端。
    • 服务端逻辑相对轻量。
  • 缺点/挑战:
    • 依赖调用方遵循协议正确地生成和传递唯一ID。
    • 同样需要保证“检查ID”和“写入ID”这两个操作的原子性。

3. 数据库唯一键约束

这是最简单、最可靠的防重方式之一,它利用了数据库底层UNIQUE KEY的特性来保证幂等性。

  • 实现流程:
    1. 在你的业务表中,找到一个或多个能够唯一标识一笔业务的字段。例如,在订单表中,order_no(订单号)就是天然的唯一标识。
    2. 为这个字段(或字段组合)在数据库层面添加一个唯一键约束 (UNIQUE KEY)
    3. 当业务请求过来需要插入数据时,直接执行INSERT操作。
      • 第一次请求: 插入成功。
      • 后续的重复请求: 再次尝试插入同样order_no的数据时,数据库会因为违反了唯一键约束而直接抛出异常。
    4. 你的代码在捕获到这个特定的数据库异常后,就知道这是一个重复请求,然后可以进行相应的处理(比如返回成功或提示重复)。
  • 优点:
    • 实现成本极低,无需引入额外的组件(如Redis)。
    • 可靠性极高,由数据库保证了最终的一致性。
  • 缺点/挑战:
    • 只适用于那些有明显业务唯一标识的“插入”场景。对于“更新”操作,它就无能为力了。
    • 与业务逻辑强耦合,不具备通用性。

  目录