系统设计:经典解决方案
系统设计:经典解决方案
一、数据安全
从这节课开始,我们针对架构设计中的一些常见场景和问题,讲解一些经典解决方案,这些方案既可以用于工作,也可以用于面试。这节课我们来讲数据安全。在架构设计中,数据的安全性非常重要,特别是一些敏感数据或者隐私数据,比如密码、手机号码等,如何来保证数据在传输、处理、存储过程中的安全性,有一套通用的解决方案,今天我们就来讲讲。在讲解之前,我们先留一个面试题给你:如果你是一个后端工程师,正在设计后端系统的架构,那么,如何保证密码和手机号码的安全性?
1. 加密存储
前有CSDN,后有人人网,都是因为明文存储密码等隐私信息,导致被黑客脱库后,大量用户的密码被泄露。因此,隐私数据一定要加密之后才能存储到数据库,这应该是在架构设计和开发中的一条铁律。常用的加密算法有哈希算法、对称加密算法和非对称加密算法。不同的隐私数据的应用场景不同,因此安全策略也不同,使用的加密算法也不同。接下来,我们简单介绍一下这三种类型的加密算法。
(1)哈希算法
哈希算法我们在《数据结构与算法之美》中讲过,它其实就是一个复杂的数学函数,输入任意长度的数据(比如密码“123456”),经过计算之后,得到一段固定长度的、看起来完全随机的字符串(比如SHA-256算出来是“8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92”)。哈希算法是一种单向的加密算法,因为这计算的过程中,丢失了大量信息,所以理论上无法从结果反推回输入。它就像是给数据生成一个独一无二的“指纹”。这个特性完美契合了密码存储的需求。我们并不需要知道用户的密码是什么,只需要在登录时验证他输入的密码是否正确。
早期我们一般使用MD5或者SHA-1哈希算法,对密码进行加密之后,再存储到数据库。虽然密码无法反推解密,但黑客可以把常用密码和他们的哈希值提前算好,做成一个巨大的字典(叫做彩虹表)。拿到你的密码的哈希值之后,直接去表里一查,原始密码就找到了。
怎么解决彩虹表攻击呢?我们可以给密码“加盐”。盐(salt)是一个随机字符串。我们在计算哈希值时,不是直接 hash(password),而是 hash(salt + password) 或者 hash(password + salt),然后把盐和哈希值一起存到数据库里。在验证用户密码时,用同样的盐和用户输入的密码,计算哈希后进行对比。即使两个用户密码相同,因为他们的盐不同,最终的哈希值也完全不同。黑客必须为每个可能的“盐”单独预计算一张彩虹表,这个计算成本高到无法实现,彻底废掉了彩虹表的攻击方式。
除此之外,为了避免暴力破解,我们还可以故意采用慢速的哈希算法。如果哈希算法计算速度比较快,黑客可以快速地在短时间内生成大量的彩虹表,进行碰撞对比破解。但是,如果哈希算法比较慢,计算成本增加,这种暴力破解的成本就提高了很多。像bcrypt、scrypt、Argon2 这些现代密码哈希算法被设计出来就是故意“慢”且“耗资源”的。它们内部有工作因子这个参数,可以人为调整计算所需的时间和内存资源。比如bcrypt,你可以设置工作因子为12,它可能就需要几百毫秒来算一次,而SHA-256可能只需要几微秒。这个速度差别对用户登录无感,但对需要尝试数十亿次密码的暴力破解来说,成本就成了天文数字。
(2)加密算法
尽管用户密码可以使用哈希算法来加密,但是,像手机号码这类隐私数据,加密之后存储在数据库中,业务上可能需要还原后再使用,比如拿来发送短信,所以,使用的加密算法必须支持解密,因此,哈希算法就不适用了。这个时候,我们需要真正的加密算法,加密算法分为对称加密算法和非对称加密算法。
对称加密算法(如AES)的加密和解密使用同一把密钥。算法是公开的,安全性完全依赖于密钥的保密。你可以想象一下,如果两个系统需要进行网络通信,通信的内容需要加密传输,如果使用对称加密算法,为了保证密钥的安全性,需要线下(非网络传输的方式)在一个系统生成密钥并传递给另一个系统。但是,如果有众多客户端需要跟后端系统进行安全通信,这种线下传递密钥的方式就无法做到了,而线上传递又不安全,有可能被网络劫持,这个时候,就需要用到非对称加密算法了。
非对称加密算法(比如RSA)则有一对钥匙:公钥和私钥。公钥可以公开;私钥需要严格保密。用公钥加密的数据,只能用对应的私钥解密。用私钥加密的数据,只能用对应的公钥解密。非对称加密算法解决了密钥分发问题,你可以把公钥扔得到处都是,谁想给你发加密信息,就用你的公钥加密,只有你拿着私钥能解密。私钥永远不需要共享。即便黑客窃取到了加密之后的数据和公钥,没有私钥,解密不了数据。使用非对称加密最经典的例子就是 HTTPS,我们待会就会讲到。
相较于对称加密算法,非对称加密算法的计算速度要慢几个数量级,一般不用来直接加密大量数据。在手机号加密存储的场景中,我们最常用的是对称加密算法AES,因为密钥不需要在网络中传输。
2. 密钥管理
从上述的讲解,我们可以发现,不管是对称加密算法还是非对称加密算法,密钥的安全存储至关重要,密钥一旦泄露,数据将无安全性可言。那么,密钥到底应该存储在哪里呢?
硬编码到代码中肯定是不可行的,放到本地配置文件(比如application.yaml)呢?当然也不可行,因为它跟硬编码到代码差异不大,也会一起上传到代码仓库,所有的开发者都能查看,容易泄露。当然,放到启动参数或者环境变量,会更好一点,因为这样的话,只有运维人员可以查看。其实,更好的一个方法是,存储到配置中心,并且是加密存储(大多数配置中心都支持),通过配置中心的权限控制功能,控制只有有限的人员管理密钥!
实际上,对于更高安全级别的密钥,我们还可以使用密钥管理服务(KMS)。KMS是一个专门用于生成、存储、轮换、禁用、销毁密钥的系统,如阿里云KMS。它的核心思想是实现密钥与应用的分离。我们的应用程序不再持有密钥本身,而是在需要加密或解密时,通过API调用去请求KMS服务来完成操作。KMS提供了严格的访问控制和审计日志,确保每个密钥的使用都被记录和监控。此外,它还能帮助我们定期自动轮换密钥,即使某个旧密钥不慎泄露,影响范围也能被控制在有限的时间内。通过引入KMS,我们将最核心的密钥安全交给了专业系统来保障,极大地提升了整体架构的安全性。
3. 安全传输
当我们通过网络传输数据时,传统的 HTTP 协议存在严重的不安全性:HTTP 是明文通信,数据在客户端和服务器之间传输时,经过的任何一个路由节点或网络设备(如路由器、防火墙)都可以被轻松截获和查看。这意味着你的密码、信用卡号、聊天记录等敏感信息,对攻击者来说是透明的。攻击者不仅可以窃听,还可以在中途修改通信内容。例如,在一个软件下载的过程中,攻击者可以将正常的软件替换成包含病毒或木马的版本。
HTTPS 就是为了彻底解决这些问题而生的。
简单来说,HTTPS 是在应用层(HTTP)和传输层(TCP)之间增加了一个安全层(SSL/TLS)。这个安全层在建立 TCP 连接之后、传输 HTTP 数据之前,先进行一系列的安全“握手”操作。握手成功后,所有的 HTTP 数据都会被这个安全层加密后再交给 TCP 传输。其中,SSL(Secure Sockets Layer)和 TLS(Transport Layer Security)是协议名,你可以理解为 SSL 是旧版本,TLS 是新版本。现在普遍使用的是 TLS,但大家习惯上仍称之为 SSL。
关于HTTPS的详细交互过程,我们在计算机网络课程中有详细的讲解,这里就不赘述了。
4. 最后总结
再回到开篇的面试题,要保证用户手机号和密码的安全,首先,客户端跟服务器之间的通信必须使用HTTPS,保证用户的手机号码和密码在传输的过程中不被泄露,接下来,数据抵达服务器端。密码和手机号的安全策略是不同的,我们需要区分对待。
对于密码,我们使用不可逆的哈希算法进行单向加密。单纯的哈希算法是不够的,因为黑客可以使用彩虹表进行反向查询。我们必须加盐(Salt)。除此之外,还可以使用 bcrypt、scrypt 或 Argon2等特殊的慢速算法替代快速的SHA-256算法,增加暴力破解的成本。
对于手机号,我们需要可逆的加密,比如AES。对于加密算法来说,密钥的管理是重点,KMS是专业的密钥管理方案,当然,对于小型应用,也可以使用配置中心、环境变量、启动参数等。
二、超时重试
在分布式系统中,由于系统之间的调用需要经过网络,网络抖动、系统负载暂时过高,都会导致调用可能发生一过性的超时。为了提高调用的成功率,重试变成了抵抗这种调用超时的有效手段,是一个非常常用的设计策略。现在很多框架、中间件、基础系统,都内置了超时重试的功能。但是,重试并不是没有代价的,错误的设置重试可能会导致请求风暴或者业务逻辑错误(比如,重复执行两次下单操作)。
作为架构师,你在设计系统的时候,是否有考虑过超时重试对业务造成的影响,是否有审查过超时重试策略是否正确设置?这节课我们就来讲下超时重试!
1. 超时重试
网络超时导致的重试一般都是在框架层面中实现的。我们通过一个具体的例子来看下,各个框架都是如何处理超时重试问题的。假设我们有一个“用户下单”接口,它的简化调用链是这样的:
- 用户请求首先到达 Nginx(作为反向代理和负载均衡器)
- Nginx 将请求转发给后端的 SpringBoot服务(API-Gateway)
- SpringBoot通过Dubbo RPC调用Dubbo微服务或者使用OpenFeign调用SpringBoot微服务
- 微服务处理业务逻辑时,会调用 Redis(查询缓存/扣减库存等)
- 微服务也会调用 MySQL(持久化订单等)
- 最终可能发送一条消息到 RocketMQ(触发后续如积分增加等异步操作)
这个过程中的每一步都可能因为网络、性能、故障等原因出现超时或错误,而每个组件都可能有自己的重试逻辑。层层重试,如果设计不当,非但不能提升系统可用性,反而可能放大问题。接下来,我们看以上涉及到的各个框架、中间件、基础系统它们的重试策略。
(1)Nginx
Nginx作为高性能的反向代理和负载均衡器,其超时重试机制设计得十分灵活。Nginx的超时配置主要包括三个方面:连接超时(proxy_connect_timeout)、发送超时(proxy_send_timeout)和读取超时(proxy_read_timeout)。这些超时参数分别控制建立连接、发送请求和接收响应的最大等待时间。当这些超时发生时,Nginx可以根据proxy_next_upstream指令的配置,决定是否将请求重试到upstream中的其他服务器。
通过proxy_next_upstream指令,我们可以指定Nginx在哪些情况下需要重试请求。默认情况下,Nginx只在error(error特指的是网络连接、传输或协议层面的失败,而不包括后端应用返回 HTTP 出错状态码)和 timeout情况下重试请求,但也可以配置在特定HTTP状态码(如500、502、503、504)时重试。对于非幂等请求(如POST、DELETE、PUT、PATCH),默认情况下Nginx不会重试,对于幂等请求(如GET、HEAD、OPTIONS),默认情况下Nginx进行超时重试。
Nginx还提供了两个重要的重试参数:proxy_next_upstream_tries 限制最大重试次数,proxy_next_upstream_timeout 限制所有重试尝试的总时间(即多次重试的时间总和)。这种设计防止了因持续重试导致的请求堆积和资源耗尽。默认情况下,这两个值设置为0,表示无限制,为什么可以设置为无限制,是因为Nginx的重试,是切换到 upstream 中的另一台服务器,而不是将请求重复转发给同一台后端服务器。
http {
upstream backend {
server backend1.example.com:8080;
server backend2.example.com:8080;
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_connect_timeout 10s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
proxy_next_upstream_timeout 10s;
proxy_next_upstream_tries 3;
}
}
}(2)Dubbo
Dubbo的重试行为跟容错策略紧密相关。其实,在「服务发现」那一节课中,我们提到过Dubbo的集群容错策略。我们在重温一下。
- Failover(失败自动切换):当调用失败时,自动切换到其他实例重试。这是最常用的策略,特别适合读操作。注意:写操作需谨慎使用,避免重复提交(需服务端幂等)。
- Failfast(快速失败):只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
- Failsafe(失败安全):调用失败后仅打印日志,不抛异常,适用于可降级的非核心功能(如日志上报)。
- Failback(失败自动恢复):后台记录失败请求,定时重发,通常用于消息通知操作。
- Forking(并行调用):同时调用多个实例,只要有一个成功就返回结果,适合对实时性要求极高的场景。
- Broadcast(广播):调用所有服务实例,逐个调用,任意一台报错则报错 ,通常用于通知所有服务实例更新缓存等本地资源信息。
Dubbo 默认采用 Failover 策略。在这个策略下,如果调用失败(如遇到超时、网络异常等),它会自动尝试切换至集群中的其他服务器进行重试。默认的重试次数是 2 次(通过 retries 参数配置)。需要注意的是,这个数字不包括最初的第一次调用。因此,加上第一次调用,默认情况下一个服务方法最多可能被调用 3 次。
因此,对于一些非幂等操作(比如下单等写操作),默认采用Failover策略会导致重复下单等问题,因此,我们需要在配置文件中覆写容错策略。如下所示。
<!-- 全局默认策略(所有服务生效) -->
<dubbo:consumer cluster="failfast" retries="0"/>
<!-- 单个服务指定策略 -->
<dubbo:reference interface="com.example.UserService" cluster="failover" retries="3">
<!-- 方法级重试(覆盖服务级) -->
<dubbo:method name="getUser" retries="2"/>
</dubbo:reference>(3)Feign
除了通过Dubbo RPC来开发微服务之外,在Java Spring生态中,我们还常用SpringBoot来开发微服务,提供HTTP协议的微服务接口,这个时候,调用微服务接口,我们往往使用Feign框架(或者开源版本OpenFeign)。
Feign 的超时设置主要针对两个阶段:
- 连接超时 (connectTimeout):指建立 TCP 连接的最大等待时间。如果超过这个时间还没和目标服务“握上手”,就会抛出 ConnectTimeoutException。
- 读取超时 (readTimeout):指 TCP 连接建立成功后,等待服务端返回响应数据的最大等待时间。如果超过这个时间还没收到完整的响应数据,就会抛出 ReadTimeoutException。
Feign 的超时可以在全局或针对特定服务进行配置。
# 在 application.yml 中为所有 Feign Client 设置默认超时
feign:
client:
config:
default: # 全局默认配置
connectTimeout: 2000 # 连接超时 2 秒
readTimeout: 5000 # 读取超时 5 秒
# 如果某个服务响应较慢,可以单独配置
feign:
client:
config:
user-service: # 服务名,与 @FeignClient("user-service") 对应
connectTimeout: 3000
readTimeout: 10000 # 该服务的读取超时设为 10 秒Feign 的重试机制默认是关闭的,这意味着失败或者超时之后不会二次调用。要启用重试,你需要显式配置。Feign内置了几种常用的重试策略:
- Retryer.NEVER_RETRY:默认策略,不进行任何重试。
- Retryer.Default:指数退避重试策略。默认最大尝试 5 次(含首次),初始间隔 100ms,最大间隔 1秒。
- 你也可以实现 feign.Retryer 接口,来自定义重试策略。
我们在前面讲解RPC框架的时候,提到负载均衡分为客户端负载均衡和代理负载均衡。对于RPC框架来说,一般会使用客户端负载均衡,也就是在客户端(RPC Client)配合服务注册中心,实现负载均衡。像Dubbo RPC这类框架,功能大而全,已经内置实现了客户端负载均衡。但是,Feign 的核心价值是简化 HTTP API 的调用,但它本身不具备负载均衡的能力。因此,Feign使用Ribbon来实现客户端负载均衡和服务发现(通过访问Eureka等注册中心)。
Ribbon本身也提供了超时设置,如果 Feign 配置了超时时间,则会覆盖 Ribbon 的超时设置;如果 Feign 未配置,则使用 Ribbon 的配置。Ribbon 自己也有一套重试机制。
Feign 的重试是在更高层面(方法层面的,而非HTTP这一层)的重试,一旦 Feign 决定重试,会重新调用整个 Feign 方法,可能会触发 Ribbon 重新选择服务器。当 Ribbon 在当前选择的服务器上调用失败(如网络连接失败、读取超时)时,它可能会切换其他服务器重试(MaxAutoRetriesNextServer参数表示重试几个服务器,MaxAutoRetries参数表示在一个服务器上重试几次)。
一般我们倾向于配置 Ribbon 的重试而关闭 Feign 的重试(保持默认)。除此之外,跟Nginx相同,默认情况下Ribbon 的 OkToRetryOnAllOperations 设置为 false,只能对GET、HEAD、OPTIONS请求进行重试,对POST、PUT、DELETE、PATCH不能进行重试。
(4)Redis
Redis的超时重试逻辑主要看它的客户端,不同的编程语言使用的客户端不同,我们拿Java语言的Jedis和Redisson来举例说明。
Jedis是轻量级的Redis客户端,并没有提供重试机制。Redisson功能更加复杂和强大,提供了超时重试机制,并且默认对所有的超时都进行重试。
- retryInterval:重试之间的时间间隔(默认 1500ms)。
- retryAttempts:命令执行失败后的最大重试次数(默认 3 次)。
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setConnectTimeout(10000) // 10 seconds
.setTimeout(3000) // 3 seconds
.setRetryAttempts(5) // 重试5次
.setRetryInterval(2000); // 每次重试间隔2秒
RedissonClient client = Redisson.create(config);对于GET、SET、DEL、HSET 等幂等操作,重试是安全的。对于INCR、LPUSH、PUBLISH、SADD 等非幂等操作,重试是不安全的,例如,一个 INCR 命令如果因超时而重试,可能导致值被增加了两次。因此,我们可以显式地去关闭Redisson的全局重试。此时,任何命令执行失败(如超时)都不会重试,直接抛出异常。
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setRetryAttempts(0) // 关闭重试
.setRetryInterval(1000); // 该值在重试次数为0时无效
RedissonClient client = Redisson.create(config);(5)MySQL
我们还是拿Java举例,MySQL 的官方驱动 mysql-connector-java 在超时重试方面非常保守。它的设计原则是:“失败一次,快速上报”,将处理失败的控制权交给应用程序,盲目重试可能引发数据不一致等严重问题。
对于数据库连接池,比如Druid,也不直接重试失败的SQL操作。Druid 在获取连接时,可以配置maxWait:从池中获取连接的最大等待时间(毫秒)。如果在此时间内无法获得可用连接,则抛出异常。这可以看作是一种“超时”控制,而非“重试”。Druid 本身没有“获取连接失败后自动重试N次”的逻辑。这种逻辑需要在你的应用程序中实现。
(6)RocketMQ
与上面讲解的各种框架往往倾向于不重试所不同,超时重试是RocketMQ 确保消息可靠性的重要手段,它主要分为生产者发送重试和消费者消费重试两大块。了解并合理配置它们,对构建可靠的消息系统至关重要。
生产者重试解决的是消息能否成功发送到Broker的问题。
当发送请求失败或超时(如网络异常、Broker无响应、Broker返回非成功状态等)时,生产者会自动重试。其中,发送超时时间通过sendMsgTimeout参数设置,默认为3秒钟,防止因网络问题或Broker故障导致生产者线程被无限期阻塞。生产者有同步发送和异步发送两种发送模式,重试次数对应通过retryTimesWhenSendFailed和retryTimesWhenSendAsyncFailed参数来设置,默认值都为2。对于大多数错误(如网络连接异常),RocketMQ会立即进行重试,不会等待。但是,当Broker返回系统流控错误时,客户端会采用指数退避策略延迟重试,以避免加重Broker压力。
消费者重试解决的是消息能否被消费者成功处理的问题。
当消费者处理消息时抛出异常、返回 RECONSUME_LATER 状态,或者处理超时,消费者会向 Broker 发送“消费失败”的反馈,Broker 收到后会将这条消息放入对应的重试队列(%RETRY% 开头的 Topic)。Broker 会根据预先配置的重试间隔(阶梯式延迟时间),在延迟时间到达后重新将消息投递给消费者。
对于无序消息,当一条消息消费失败进入重试后,它会按延迟时间单独重新投递,不影响同队列其他消息的正常消费。消费者可以继续处理后续的消息,无需等待前一条重试消息的成功。无序消息的重试次数默认 16 次,重试间隔呈阶梯式增长(10s、30s、1m、2m…),超过次数后进入死信队列(DLQ),需人工介入处理。
对于顺序消息,顺序消息要求同一队列的消息严格按照顺序消费。如果当前消息消费失败(抛异常或返回 RECONSUME_LATER),消费者会暂停该队列的后续消息消费,直到当前消息重试成功或重试次数用尽(顺序消息默认重试次数接近无限,为Integer.MAX_VALUE)。重试间隔默认固定为 1000ms(可通过 suspendTimeMillis 调整),在此期间后续消息被阻塞,从而保证顺序性。
这里需要注意的是,RocketMQ只针对集群模式(一条消息只要被集群内任意一个消费者成功处理一次即可)支持重试消费,对于广播模式(一条消息被集群内的所有消费者都消费一遍)不支持重试消费。因为在广播模式下,每个消费者独立维护进度,Broker 无法统一管理重试状态。
2. 重试策略
前面频繁提到各种重试策略,比如指数退避策略等,这里我们就展开讲下,常用的几种重试策略。
(1)立即重试
这是最简单粗暴的策略,即在失败后立即进行重试,不等待任何间隔。这种重试方式极易加重下游负担,如果下游服务是因为过载或故障而变慢,立即重试相当于在对方已经气喘吁吁时又上去猛捶几拳,不仅重试不会成功,还会雪上加霜,甚至击垮下游服务。因此,这种重试策略不常使用。
(2)固定间隔
在每次重试之间等待一个固定的时间间隔。设定一个重试间隔 delay 和最大重试次数 maxRetries。第一次失败后,等待 delay 时间后重试;第二次失败后,再次等待相同的 delay 时间,如此循环,直到成功或达到最大重试次数。
这种重试策略适用于失败原因是短暂的、且预期在固定时间内可以恢复的场景。优点是实现简单。缺点是不够智能。如果间隔设得太短,仍有加重下游压力的风险;如果设得太长,则总恢复时间会变长,影响用户体验。
(3)指数退避
这是最经典、最常用的重试策略。其核心思想是,重试间隔随着重试次数的增加而呈指数级增长。定义基础等待时间 baseDelay 和最大重试次数 maxRetries。第 n 次重试(从0开始)的等待时间为 min(baseDelay * 2^n, maxDelay)。例如:baseDelay=1s,那么重试间隔将是 1s, 2s, 4s, 8s, 16s... 直到达到预设的 maxDelay(如 60s)后便不再增加。指数退避重试解决了固定间隔重试的问题,既能应对立刻就恢复的小问题(例如,只是网络抖动),也能应对需要较长时间恢复的大问题(例如,服务需要重启)。
(4)随机抖动
这通常不是一个独立的算法,而是为了优化指数退避而引入的一种关键补充机制。在实践中,“指数退避 + 随机抖动”是黄金组合。它在指数退避计算出的等待时间基础上,增加一个随机值。解决“惊群效应”问题。什么是惊群效应?想象一个场景:某个服务短暂故障,导致上游成千上万个客户端请求同时失败。这些客户端都采用了相同的指数退避策略(如 baseDelay=1s)。它们会在1秒后、2秒后、4秒后同时发起重试。这种同步的重试洪流会在每个时间点形成一波波峰值,可能再次冲垮刚刚恢复的下游服务。抖动机制通过将重试时间随机化,打散了这些同步的请求,从而保护了下游服务。
(5)线性递增
重试间隔随着重试次数线性增加。第 n 次重试的等待时间为 baseDelay + n * increment。例如:baseDelay=1s, increment=2s,那么间隔将是 1s, 3s, 5s, 7s, 9s...它介于固定间隔和指数退避之间,提供了逐渐增加的重试间隔,但增长又不像指数退避那么剧烈。适用于你确信问题会持续一段时间,但又不想等待过久的场景,其应用远不如指数退避广泛。
3. 最后总结
这节课我们讲了分布式系统中保证调用成功率的关键手段:超时重试。大部分框架都内置了超时重试的策略,但默认情况下都不会打开,或者只针对GET等幂等操作打开,当然,也有例外,那就是消息队列,比如RocketMQ,超时重试是其保证消息不丢失并成功消费的关键,因此,默认并推荐打开!至于因为超时重试而导致的重复执行问题,我们需要通过改造代码让其支持幂等,这个我们下一节课再讲。除此之外,我们还讲到了各种重试策略或者说是算法,其中最常用的就是指数退避算法,当然,我们一般不需要自己去实现,可以使用现成的开源项目,比如Spring Retryer、Guava Retryer等。
三、幂等方案
在分布式系统中,系统之间的调用除了成功和失败之外,还有第三种结果,就是超时。超时是一种未决行为,也就是说,你不知道到底有没有执行成功。
在上一节课,我们讲到,为了降低分布式系统因网络抖动等原因导致的接口调用失败率,很多框架都支持超时重试机制,比如Dubbo RPC、RocketMQ消息队列。那么,超时重试就有可能导致业务逻辑的重复执行,对于有些业务逻辑来说(比如下单),重复执行可能会导致数据或者业务存在问题,因此我们需要进行处理,这也就是我们这节课要讲的内容:幂等。
1. 幂等操作
那么,到底什么是幂等呢?简单来讲就是,一个操作执行多次所产生的影响,与执行一次所产生的影响是相同的。我们常提到的查询操作就是一个幂等操作。跟幂等操作相对的就是非幂等操作,从数据库角度,我们来看增删改查各个操作的幂等性。
- 查询操作
查询操作是幂等操作。你可能会说,根据某个ID进行的两次查询之间,可能数据会被修改,查询到的数据可能并不相同,这也算是幂等吗?实际上,定义里关注的重点是:是否产生影响,虽然查询出来的数据有可能不同,但查询本身并没有对数据产生影响(即修改、删除等)。
select * from user where id=123;- 删除操作
很多同学会觉得删除操作是幂等,这样认为可能不够严谨。删除操作是否幂等,要根据场景具体来分析,有些是幂等操作,有些是非幂等操作,示例如下。
delete from user where id=123; //幂等操作
DELETE FROM user //删除创建时间最早的三个用户,非幂等
WHERE id IN (
SELECT id
FROM (
SELECT id
FROM user
ORDER BY create_time ASC
LIMIT 3
) AS oldest_users
);- 更新操作
这个要分情况看,对于update a=x这样的操作,天然是幂等的,比如更新用户名就是幂等操作,但是,对于update a=a+x这样的操作,就不是幂等的,比如用户转账,操作一个用户的账户减钱,另一个用户的账户加钱,就需要处理非幂等带来的问题。
update user set avatar='hello.img' where id=123; //幂等
update wallet set amount=amount+120 where id=123; //非幂等- 插入操作
当同一个Insert被多次执行,有可能会导致插入多条相同的数据,导致业务异常,也就是说,Insert非幂等。在某些业务场景下,我们需要通过某些方案将其转换为幂等。比如,用户注册,如果同一注册接口请求被执行多次,就会在数据中注册多个同一个用户,业务上显然是不可接受的,解决方案是,通过将手机号码设置为数据库表的唯一索引,通过数据库约束,限制只能插入一个用户。由此来解决此接口的非幂等带来的业务异常问题。
insert into user(name, telephone) values('xiaowang', '139xxxxx');2. 重复执行
其实,不知道你有没有发现,非幂等操作出现问题的前提是:同一个操作被重复执行。那么,为什么同一个操作会被重复执行呢?这里我们总结了一些场景。
- 前端重复提交:用户在点击提交按钮之后,因为网络延迟或者抖动,导致没有及时对用户的提交行为做出成功的响应,用户以为没有提交成功,重复点击提交按钮,导致后端业务重复执行。
- 接口超时重试:上一节课中,我们讲到,在微服务架构中,很多框架都支持超时重试机制,比如Dubbo RPC默认是开启超时重试,当接口调用超时时,框架会默默的帮我们重试,也会导致业务的重复执行。
- 消息队列重试:上一节课我们讲到,跟其他框架不同,其他框架大都默认不开启超时重试,即便开启也只是针对查询操作,但是,对于消息队列来说,超时重试是保证消息不丢失的可靠手段,默认是开启的。
- 第三方平台回调:比如使用微信支付,用户支付成功之后,微信支付平台会回调你的系统的回调接口,为了保证支付成功信息通知的成功率,一般会对超时情况进行重试回调,这就有可能导致重复执行。
在超能简历项目的业务场景中,注册、创建简历、下单、支付回调后授权都需要考虑幂等问题。
- 注册,这个不用讲,重复执行注册多个相同账户肯定是不行的。
- 创建简历,网络抖动或者用户手抖导致创建了两份相同的简历,似乎是可以接受的哈,这个看自己的业务需要
- 下单,点了一次购买会员,结果下了两个订单,跟钱有关,有点敏感,最好要避免
- 授权,支付成功之后,微信支付会回调我们后端的授权接口,给用户开通权限或者延长会员时长,以及增加各项权益次数。由于网络抖动等原因,微信支付没有收到回调成功的响应的话,就会重复回调。这就有可能会导致多次多次延长会员时长,多次增加各项权益次数,显然是不能接受的。
3. 解决方案
怎么解决幂等问题呢?从前面的分析,我们可以发现,幂等问题产生的两个原因,一是操作是非幂等的,二是操作重复执行。要解决幂等问题,就可以从这两方面入手。要么不重复执行,要么将非幂等操作改造成幂等操作!
对于用户交互上引起的操作多次执行问题,我们可以在客户端限制频繁请求,比如当用户点击提交按钮之后,灰掉按钮(不可点击),减少用户的误操作。不过,这只能尽量减少用户触发重复操作,但无法彻底解决操作被重复执行问题,毕竟还有框架的超时重试等,我们仍然需要在后端将非幂等操作改造成幂等操作。
将非幂等操作改造成幂等操作的方案主要有两个:业务标识和Token机制。实际上,这两种方法本质是一样的,核心思想是:找到定义操作的唯一标识,通过唯一标识去排他(只允许执行一次)。
(1)业务标识
如果我们在业务上可以找到唯一标识,对应到数据库表上就是主键约束(或唯一索引),我们可以用这个业务上的唯一标识来作为接口请求的唯一标识,限制同一接口请求(即具备相同唯一标识的接口请求)被多次执行。
举个例子,对于用户注册这个业务,我们可以把手机号码作为唯一标识(一个手机号码只能注册一个用户),对应到数据库,就是将手机号码作为唯一索引,限制数据库中插入两条具有相同手机号码的数据。
对于插入接口,我们有机会使用主键约束(或唯一索引)限制只能创建一条具有相同唯一标识的数据。但是,对于更新接口,还能利用这种方法解决幂等问题吗?我们拿前面提到的支付成功回调授权接口为例。授权接口实际上是一个更新接口,它会延长用户vip时长,增加用户的各项权益次数,对应到数据库都是update a=a+x操作。显然,即便有主键约束或者唯一索引,也限制不了多次update。
解决更新操作的幂等问题的方法就是:将更新操作改为插入操作,当然,这样讲稍微有点不准确,我们还是结合例子具体看下如何来做。
微信支付成功之后,在回调我们的授权接口时,微信支付会给我们一个它的这笔支付的Transaction ID,我们可以把这个Transaction ID作为唯一标识,存储到某个表中(新建一张表或作为其他某个表的一个字段),支付成功后回调授权接口时,先检查这个Transaction ID是不是已经存储了,如果是,说明已经授权过了,就不要再授权了。如果没有,则插入这个Transaction ID。
(2)Token机制
使用业务唯一标识,借助数据库的主键约束或者唯一索引来解决幂等问题,这种方法可以解决我们前面提到的业务中的注册和支付成功后回调授权的幂等问题。但是,对于创建简历、下单,并没有业务唯一标识,那么,就无法用这种方法来解决了。
前面讲到,解决幂等问题的核心是:找到定义操作的唯一标识,通过唯一标识去排他(只允许执行一次)。既然业务上无法找到一个合适的唯一标识,那么,我们就可以人为给它创造一个,也就是我们将要讲的基于Token的解决方法,这里的Token也可以叫做幂等号(Idempotency Key),总之就是人为生成的,没有业务含义的一个唯一ID。
创建简历是可以容忍超时重试导致的重复创建两个相同简历的,因此,我们以下单为例来介绍一下这种解决幂等问题的方法,当然,这套解决方案也完全可以无缝的用到创建简历的场景中。
在进入下单页面时,前端会先请求后端接口,获取一个token(幂等号),然后,用户操作下单,对后端调用下单接口时,需要在接口中(一般是放到HTTP header中)将这个token一并传递给后端。后端接收到这个token之后,会检查是否已经使用掉了,如果没有,说明是第一次基于这个token做下单请求,那么,就执行下单请求。如果这个token已经使用掉了,说明是重复的请求,可能是因为超时重试,也可能是用户频繁多次点击,就直接拒绝执行下单请求。
现在,我们再挖一下细节,如何判断token有没有用掉?
我们可以将生成的token存储在Redis中,接受到下单请求之后,先检查token是否存在,如果存在说明这个token还没有用过,将token删除,返回true,允许下单。相反,如果token不存在,说明token已经被使用,返回false,拒绝下单。为了保证查询和删除token操作的原子性,我们可以使用Lua脚本执行这坨逻辑。
String redisKey = "order:token:" + token;
// 关键:使用Lua脚本保证校验和删除的原子性,避免并发问题
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(redisKey),
"unused"); // ARGV[1] 是我们期望的值
// 2. 处理校验结果
if (result == null || result == 0) {
// Token无效 (不存在、已使用、过期、值不匹配)
// 可能的情况:
// a) Token已使用过 -> 幂等返回,告知客户端订单已创建成功(需要业务逻辑支持查询)
// b) Token不存在/过期 -> 返回错误,提示客户端刷新页面重新获取Token
// 这里简化处理,统一返回"重复请求"错误
throw new IdempotentException("重复请求或Token无效");
}这里我们再多说几句,基于Token的解决方案,需要引入Redis,获取Token和验证Token都需要跟Redis进行交互,那么,怎么解决Redis超时或者宕机对下单这一核心功能可用性的影响呢?
其实,解决起来也比较简单。毕竟Redis超时或者宕机是小概率事件,当Redis出问题时,可以让下单操作也失败即可,对于大多数中小型应用都是可以接受的,运维通过监控告警发现Redis问题,即时恢复即可!当然,我们也可以使用前面学到的降级方案,如果Redis工作异常,则暂时停止幂等逻辑,走非幂等的下单逻辑,这种情况可能小概率造成重复下单,但问题也不大,可以记录日志并出发告警,后期人工排查!具体选择哪个方案,可以结合业务对失败和重复的容忍度来决定。
4. 最后总结
这节课我们讲了幂等问题出现的原因:超时重试+非幂等操作,解决的思路有二,一是避免重试,但只能解决部分问题,二是将非幂等操作改造成幂等操作。改造方法的核心就是找到唯一标识,通过唯一标识排它。一般有两种方法,一是使用业务唯一标识,比如手机号码等,通过数据库主键/唯一索引来禁止多次插入,对于更新操作,也可以前置一个具有唯一标识的插入操作来禁止多次更新。当然,对于不存在业务唯一标识的操作,我们也可以人为创建一个Token作为唯一标识,通过这个Token来排它!
四、分布式ID
分布式ID是分布式系统中常用的功能组件,也是面试中常考的知识点,本节课我们聚焦讲解各种分布式ID生成方案,并且分析各自的优势、劣势及其应用场景,方便系统设计中的技术选型和应对面试。
1. 应用场景
到底什么是分布式ID呢?其中的“分布式”又该如何理解呢?
要理解“分布式”的含义,我们要从单机ID生成的方式讲起。我们看一个非常常见的应用场景,也是前面提到的,随着业务的发展,当单台数据库无法承载海量数据的存储和访问时,就需要分库分表。在这种架构下,如果每个数据库仍然使用自增ID,就会出现很明显的问题:多个数据库之间的ID会发生冲突。比如,订单数据量比较大,分成了8个订单库,每个订单库使用自增ID,就会导致订单ID的重复。
为了解决这个问题,我们就需要分布式ID。ID最大的特点就是唯一,单机ID要求单机范围内唯一,分布式ID要求全局唯一,即在所有实例之间都要唯一,在我们这个例子里就是要求多个订单表生成的ID要全局唯一。
其实,对于刚刚订单分库分表的例子,一种简单的解决方案是,让不同的订单表采用不同的起始值和相同的步长来递增(通过设置MySQL的auto_increment_increment和auto_increment_offset参数)。1号订单库的ID序列是1, 9, 17, 25...,2号订单库的ID序列是 2, 10, 18, 26...,以此类推,这样就可以避免ID冲突了。不过,这种方法存在一个问题,那就是没法应对后续的继续扩容(增加新订单表)!
实际上,除了全局唯一这一个特性之外,分布式ID还有其他特性要求(并非强制),我们总结如下:
- 全局唯一:这是最基本的要求,整个系统内绝对不能出现重复的ID
- 局部递增:多数场景不要求全局递增,局部(单机)递增有利于存储(待会讲)
- 高性能:在高并发下,ID的生成不能过度影响到业务系统的性能。如果我们要做一个独立的发号机给其他模块或者系统使用,那么基于MySQL自增ID来实现发号机,性能受限于MySQL,往往不满足高性能要求!
- 信息安全:不能通过ID泄露用户数、每日订单数等敏感业务信息。比如,使用数据库自增ID,通过ID就可以轻松知道网站有多少用户,通过两日订单号之差就能估算每日的订单数,泄露了重要业务数据!
- 部分可读:对于一些特殊的ID,比如订单号,我们希望其包含一些业务信息,方便查询或者分库分表(比如基因法分库分表),关于订单号的设计,我们在后面在案例部分中会详细讲解。
了解了分布式ID的特性要求之外,我们来看几个经典的通用的生成方法,基于这些方法,你可以根据自己的业务进行适当的改造,比如刚刚提到的在订单号中融入业务信息,以更加适合查询或分库分表。
2. UUID
UUID(Universally Unique Identifier),中文通常译为“通用唯一识别码”,是一个128位(16字节)的数字。它可以在全局范围内生成唯一的ID,并且不依赖数据库,完全去中心化,生成性能非常高,因为其生成的ID为随机的,非连续递增,因此不存在泄露业务信息的问题。
UUID一般表示为32个十六进制数(一个十六进数是4bits,32个也就是128bits),通常用连字符‘-’分为五部分,形式为 8-4-4-4-12,加上连字符,总共36个字符。例如:123e4567-e89b-12d3-a456-426614174000。UUID有不同版本的生成方法,不同版本对这些部分的解释和填充方式不同。
(1)版本1:基于时间和MAC地址
这是最经典的UUID生成方式,由时间戳,时钟序列,MAC地址组成。这种生成方式有一个很大问题,就是会泄露服务器的MAC地址,因此,是不推荐使用的。
这里有一个时钟序列,我们稍微解释一下它的作用:
- 处理时钟回拨:如果生成 UUID 时系统时间突然向后调整,仅依赖时间戳和 MAC 地址可能会产生与之前相同的 UUID。时钟序列会在检测到时间回拨时递增,从而确保即使时间戳重复,生成的 UUID 仍然唯一。
- 弥补时间戳精度:UUID 的时间戳精度为 100 纳秒,但在某些系统上可能无法达到该精度。时钟序列提供了一个额外的“计数器”,在同一时间戳内保证多次生成的 UUID 不重复。
UUID 的 128 位中,时间戳占 60 位(前 60 位),时钟序列占 14 位,MAC 地址占 48 位(最后 48 位)。时钟序列在初始化时通常取一个随机值,并在后续遇到时间回拨时递增。
(2)版本2:基于DEC安全版本
UUID 版本 2(DEC 安全版本) 是版本1 的一个变体,由 DEC(Digital Equipment Corporation)公司提出。它基于版本1 的底层结构,但替换了部分时间戳字段。
版本2 的 128 位中仍包含 时钟序列 和 节点标识(通常也是 MAC 地址),但时间戳字段中的部分bit位,替换成了本地域标识符(例如 POSIX 的 UID 或 GID),而保留了一部分时间戳高位以维持时间排序性。这样,UUID 在全局唯一的同时,还能直接嵌入用户或组标识。
通过将 UUID 与特定用户或组绑定,可以在分布式系统中实现基于身份的访问控制,例如确保某个资源只能由拥有特定 UID 的进程访问。不过,它在现代项目中也极少被使用。
(3)版本3:基于命名空间和MD5
版本3和版本5都是通过对一个命名空间标识符和一个名称进行哈希来生成UUID。
命名空间标识符可以是你的业务类型,比如order、user等,但可能会重复,因此,你需要预先设定好的一个对应的随机字符串,比如order对应6ba7b810-9dad-11d1-80b4-00c04fd430c8,user对应1b3e4567-e89b-12d3-a456-426614174000,这样就避免了重复。
名称就是在你选择的那个命名空间里,你希望为其生成UUID的唯一标识字符串。它可以是任何字符串:一个域名、一个URL、一个电子邮件地址、一个用户名、一个文件名、一个数据库的主键值等等。版本3将命名空间与名称拼接,对拼接后的数据计算MD5哈希,得到128位结果,然后就得到了UUID。只要你保证在同一个命名空间下,每个名称字符串是唯一的,那么生成的UUID就是唯一的。
这个UUID生成算法有一个特点,那就是基于相同的命名空间和名称可以生成一个确定性的UUID,非常适合跨系统数据关联:当多个系统需要对同一实体(如用户、订单)保持一致的标识时,使用共同约定的命名空间和名称即可各自生成相同UUID,无需中央ID分发服务。
(4)版本4:基于随机数
这是目前最常用、最简单的UUID生成方式。使用随机数来填充UUID的128位。生成简单,完全随机,不可预测,并且完全无序,安全性好。在Java中常用的java.util.UUID.randomUUID()函数就是基于这个版本实现的。
(5)版本5:基于命名空间和SHA-1
与版本3类似,只是将MD5算法替换为更安全的SHA-1算法。
(6)UUID作为分布式ID存在的问题
相对于前面提到的数据库自增ID,UUID避免了业务信息的泄露,但是,如果使用UUID作为数据库主键,因其无序性,相对于有序的数据库自增ID,在数据插入和查询的效率上要低。
InnoDB的聚簇索引结构天然适配顺序写入,自增ID按顺序写入(如1, 2, 3,...),新数据始终追加到B+索引树末尾,减少页分裂和磁盘I/O。UUID随机生成(如a1b2c3...),导致新数据插入位置分散,频繁触发页分裂。每次分裂都需要移动数据、重建索引,产生大量随机I/O,写入速度很受影响。
再来看查询。用自增 ID,数据在物理磁盘上存得紧凑,范围查询(比如 ID > 1000)几乎就是顺序读,缓存命中率高。而且主键本身只占 4~8 个字节,索引结构小巧,内存负担也轻。换成 UUID,数据在磁盘上散落各处,范围查询不得不跨页检索,缓存利用效率自然就差。 UUID 本身长度大(16~36 字节),内存负担也重。
3. 雪花算法
实际上,我们可以使用经典的Twitter开源的雪花算法(Snowflake)来替代UUID。雪花算法生成的ID是局部有序的(单机有序,待会会讲到),这样就避免了作为数据库主键的插入和查询性能问题,而它又是非连续的随机跳跃的,因此也可以避免业务泄露问题。
雪花ID是一个64位长整型数字,由4部分组成:
0 | 00000000000000000000000000000000000000000 | 0000000000 | 000000000000- 符号位(1bit):固定为0,保证ID为正数
- 时间戳(41bits):记录ID生成的时间(毫秒级),注意这里的时间戳并非当前时间戳,而是当前时间戳减去开始时间戳(自定义的epoch)的毫秒数,Twitter使用的是 2010-11-04 09:42:54 UTC 作为epoch。
- 工作进程ID(10bits):标识生成此ID的机器或服务实例,保证了分布式环境下的唯一性。此值可以由机房ID和机器ID组成,比如机房ID占前5位bit,机器ID占后5位bit,确保每个实例上的工作进程ID唯一。
- 序列号(12bits):为了解决同一机器、同一毫秒内可能产生的并发冲突。同一台机器在同一毫秒内,最多可以生成2^12(即4096)个不重复的ID。如果同一毫秒内生成的ID数量超过4096,生成器会自旋等待直到下一毫秒,然后序列号从0开始重新计数。
从以上雪花ID的构成,我们可以发现,在同一台机器上,时间戳是递增的,在同一时间戳下,序列号是递增,因此,生成的ID也是局部有序的(在同一个机器上有序)。接下来,我们在看下雪花算法的具体生成流程:
- 获取当前时间戳:计算当前时间与预设epoch时间的毫秒差。
- 检查时钟回拨:这个是雪花算法面对的最大技术挑战。因为ID的生成依赖时间戳,而服务器存在时钟回拨问题(服务器时间被人为或同步服务调整),如果往前回拨就可能导致生成的ID重复。因此,我们需要比较当前时间戳和上一次生成ID的时间戳。如果当前时间戳小于上一次的时间戳,说明发生了时钟回拨。此时通常会抛出异常或等待直到当前时间戳大于等于上一次生成ID的时间戳。
- 处理同一毫秒的并发:如果当前时间戳等于上一次的时间戳,则将序列号加1。如果序列号达到最大值(4095),则等待至下一毫秒,时间戳增加,序列号重置为0。如果当前时间戳大于上一次的时间戳,则序列号重置为0。
- 组合各部分信息:将符号位(0)、时间戳、工作进程ID、序列号通过位运算组合成一个64位的Long型整数。
4. Redis INCR
我们再来看基于Redis的INCR命令的分布式ID生成方案,其核心思想是利用Redis的单线程原子性和高性能,形成一个集中式的ID发放中心。
Redis提供了INCR命令(INCR key:将键 key 存储的数字值增加1),它是一个原子操作,这意味着即使有数百个客户端同时向Redis发送 INCR 命令,Redis也会串行执行,每个命令都会收到一个唯一且递增的返回值,绝不会出现并发冲突。这样,我们就得到了一个简单的自增ID。所有微服务实例都通过向这个Redis实例调用 INCR global_id 来获取下一个ID。
其实,这个基于MySQL自增主键的ID生成方式是类似的,ID生成的性能强依赖于MySQL、Redis的性能,好在Redis的性能要远强于MySQL,但显然没有去中心化、去存储的UUID和雪花算法性能高。当然,它们也并非无用武之地,对于需要全局唯一且全局递增有序ID的场景,显然UUID和雪花算法都不适合,而基于MySQL自增主键或者Redis INCR的ID生成方案就派上用场了。
5. 号段模式
号段模式是对刚刚提到的MySQL自增主键和Redis INCR生成ID方法的优化,也称为批量获取模式。其核心思想是:每次不是获取一个ID,而是从数据库中批量获取一个号段,例如 [1, 1000]。服务消耗完这个号段后,再去数据库获取下一个号段。这大大减少了与数据库的网络交互次数,将性能提升了几个数量级。
为了避免多个服务并发竞争号段,我们需要保证取号段的逻辑是互斥的。具体来讲,对于MySQL,我们需要使用数据库事务保证操作的原子性,如下所示。使用leaf_alloc表管理各业务(biz_tag)的ID分配,字段包括:
- biz_tag(业务标识,主键)
- max_id(当前最大ID)
- step(步长,即单次获取ID数量)
BEGIN;
UPDATE leaf_alloc SET max_id = max_id + step WHERE biz_tag = #{tag};
SELECT biz_tag, max_id, step FROM leaf_alloc WHERE biz_tag = #{tag};
COMMIT;对于Redis,要稍微简单一些,因为命令的执行本身就是单线程的,满足原子性和互斥性。命令执行成功返回的是增加1000(步长)之后的global_id,因此,获取到的号段区间为[ global_id-1000+1, global_id]。
INCRBY global_id 1000从工作流程中,我们可以发现,当号段区间消耗完,去数据库取新的号段时,如果多个服务同时请求号段,就要排队执行,这就会导致短暂的性能问题,即需要等待拿到号段之后,才能继续生成新的ID。这个问题可以通过双Buffer预取的方式解决。
具体的实现方式是:准备两个号段缓冲区(Buffer)。当一个Buffer的ID消耗达到预设的阈值时,比如剩余10%,提前在后台异步地加载下一个Buffer。当一个Buffer用完时,可以立即切换到早已准备好的另一个Buffer,从而消除等待时间。
6. 大厂方案
前面我们讲了很多分布式ID生成方案,在实际的项目开发中,我们并不需要从零开始实现,而是可以使用现成的开源的实现,比如美团的Leaf、百度的UidGenerator、滴滴的TinyID,都是常用的分布式ID生成组件。
其中,滴滴的TinyID是基于号段模式的ID生成器。美团的Leaf实现了两种分布式ID生成算法,一个是号段模式,采用了双buffer来实现,另一个是Snowflake雪花算法。百度的UidGenerator基于Snowflake算法做了优化,引入了高性能队列Disruptor中的RingBuffer思想来提升效率。
我们具体来看下UidGenerator。UidGenerator默认的位分配与原生Snowflake略有不同,其64位ID结构通常包含28位的时间戳(秒级)、22位的工作进程ID和13位的序列号。UidGenerator不会在每次调用时实时计算ID,而是通过后台线程预生成UID并填充到一个RingBuffer(环形数组)中。应用程序获取ID时,只是从RingBuffer中取出下一个可用的UID,这极大地提升了获取效率。
7. 最后总结
这节课我们系统梳理了分布式ID生成的多种方案及其核心原理。从最基础的数据库自增ID及其优化版号段模式,到完全去中心化的UUID和雪花算法,再到各大厂基于这些基础方案的综合优化实践,我们可以看到,并没有一种万能方案,关键在于根据业务场景在性能、有序性和安全性之间做出权衡。理解这些方案的底层机制,有助于我们做出合理的架构选型。
五、分布式锁
在单机单进程的系统中,多个线程访问共享资源时,可以使用语言内置的锁(如 Java 的 synchronized、ReentrantLock)来保证线程安全。但在分布式系统或微服务架构中,应用往往部署在多个节点上,单机的线程锁无法跨节点生效。这时就需要一种跨进程、跨节点的锁来协调多个节点对共享资源的访问,这就是分布式锁。
1. 分布式锁
需要互斥访问共享资源的分布式环境几乎都需要分布式锁。举两个典型例子:
场景一:定时任务重复执行
为了保证服务的高可用,同一个定时任务(如每天对账)通常会部署在多个服务节点上。这样,当其中一个节点宕机或正在重启时,其他节点可以接管任务,避免任务漏执行。但如果不加控制,所有节点都会在同一时间执行该任务,导致数据被重复处理。解决方案:执行任务前,所有节点先去获取分布式锁,只有成功获取锁的节点才能执行任务,其他节点放弃执行。
场景二:秒杀库存扣减
电商秒杀活动,库存只有 100 件,但瞬时可能有数十万请求。若每个请求都直接检查库存并扣减,必然导致超卖(扣到负数)。解决方案:对“检查库存 + 扣减库存”这一临界区加分布式锁,确保同一时间只有一个请求操作库存。虽然这会降低吞吐量,但保证了数据一致性(实际工程中常结合缓存、队列等优化性能,后面在案例部分讲解)。
一般来说,一个可靠的分布式锁需要具备以下基本特性:
- 互斥性:任意时刻,只有一个节点能持有锁。
- 安全性:锁只能由持有它的节点释放,不能被其他节点释放。
- 超时释放:锁必须有超时机制,即使持有锁的节点崩溃无法主动释放,锁最终也会自动释放,避免系统永久阻塞。同时也能防止死锁。
- 可重入性:同一个节点在已持有锁的情况下,可以再次成功获取该锁(即锁的持有者可以重复进入临界区)。
- 高性能 & 高可用:加锁、释放锁的开销要低,且锁服务本身要具备高可用,防止单点故障导致锁失效。
接下来,我们讲解几种常用的分布式锁实现方案。
2. 基于数据库的方案
如果你的系统使用 MySQL,又不希望引入复杂的外部组件,且并发量不高、锁竞争不激烈,可以基于数据库实现简单的分布式锁。常见的有两种思路:乐观锁和悲观锁。
注意:乐观锁本质上不是“锁”,而是一种并发控制策略,但常被归类到分布式锁的简单实现中,我们也沿用业界通俗叫法。
(1)乐观锁
乐观锁假设并发冲突很少发生,因此读数据时不加锁,只在更新时检查数据是否被其他事务修改过。常用方式:增加一个 version 字段或使用业务字段本身作为条件。
我们举个例子。以下库存扣减会导致超卖:在高并发下,多个请求可能同时执行完第1步,都读到库存为100,都认为可以扣减,最终执行UPDATE后,库存可能被扣减到负数,导致超卖。
-- 步骤1:查询当前库存
SELECT stock FROM product_stock WHERE product_id = 'PROD123'; -- 假设读到 stock=100
-- 步骤2:应用层判断如果库存>0,则执行扣减
-- 步骤3:更新库存
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 'PROD123';如果我们基于乐观锁来进行并发控制,这里可以在product_stock数据库表字段中添加一个version字段,然后修改之后的业务逻辑对应的SQL如下:
-- 1. 查询当前库存和版本号 (假设读到 stock=100, version=5)
SELECT stock, version FROM product_stock WHERE product_id = 'PROD123';
-- 2. 应用层判断:如果库存 > 0,则准备扣减。组装更新条件。
-- 3. 更新库存!这是最关键的一步。
-- 将版本号作为更新条件,并 set version = version + 1
UPDATE product_stock
SET stock = stock - 1,
version = version + 1
WHERE product_id = 'PROD123'
AND version = 5; -- 这里传入之前读到的版本号
-- 4. 检查更新结果第三步的UPDATE语句是原子操作。MySQL会保证同一时刻只有一个UPDATE语句能成功执行。
- 成功情况:如果当前数据库中 product_id = 'PROD123' 的记录的 version 值确实是5,则更新成功。库存减1,版本号变为6。该操作返回的“影响行数”为 1。
- 失败情况:如果在此期间,另一个请求已经成功更新了该记录(版本号已经变成了6),那么这个UPDATE语句中的 version = 5 条件就不成立,更新不会执行。该操作返回的“影响行数”为 0。
当然,这个业务逻辑比较简单,我们完全可以不使用version,而是直接使用数据本身的状态值作为条件。
UPDATE product_stock
SET stock = stock - 1
WHERE product_id = 'PROD123'
AND stock >= 1; -- 核心:将业务状态(库存必须大于0)作为更新条件如果库存 >= 1,则扣减成功。如果另一个请求已经把库存扣到0,这个条件就不成立,更新失败。这种方式更直接,但缺点是如果更新逻辑复杂,需要set多个字段,用版本号是更通用的做法。
我们来总结一下乐观锁的优缺点和应用场景。
- 优点:实现简单,在读取数据时不加锁,极大提高了并发读的性能,冲突较少时效率极高,除此之外,由于没有真正的锁竞争,根本不会产生数据库死锁。
- 缺点:冲突频繁时,大量重试会消耗 CPU 和数据库连接,响应时间不稳定,因此,它不适合并发写非常高的场景。除此之外,它是一个“尝试-失败-重试”的模型,对于获取“锁”的客户端来说,它可能需要进行多次尝试才能成功,响应时间不确定。
- 适用场景:乐观锁适合并发冲突发生的概率较低、业务逻辑处理耗时较短(这样重试的代价不会太大)的业务场景,如中小型电商的库存扣减、账户余额更新等。
(2)悲观锁
悲观锁的策略与乐观锁完全相反。它假设并发冲突一定会发生,因此在操作数据之前,会先采取“上锁”的措施,确保在自己操作的过程中,数据不会被其他事务修改。
在MySQL中,悲观锁通常通过 SELECT ... FOR UPDATE 语句实现。这个语句会在事务中对选中的数据行加上排他锁。其他事务在对这些被锁定的行进行 SELECT ... FOR UPDATE、UPDATE、DELETE 等操作时,会被阻塞,直到当前事务提交(COMMIT)或回滚(ROLLBACK)释放锁为止。
还是上面举的库存扣减的例子,我们来看下悲观锁的实现思路,这里必须强调的是:整个操作必须在一个数据库事务中进行。
BEGIN; -- 开启事务
-- 锁定该行,其他事务的 SELECT ... FOR UPDATE 或 UPDATE 会被阻塞
SELECT stock FROM product_stock WHERE product_id = 'PROD123' FOR UPDATE;
-- 应用层判断库存 > 0,然后扣减
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 'PROD123';
COMMIT; -- 提交事务,释放锁悲观锁优缺点和应用场景。
- 优点:悲观锁的实现逻辑更加清晰:“先锁住,再操作”,无需像乐观锁那样处理重试逻辑,在冲突频率极高的场景下,避免了乐观锁不断重试带来的性能损耗。乐观锁能够绝对保证在锁持有期间,数据不会被其他事务修改,非常适合对数据一致性要求极高的核心场景(如金融交易)。
- 缺点:乐观锁的缺点也非常明显,数据库连接是宝贵资源,FOR UPDATE会长时间占用连接,高并发下容易成为瓶颈,拖垮数据库。更坏的是,一旦出现死锁(在我之前的一个项目里,就用的这种low方案,经常死锁),数据库连接慢慢就会被消耗殆尽,系统就无法正常运行了。
- 适用场景:乐观锁一般应用在对数据一致性要求极其严苛,并发量不高的场景。
3. 基于Redis的方案
Redis 性能极高,是实现分布式锁的热门方案。
(1)加锁
在Redis的早期版本中,我们使用SETNX命令(Set If Not Exists)和EXPIRE命令来实现分布式锁,具体的命令如下所示。只有当lock_key不存在的时候,SETNX才会设置成功,并且命令返回1,表示锁获取成功;反之,如果lock_key已经存在,SETNX设置失败,命令返回 0,表示锁已被其他客户端持有。
SETNX lock_key unique_value #加锁
EXPIRE lock_key 30 #设置超时30秒以上两个命令独立执行,合在一起不是原子操作。如果在执行 SETNX 后客户端崩溃,未能设置过期时间,会导致锁一直无法释放。其实,这种方案早已经过时了。Redis 2.6.12 及以上版本提供了 SET 命令的扩展参数,可以原子性地完成设置值和过期时间:
SET lock_key unique_value NX PX 30000- NX:仅当键不存在时才能设置成功
- PX 30000:设置键的过期时间为 30000 毫秒(30 秒,避免死锁)
- unique_value:必须是全局唯一的值(如线程ID),用于安全释放锁
在上面的加锁逻辑中,我们都给锁设置了超时时间,这样可以避免客户端宕机来不及释放锁而导致的锁一直无法释放。不过,这也会带来新的问题,那就是,客户端在加锁后的业务逻辑还没有完成的情况下,锁有可能就已经超时释放了,这个问题该如何解决呢?
我们就需要使用看门狗(Watch Dog)对锁进行自动续期。在客户端获取锁成功后,会启动一个后台线程(看门狗),在锁过期时间的三分之一左右时,自动去续期(延长锁的持有时间),防止业务没执行完锁就过期了。是不是基于Redis实现分布式锁也没那么简单?不用担心,对于Java开发工程师来说,我们可以直接使用Redisson。Redisson 是 Redis 的 Java 客户端,提供了开箱即用的分布式锁实现。
(2)释放锁
了解了加锁逻辑,我们再来看如何释放锁。自己只能释放自己的上的锁,因此,释放锁不能简单地使用 DEL 命令,必须先验证锁的值是否与当前客户端匹配,防止误删其他客户端的上的锁。同时,为了保证检查+删除的逻辑是原子操作,我们必须使用Lua脚本来执行。
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end有的同学会说,不使用Lua脚本也行吧,先执行检查命令,如果是自己上的锁,再执行删除命令,貌似也没有什么问题吧。其实问题在于,锁有可能超时。如果客户端A检查发现是自己上的锁,在准备释放锁之前,锁超时释放了,另一个客户端B又重新上了锁,这个时候,客户端A再执行删除命令,就会释放客户端B加的锁。有些同学肯定又要说了,不是有Watch Dog自动续期嘛,但是,Watch Dog也有打瞌睡的时候,万一它崩溃或者阻塞没来得及续期,锁也是不可避免会超时的。
(3)脑裂问题
前面我们讲过,为了保证Redis的高可用性,我们往往采用主从部署架构,并且使用Redis Sentinel做故障自动转移。基于这种架构来实现分布式锁,就会存在脑裂问题:Redis 的主从复制是异步的,客户端A在Master节点申请到了锁,在数据被异步复制到Slaves之前,Master宕机了,Sentinel重新选举了一个Master,客户端B又在新的Master上加了锁,这样就会导致锁失效,即两个客户端同时持有了锁。
为了解决上述脑裂问题,Redis 的作者 Antirez 提出了 RedLock 算法。它的核心思想是:同时向多个 Redis 实例申请锁,只要大多数实例成功,就认为获取锁成功。这基于分布式系统中的“多数派”思想。
RedLock 实现复杂,需要多套 Redis 主节点,运维成本高。在实际业务中,如果能够容忍极小概率的锁失效(比如非金融场景),使用主从架构已经足够;如果不能容忍,更推荐使用 ZooKeeper 等分布式协调组件。
4. 基于分布式协调组件的方案
在前面的文章中,我们有一节专门讲解分布式系统协调组件,比如Chubby、ZooKeeper、Etcd等,其实,Chubby最开始被发明出来,就是为了解决分布式的加锁问题。我们知道,这些分布式协调组件最大的特点就是:高可用和数据强一致。因此,不存在因为故障导致的锁失效问题(两个客户端同时持有锁)。
下面以 ZooKeeper 为例说明实现原理。在此之前,我们必须先了解几个核心概念:
- ZNode:数据节点,类似文件路径(如 /locks/my_lock)。它有以下类型:
- 持久节点:永久存在,除非手动删除。
- 临时节点:会话结束后(客户端断开或崩溃)自动删除。这是实现避免死锁的关键。
- 顺序节点:创建时会由 ZooKeeper 自动在节点名后附加一个单调递增的序列号(如 lock-0000000001)。
- Watcher:监听器,客户端可以在 ZNode 上设置监听。当该节点发生特定变化(如被删除、子节点列表变化)时,ZooKeeper 会通知客户端。这是实现阻塞等待和通知机制的关键。
- 会话:客户端与 ZooKeeper 服务器之间建立的连接,会话有超时时间,如果客户端宕机,无法心跳保活,会话超时后,其创建的所有临时节点将被自动删除。
(1)加锁流程
我们以商品库存扣减为例(商品stock_123)。
- 创建锁节点:客户端 A 想要获取锁时,在锁目录/locks/stock_123下创建一个临时顺序节点,如 /locks/stock_123/lock-0000000001 。
- 检查并获取锁:客户端 A 获取 /locks/stock_123 下的所有子节点,并按序列号排序。判断自己创建的节点是否为序号最小的那个。如果是,说明客户端 A 成功获取了锁。
- 等待锁:如果客户端 A 创建的节点不是序号最小的(例如,它创建的是 lock-0000000002,而 lock-0000000001 已存在),说明锁已被其他客户端持有。此时,客户端 A 并不需要不断地轮询。它只需监听比自己序号小的那个相邻节点的删除事件。例如,客户端 A(lock-0000000002)会监听 lock-0000000001 的删除事件。一旦被监听的节点被删除(即前一个客户端释放了锁),ZooKeeper 会通知客户端 A。客户端 A 被唤醒后,重新执行第 2 步。
(2)释放锁流程
- 正常释放:客户端 A 完成业务逻辑后,主动删除自己创建的那个临时节点(如 lock-0000000001)。
- 异常释放:如果客户端 A 在持有锁期间发生宕机或网络断开,其与 ZooKeeper 的会话会失效。根据 ZooKeeper 的特性,该会话创建的所有临时节点都会被自动删除。这相当于自动释放了锁,完美地避免了死锁问题,无需像 Redis 那样设置超时时间。
5. 最后总结
纵观以上几种分布式锁的实现方案,每一种都有其独特的优势和适用的场景,选择何种方案往往是在一致性、性能、复杂度和运维成本之间做权衡。
对于数据竞争不激烈、并发量不大的场景,可以使用基于数据库的乐观锁作为分布式锁;而若对数据一致性有要求且业务场景无须担心性能开销,也可以使用数据库的悲观锁。当面对高并发、高性能需求时,Redis 成为首选,尽管在极端情况下存在锁失效的风险,但其简洁高效的特点使其成为互联网应用中最普遍的选择。而对于金融、交易等对正确性有严苛要求的场景,ZooKeeper 凭借其强一致性和高可用性,往往更加适合。
简而言之,没有完美的方案,只有最适合的方案。理解每种方案的底层原理和特性,才能根据实际业务场景的技术要求、团队的技术储备和运维能力,做出最明智的选择。
六、分布式缓存
在大多数系统中,最好是的往往是数据库,毕竟数据库中的数据一般都是存储在磁盘上,而相较于内存读写速度和CPU的处理速度,磁盘的读写速度要慢很多(要差好几个数量级)。数据库一般分为增删改查(insert、delete、update、select)四个操作,对于大多数应用来说,查询往往要多于其他三个操作,而且,很多查询操作也比较复杂,需要级联、排序等等,性能极易出现问题。对于高性能、高并发的系统来说,缓存是架构设计中必不可少的组件,可以大大减少数据库的访问压力,提高系统的数据查询性能。
1. 多级缓存
缓存按照层级分类的话,可以分为L1 Cache(本地缓存)和L2 Cache(分布式缓存),可以单独使用L1,也可以单独使用L2,当然,也可以L1+L2同时使用,为系统构建多级缓存。
(1)L1 Cache(本地缓存)
L1 Cache 是应用程序内部的本地缓存,在水平扩展的多节点部署架构中,每个应用服务器都拥有独立的 L1 缓存。这类缓存通常可通过 Caffeine、Guava Cache 或 Ehcache 等成熟缓存库实现,若缓存逻辑较为简单,也可直接基于 ConcurrentHashMap 自行构建。
L1 Cache 具有访问延迟极低、无网络开销的优点,但其容量受单机内存限制,无法在集群节点间共享数据,可能导致数据不一致;同时进程重启会造成缓存数据丢失。
因此,L1 Cache 适用于以下场景:变更频率低、可接受短期不一致的只读或低频写数据;访问极其频繁的少量数据,如系统基础配置、热点 Key 等。例如,在超能简历项目中,简历模板和产品信息这类几乎不变更且高频访问的数据,就非常适合存储在 L1 Cache 中。
(2)L2 Cache(分布式缓存)
主流的 L2 Cache 通常采用 Redis实现,当然,也可选用 Memcached。前者所支持的数据结构类型更加丰富。L2 Cache 不再与应用程序共处于同一进程,而是需要独立部署。
之所以称为分布式缓存,是因为它能够像数据库分库分表那样,将数据分片存储在不同的 Redis 实例中,形成一个可水平扩展的缓存集群(Redis Cluster)。
相比于 L1 Cache,L2 Cache 的优势在于容量更大、易于扩展。但由于需要经过网络 I/O 以及数据的序列化与反序列化过程,其访问速度通常低于 L1 Cache,延迟一般在微秒到毫秒级别。
(3)L1 Cache + L2 Cache
多级缓存架构,既可以兼顾L1的高性能,也可以兼顾L2的大容量,但是保证数据的一致性相对较难。更新数据时,不仅要写数据库、删 L2,还要通知所有节点删除各自的 L1(需要通过消息队列或 Redis Pub/Sub)。L2 Cache基于分布式Redis(Redis Cluster),可以无限水平扩展,性能极好,延迟已在毫秒级,再加 L1 提升有限。引入 L1 后需要额外的开发、测试、运维成本。
当然,在同一个系统里,我们可以基于数据的特点,部分数据存储在L1 Cache,部分数据存储在L2 Cache,但同一数据往往既存储在L1 Cache,又存储在L2 Cache。
2. 缓存一致性
在实际项目中,大多数系统选择仅使用 L2 缓存(Redis),因此,我们重点看数据库和L2缓存的数据一致性问题。
缓存作为数据库的“前置副本”,存储的是热点数据。当数据发生变更(增、删、改)时,如果只更新数据库而不更新缓存,缓存中就会残留旧数据,导致后续读请求读到脏数据。因此,更新数据库的同时,需要更新缓存。
常见更新策略有以下几种。
(1)先更新数据库,再更新缓存
这种策略在并发场景下可能出现数据不一致。例如,线程 A 将数据库从 1 改为 2,线程 B 将数据库从 2 改为 3。由于线程调度,可能发生:
- A 更新数据库(1->2)
- B 更新数据库(2->3)
- B 更新缓存(1->3)
- A 更新缓存(3->2)
最终数据库为 3,缓存为 2,不一致。
如果换做先更新缓存,再更新数据库,同样也会并发更新导致的不一致问题。
(2)先更新数据库,再删除缓存
将更新缓存改为删除缓存,可以避免因以上因并发更新导致的数据库和缓存不一致问题。线程A在更新完数据库之后,会选择删除缓存;线程B在更新完数据库之后,也会选择删除缓存。不管它们的执行顺序如何交叉,最终的效果都是缓存中的数据被删除。下次读请求会从数据库重新加载,保证最终一致性。
在“更新数据库”与“删除缓存”之间,若有读请求到来,会命中旧缓存,返回脏数据。但这个窗口通常很短(毫秒级),大多数业务可接受。如果对一致性要求非常高的数据,建议直连数据库,不使用缓存。
那么,能不能先删除缓存,再更新数据库?答案是不行!
若删除缓存后、更新数据库前,有其他读请求到来,因缓存缺失而读取数据库旧值并写回缓存,导致缓存再次变脏,且后续更新数据库后无法修复该脏数据。此时不一致窗口更长,不推荐使用。
(3)延迟双删
实际上,先更新数据库,再删除缓存,在极端情况下,仍然会有问题。
- 线程A请求数据X,缓存中不存在,于是,数据库中读取X值1。
- 线程B更新数据库中的X=2
- 线程B删除缓存中的X(实际上缓存中没有X)
- 线程A将从数据库中读取的X值回写到缓存
此时,数据库中X=2,缓存中X=1,数据不一致!
怎么解决这个问题呢?在线程B更新数据库之后,延迟删除缓存,延迟保证了即便有其他线程恰巧在更新数据库前读取了旧数据,那我的删除缓存操作也要等到它将数据库的旧值写入到缓存之后再执行。
当然,如果先前缓存中就已经有数据,延迟删除缓存,也会导致高并发下,大量请求可能获取到缓存中的旧值。为了尽量减少不一致的时间窗口,我们可以先删除缓存,再更新数据库,最后再延迟删除缓存,也就是所谓的延迟双删。
- 删除缓存
- 更新数据库
- 休眠一段时间(略大于读请求从数据库加载并写入缓存的耗时)
- 再次删除缓存
我们再看另一个新问题:缓存删除失败怎么办?
在实际运行中,删除缓存可能因网络抖动、Redis 故障等原因失败。若删除失败,缓存将一直保留旧数据,导致长期不一致。对应的解决方案:
- 设置TTL:为缓存设置过期时间,即使删除失败,缓存也会在 TTL 后自动失效,从而保证最终一致性。TTL 应根据业务对一致性的容忍度来设定(如 5 分钟、1 小时)。
- 异步重试:将删除操作写入本地消息队列或借助消息中间件,由后台线程持续重试直到成功。
- 监控报警:记录删除失败日志,通过监控系统及时发现并人工介入。
3. 穿透击穿雪崩
我们来讲讲面试中经常会遇到的几个概念:穿透、击穿、雪崩。
(1)缓存穿透
可能是恶意攻击或业务逻辑错误,查询一个数据库中根本不存在的数据(如查询不存在的用户ID),导致每次请求都穿透缓存直达数据库,如同缓存不存在,这就是缓存穿透。
对应的解决方案有:
- 缓存空对象 (Null Object):即使数据库查询为空,也在缓存中存储一个表示“空”的特殊值(如null, {}, 或特定标志对象),并设置一个较短的TTL。
- 布隆过滤器 (Bloom Filter):写数据库时,同步写入本地内存中的布隆过滤器(参看《数据结构与算法之美》,可基于Guava BloomFilter构建)。在访问数据库前,先访问布隆过滤器,如果数据不存在,就不要访问数据库了。
(2)缓存击穿
某个热点Key(访问量巨大)在缓存中过期失效的瞬间,大量并发请求同时无法从缓存读取,直接涌向数据库,导致数据库瞬时压力剧增甚至崩溃,这就是缓存击穿。
对应的解决方案有:
- 互斥锁:当缓存未命中时,不是立即去查数据库,而是先尝试获取一个针对该Key的分布式锁。获取锁成功的线程负责查数据库、重建缓存。其他未获取锁的线程等待一小段时间后重试从缓存读取。
- 逻辑过期:缓存数据中不设置TTL(即便到期也不会从缓存中删除),而是存储一个过期时间字段。应用读取缓存时,检查过期时间。如果未过期,直接返回数据。如果已过期,尝试获取分布式锁。获取锁成功的线程异步去重建缓存(避免阻塞当前请求)。所有请求直接返回旧的、已过期的缓存数据(牺牲一致性,保证可用性)。
- 永不失效 (针对极热点Key): 后台定时任务主动更新缓存,缓存本身不设置过期。
(3)缓存雪崩
在同一时间点,大量缓存Key集中过期失效,或者缓存服务(如Redis集群)整体宕机,导致所有请求直接涌向数据库,数据库压力瞬间激增甚至崩溃,这就是缓存雪崩。
对应的解决方案有:
- 差异化过期时间: 给缓存Key设置TTL时,在基础值上增加一个随机因子(如基础TTL + random(0, 300s)),避免大量Key同时到期。
- 构建高可用缓存集群: Redis Sentinel, Redis Cluster 保证服务可用性,避免单点故障。
- 服务降级 & 熔断:使用Hystrix, Sentinel等组件,当检测到数据库访问超时或错误率激增时,快速失败,直接走降级逻辑(直接返回错误提示、默认值),给数据库恢复时间。
- 提前预热: 对于已知的热点数据(如首页数据、活动数据),在访问高峰来临前、缓存失效前,通过定时任务或人工触发主动加载到缓存。
4. 大Key和热Key
我们来讲讲影响Redis分布式缓存性能的两大杀手:大Key和热Key。这也是面试中经常考的知识点。
(1)大Key问题
大Key通常是指单个Key对应的Value数据量异常庞大的情况,当然,Key本身很大的情况也算,但不常见。具体阈值需根据业务和Redis实际性能而定,常见标准:
- String类型:Value > 10KB 或 100KB
- List/Hash/Set/ZSet类型:元素数量 > 1000 (或 5000, 10000) 或 Value总大小 > 100KB (或 1MB)
- Key本身长度过长(如几百字节)
大Key产生的原因也很好理解。你想想,在我们为系统构建缓存时,并不会缓存所有的数据,往往会缓存那些访问速度慢、访问频率高的数据,当然还包括读多写少这个基本要求。
我们往往会通过阅读代码以及分析SQL,找到这类业务数据。那么,什么样的SQL访问会比较慢呢?大表、无索引查询、级联,当然,还有一个最常见的就是一次性搂太多数据出来。这么多数据放到Redis中,就会导致产生大Key,影响到性能。当然,产生的原因还有其他,比如,持续的向Redis的List、Hash结构的value中添加数据。又或者,程序员本身使用不当或代码Bug等。
那么,如何发现大Key呢?其实,Redis本身提供了命令来查找大Key。但是这个内置的命令不建议在产线上使用,会影响到Redis本身的性能。更好的方法是,在代码中记录访问日志,类似记录慢SQL日志一样。我们也可以对缓存的访问,记录日志。然后,离线分析日志,找出大Key,以及待会讲到的热Key。
redis-cli --bigkeys当发现了大Key之后,怎么解决呢?一种可能是你使用不当或者代码bug,直接修改代码就好了,还有一种是确实是不可避免的大Key。这种情况可以启用更加高效的序列化协议,比如Protobuf等,也可以对value数据进行压缩。如果还是解决不了问题,可以考虑水平拆分或者垂直拆分。
垂直拆分指的是将大Hash/List/ZSet拆成多个小Key(如user:1000:profile:basic, user:1000:profile:contact)。水平拆分指的是将大集合分片存储(如article:1000:comments:1, article:1000:comments:2,每片存N条评论)。
(2)热Key问题
热Key指在特定时间段内访问频率远高于其他Key的Key。例如,某个明星的微博详情、热门秒杀商品信息。其实,Redis的访问性能非常高,QPS可以达到10万,像我们平时做的项目,基本上不用考虑这个问题。
Redis同样提供了命令来查找热Key。当然,产线也要慎用,建议走缓存访问日志离线统计分析。京东开源的hotkey工具(轻量级热Key探测SDK)也可以使用。这里就不展开讲hotkey了,你可以自行百度学习。
redis-cli --hotkeys
解决热Key问题最常用有效的方案是使用L1 Cache本地缓存。当然,也可以将热Key拆分成多个Key(如hotkey:1, hotkey:2, ...)存储相同的value,这些key存储到不同的Redis实例,客户端随机选择访问其中一个Redis实例,这样就均摊了热key的访问压力。
5. 最后总结
本节课全面的讲解了架构设计中非常常用的组件:缓存,它对性能提升效果非常明显。我们讲到缓存的多级架构(L1 Cache、L2 Cache),缓存的一致性问题、缓存穿透、击穿、雪崩、大Key、热Key等问题的产生和解决。
七、分布式事务
在前面讲解微服务架构的时候,我们已经提到过分布式事务问题,系统被拆分为多个小的微服务之后,每个服务拥有自己独立的数据库。一个原本在单体应用中简单的数据库事务操作,现在却可能跨多个服务、多个数据库,这个时候要保证数据的一致性、操作的原子性,就需要分布式事务的支持。
1. 分布式事务
我们拿电商系统中的下单业务来举例。下单会涉及订单的创建和库存的扣减两个操作。如果订单创建成功,那么,库存一定要扣减成功。反过来,库存扣减成功,对应的订单也要创建成功。也就是说,订单创建和库存扣减相当于一个原子操作(要么都执行成功,要么都执行失败),订单和库存需要保持数据的一致性。
在单体架构中,单个应用依赖单个数据库,订单表和库存表在同一个数据库中,我们只需要把创建订单的SQL操作和扣减库存的SQL操作,放到同一个数据库事务中执行,就可以轻松实现以上的业务要求。但是,在微服务架构中,订单操作和库存操作可能会拆分到不同的微服务中,又或者因为数据规模太大,订单表和库存表拆分到不同的数据库中,这个时候,针对多服务或者多数据库的操作,就需要使用分布式事务来保证操作原子性和数据的一致性。补充一句,跟分布式事务相对应的是本地事务,也就是针对单数据库的事务。
前面讲解MySQL的时候,我们提到MySQL事务的四个特性ACID,这里我们再回顾一下。
- 原子性(Atomicity):一个事务中的所有操作要么全部执行成功,要么全部失败。如果事务在执行过程中发生错误,那么,就会回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性(Consistency):事务在执行前后,数据是满足各种约束条件的(包括数据库表的约束和业务约束),比如在转账业务中,一个账户增加了 100 元,另一个账户对应的应该减少 100 元。
- 隔离性(Isolation):数据库系统提供一定的隔离机制,保证多个并发执行的事务不会相互干扰,仿佛它们是串行执行一样。隔离性定义了事务之间的可见性规则,防止出现脏读、不可重复读、幻读等问题。不同隔离级别(读未提交、读已提交、可重复读、串行化)提供了不同强度的隔离保证,在性能和数据一致性之间进行权衡。
- 持久性(Durability) :一旦一个事务被提交,它对数据库所做的修改就是永久性的,即使发生系统故障(如断电、崩溃),数据也不会丢失。数据库通常通过预写日志(Write-Ahead Logging, WAL)等机制来保证持久性,即在数据页被实际修改之前,先将修改操作记录到持久化的日志中。这样,即使在故障后,系统也能通过重放日志来恢复已提交的事务结果。
理论上,分布式事务也应满足以上ACID事务特性,但由于分布式环境下网络分区的存在,很难做到严格满足ACID特性,特别是在强一致性方面(即事务提交完成后数据即时一致)。对于大多数互联网项目,对性能的要求比较高,且大多数业务并不需要强一致性,因此,最终一致性解决方案是在解决分布式事务问题时,用的最多的。所谓的最终一致性指的是,在事务提交之后,允许经过一段时间,数据才达到一致。更加通俗一点就是,数据不一致的时间窗口比较大。
接下来,我们来看下常见的分布式事务解决方案。
2. 2PC和XA
我们先来看分布式事务最经典的解决方案:两阶段提交(Two-Phase Commit,简称2PC)和XA协议,它提供了强一致性的分布式事务。需要注意的是,两阶段提交是一种实现原理或者说抽象的处理流程,XA协议是一个标准规范,将2PC处理流程规范化,定义了各种角色以及角色之间的通信接口规范。
XA协议包含三个核心角色,其中后两个尤为重要,是事务的主要参与者。
- 应用程序(Application Program, AP):定义事务的边界(开始、结束),发出业务操作指令。例如,调用orderService.create()和inventoryService.deduct()执行事务。
- 资源管理器(Resource Manager, RM):通常就是数据库(如MySQL, Oracle, PostgreSQL),也可以是支持事务的消息中间件(比如ActiveMQ)。它负责管理自己内部的事务(本地事务),并暴露基于XA规范的接口供TM(下面讲到)调用,例如MySQL InnoDB就提供了符合XA协议规范的接口。
- 事务管理器(Transaction Manager, TM):作为事务的协调者,负责协调和管理全局事务,控制事务的提交和回滚。TM通过RM暴露的接口与多个RM进行通信,驱动它们完成两阶段提交流程,比如,Java中的JTA。
需要注意的是,JTA全称为Java Transaction API,其实,它也只定义了TM实现的Java语言接口,如果要在Java项目中使用两阶段提交这种分布式事务,还需要引入第三方的JTA实现(即真正的TM),比如Atomikos、Narayana,以及使用Web应用服务器(如 WebLogic, WebSphere, JBoss,注意Tomcat不支持)内置的JTA实现。这些JTA实现会调用数据库RM暴露的XA协议接口来实现两阶段提交分布式事务。
(1)2PC执行流程
了解了以上概念之后,我们具体来看下2PC处理流程。2PC将整个事务的处理过程分为两个阶段:第一个阶段是准备阶段,第二个阶段是提交/回滚阶段。
第一阶段:准备阶段(Prepare Phase),即投票
TM向所有RM发送Prepare消息,消息内容是:“我准备要执行这个全局事务了,内含这些操作,你们是否能完成?”。每个RM收到消息后,会执行事务中的所有SQL操作(写入数据、更新记录),但不提交。它会将 undo(用于回滚)和 redo(用于提交)信息写入本地事务日志中。这一步至关重要,它确保了即使后续RM崩溃,在恢复后也有能力提交或回滚。
如果RM的本地事务执行成功,RM就向TM回复一个 Yes 消息,意思是:“我准备好了,保证能提交”。如果RM的本地事务执行失败(如违反唯一约束),RM就向TM回复一个 No 消息,意思是:“我这边出问题了,无法提交”。
TM 为每个全局事务维护一个超时计时器。在发出所有 prepare 请求后开始计时。如果某个 RM 在超时时间内没有返回任何响应(无论是网络故障、RM 宕机还是其他原因),TM 会将这个 RM 视为“No”。
此时,所有RM都已经锁定了事务相关的资源,其他事务无法修改这些数据,进入了阻塞等待状态。
第二阶段:提交/回滚阶段(Commit/Rollback Phase),即执行决议
TM收集所有RM的投票。如果TM收到了所有RM的Yes回复,TM首先将自己的决策(提交),持久化到日志中(这是为了防止自己在此刻崩溃),然后向所有RM发送 commit 指令。RM收到commit后,正式提交本地事务,释放锁定的资源,并向TM发送ack确认。TM收到所有RM的ack后,整个全局事务完成。
如果TM收到了任何一个RM的No回复,或者等待超时,TM同样将决策(回滚),持久化到日志中,然后向所有RM发送 rollback 指令。RM收到rollback后,利用之前写入的undo日志回滚本地事务,释放资源,并发送ack确认。TM收到所有ack后,整个全局事务回滚完成。
如果某个RM在提交或回滚过程中宕机,导致TM未收到该RM的ack,TM会采取以下措施:
- TM在发送commit指令后,会启动一个超时计时器,等待所有RM的ack。
- 若超时未收到某个RM的ack,TM不会立即判定该RM失败,而是持续重试发送commit指令。重试间隔和次数通常可配置(如每隔几秒重试一次,最多重试N次)。
- 这种重试是为了应对网络抖动、RM临时不可用等瞬时故障。只要RM在重试窗口内恢复,它就能接收到commit指令,完成提交并返回ack,从而达成一致。
如果重试耗尽后仍未收到该RM的ack,TM会将该事务标记为未决事务,并记录下来。恢复线程会扫描未完成的事务,重复上述重试过程,直到事务最终被解决(如RM恢复工作、或者人工介入处理)。
(2)2PC性能问题
2PC虽然实现了强一致性,但存在严重的性能问题。性能问题核心源于其同步阻塞和资源锁定,这在高性能压力的互联网场景下是致命的。性能问题产生的具体原因有以下几点:
- 漫长的资源锁定
从第一阶段收到prepare消息,并预执行本地事务开始,RM就必须一直持有事务相关的所有数据库锁(如InnoDB行锁),直到第二个阶段,通过决议,TM允许RM提交或者回滚事务才能结束。这个过程需要多次网络交互,耗时很长,因此,RM资源锁定的时间很长。在高并发场景下,这意味着大量事务,可能会因为等待这些被锁定的关键资源,而陷入阻塞。
- 高昂的通信成本
2PC是一个同步阻塞式的协议。TM在发出一个指令后,必须等待所有RM的响应,才能进行下一步。在整个过程中,TM和RM之间需要进行多轮网络往返通信。每一次网络通信都意味着延迟增加。RM越多,网络状况越复杂,整个事务的耗时就越长。
- 单点吞吐量瓶颈
TM作为协调中心,需要处理所有事务的发起、投票收集、决策和指令分发。在高并发场景下,同时开启大量2PC分布式事务会导致TM很容易成为系统的性能瓶颈。
为了提高2PC的性能,减少RM和TM的阻塞时间,于是,人们又提出了3PC方案。3PC在2PC的前面增加了一个阶段:预检查阶段。在此阶段中,RM进行可行性检查,但并不会去实际执行事务SQL和对资源加锁。具体检查包括数据库RM的基础运行情况(数据库是否可用、连接是否正常、数据库负载是否在正常范围等)、预先解析SQL检查是否正确、是否具有操作权限、约束是否冲突、资源锁是否可以获取等。
其实,你也应该已经发现,3PC仅仅是对于可能失败的情况,让其尽快失败,并不能完全解决2PC的性能问题。因此,高性能、高并发要求的互联网应用,基本都不会选择2PC/3PC实现分布式事务,除非对于一致性要求高的场景(比如金融)才会使用它,宁肯失败、宁肯阻塞、宁肯人工介入,我也要它一致!
3. TCC补偿
为了解决性能问题,我们来看另一种分布式事务解决方案:TCC(Try-Confirm-Cancel)。TCC是一种业务层面的分布式事务解决方案,也就是说,需要业务上配合改动代码,这是TCC跟2PC的最大不同。
TCC将一个完整的事务拆分为三个阶段,业务开发工程师需要编写相应的业务逻辑。
- Try(尝试阶段)
在微服务架构中,一个业务操作可能需要调用多个服务。协调者调用所有服务提供的Try接口,进行可行性检查并锁定必要的业务资源(预留资源),例如:冻结库存、冻结优惠券、预扣账户余额,为后续的执行做准备。
- Confirm(确认/提交阶段)
协调者调用所有服务提供的Confirm接口。因为Try阶段资源已预留,Confirm阶段的操作通常一定能成功。例如:将冻结的库存实际扣减、将预扣的余额真正划走、将优惠券设置为已用。需要注意的是,Confirm接口要支持幂等,因为协调者为了保证confirm不会因为网络抖动、超时等执行失败,会有重试的机制。
- Cancel(取消/回滚阶段)
协调者调用所有服务提供的Cancel接口。用于回滚Try阶段的操作,取消业务,释放Try阶段预留的资源。例如:释放冻结的库存、归还预扣的余额、解冻优惠券。同样,Canccel接口也需要支持幂等。
TCC基本的处理流程跟2PC是大同小异的,协调者调用每个服务的Try,如果都返回成功,则执行Confirm,否则,执行Cancel。唯一的区别是,TCC是业务层面的,2PC是数据库层面的。2PC不侵入业务,无须改动业务逻辑。而TCC侵入业务,需要对业务进行梳理、拆解,需要业务开发者配合开发相应的Try、Confirm、Canel业务逻辑。TCC的好处在于不管是Try、Confirm还是Cancel,每个阶段都是本地事务执行完就提交了,不会阻塞,不耽误其他本地事务的执行,它不像2PC那样,本地事务一直等到两阶段都结束之后才提交。
在上述TCC的处理逻辑描述中,我们反复提到协调者,也就是全局事务管理器,在实际项目中,我们不会从零开始实现TCC的事务管理器,通常会使用开源框架:
- Seata (Alibaba): 目前最流行的开源分布式事务解决方案。它支持 AT模式(类似2PC,无侵入)、TCC模式、Saga模式和 XA模式。
- ByteTCC: 国内早期开源基于Spring Cloud的TCC框架。
- Hmily (ShardingSphere生态): 高性能分布式事务框架,支持TCC、Saga等模式。
4. Saga模式
我们再来看另外一种分布式事务的解决方案:Saga,它跟TCC一样,也是工作在业务层,区别在于它不像TCC那样先尝试锁定资源,再真正执行业务,而是直接就“干”(执行业务),中途失败了再撤销已经执行的业务。
在微服务架构中,一个需要支持事务的业务操作,可能需要调用多个微服务的操作(以下简称服务操作)。我们给每个服务操作配备一个补偿操作,补偿操作用于撤销服务操作的业务效果,比如服务操作是“预订酒店房间”,那么,补偿操作就是“取消酒店预订”。我们按顺序执行事务涉及的所有服务操作,如果都成功完成,那么整个事务就完成了。如果其中任何一个服务操作失败,则按照相反的顺序,执行之前所有已成功的服务操作的补偿操作,从而撤销整个事务的影响。
2PC和TCC都需要协调者来协调各个参与者,那么Saga也不例外。前面提到阿里开源的分布式事务框架Seata,已经集成了Saga模式,那么,我们就看看Seata是如何协调执行各个服务操作和补偿操作的。
Seata Saga使用一个状态机引擎作为中央协调器,来定义和执行分布式事务的流程。业务流程中的每一个步骤(例如调用一个服务)和它的补偿操作都明确定义在状态表中,由状态机引擎负责驱动整个流程的执行、状态转换以及故障后的补偿。
我们拿个具体的例子来看下其操作流程。假设我们有一个订单流程,需要依次调用库存服务扣减库存,调用积分服务扣减积分,调用订单服务创建订单。如果任何一步失败,则需要补偿(撤销)之前所有成功的步骤。
第一步:引入依赖
在您的项目中引入 seata-saga-starter(以 Spring Boot 为例)。
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-saga-starter</artifactId>
<version>最新版本</version>
</dependency>第二步:定义状态机 JSON
这是最关键的一步。你需要创建一个 JSON 文件(如 order_saga.json)来定义整个流程。
{
"Name": "createOrderSaga",
"Version": "1.0.0",
"States": {
"reduceInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryService", // 服务名
"ServiceMethod": "reduce", // 扣减库存方法
"CompensateState": "compensateReduceInventory", // 对应的补偿状态名
"Next": "deductCredit" // 成功后执行下一个状态
},
"compensateReduceInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryService",
"ServiceMethod": "compensateReduce" // 补偿方法:增加库存
},
"deductCredit": {
"Type": "ServiceTask",
"ServiceName": "creditService",
"ServiceMethod": "deduct",
"CompensateState": "compensateDeductCredit",
"Next": "createOrder"
},
"compensateDeductCredit": {
"Type": "ServiceTask",
"ServiceName": "creditService",
"ServiceMethod": "compensateDeduct" // 补偿方法:增加积分
},
"createOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "create", // 创建订单
"CompensateState": "compensateCreateOrder",
"End": true // 这是最后一个状态
},
"compensateCreateOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "compensateCreate" // 补偿方法:删除订单
}
}
}第三步:实现服务接口
需要实现上述 JSON 中定义的业务方法和补偿方法,例如下面这样。
@Service
public class InventoryServiceImpl implements InventoryService {
@Override
public void reduce(String businessKey, Map<String, Object> params) {
// 业务逻辑:扣减库存
String productId = (String) params.get("productId");
Integer count = (Integer) params.get("count");
// ... execute update sql: update stock set count = count - #{count} where product_id = #{productId}
}
//注意:Seata 通过 @Compensable 注解来标识一个方法是补偿方法。
@Override
@Compensable
public void compensateReduce(String businessKey, Map<String, Object> params) {
// 补偿逻辑:增加库存
String productId = (String) params.get("productId");
Integer count = (Integer) params.get("count");
// ... execute update sql: update stock set count = count + #{count} where product_id = #{productId}
}
}第四步:发起调用
在你的业务代码中,通过 Seata 的 API 启动这个 Saga 事务。
@RestController
public class OrderController {
@Autowired
private StateMachineEngine stateMachineEngine;
@PostMapping("/createOrder")
public String createOrder() {
Map<String, Object> startParams = new HashMap<>();
startParams.put("productId", "P001");
startParams.put("count", 2);
startParams.put("userId", "U001");
startParams.put("credit", 100);
StateMachineInstance inst = stateMachineEngine.startWithBusinessKey(
"createOrderSaga", // 状态机名
"1.0.0", // 版本号
"businessKey_123", // 业务键
startParams // 参数
);
return "Saga started, id: " + inst.getId();
}
}Saga 和 TCC 都属于业务层分布式事务解决方案,都需要业务代码配合实现补偿逻辑,也都需要一个事务管理器(TM)来协调流程。但在核心设计理念、资源处理方式以及适用场景上有明显差异。
| 维度 | TCC | Saga |
|---|---|---|
| 资源处理 | Try 阶段预留资源(冻结库存、预扣余额),Confirm/Cancel 再真正提交或释放。 | 直接执行业务操作,不预留资源。失败后通过补偿操作(执行反向操作)来回滚。 |
| 事务阶段 | 三个阶段:Try → Confirm(全成功)或 Try → Cancel(任一失败)。 | 正向操作序列 + 逆向补偿序列。正向操作依次执行,任一失败则反向补偿已成功的操作。 |
| 隔离性 | 较强。Try 阶段通过资源预留,防止其他事务并发修改同一资源,保证最终一致性。 | 较弱。事务执行过程中,中间状态对外可见(例如库存已扣减但订单未创建),需要业务层面处理并发影响。 |
| 补偿复杂度 | 补偿(Cancel)只需释放 Try 阶段预留的资源,逻辑相对简单且可逆。 | 补偿操作需要实现“撤销”正向操作的效果,可能涉及复杂的数据修复(例如已发货无法简单撤销,需要发起退款流程)。 |
通过对比,看起来TCC更优秀,为什么还需要Saga呢?
主要原因是,TCC 的 Try 阶段需要冻结资源,业务改造成本高,这在某些业务场景中可能很困难甚至不可行。比如,调用第三方服务(如支付宝支付、短信平台),你无法要求它实现“预留余额”或“预占额度”的 Try 接口。Saga 可以直接调用正向操作(扣款),失败后通过退款补偿,这在对接外部系统时更现实。再比如,调用第三方 API 发货,一旦调用成功,无法“冻结”发货指令,只能后续发起退货流程。复杂业务状态,如“创建工单”后可能触发一系列审批,没有“预创建”的语义。TCC 在这些场景下要么无法实现,要么需要设计非常别扭的“预留-确认”模式。
5. 本地消息表
不管是TCC还是Saga都要改造业务,还要引入TM(比如Seata)。有没有简单点的分布式事务解决方案呢?
本地消息表是分布式事务中实现最终一致性的一种经典方案,最早由 eBay 提出。其核心思想是将分布式事务拆分为多个本地事务,利用数据库表记录消息状态,通过轮询和重试来保证消息的可靠投递与操作的最终执行。
以订单创建和扣减库存为例,我们来看他的处理流程:
- 在订单服务中开启本地事务,同时完成两件事:插入订单记录,并向“本地消息表”中插入一条待发送的消息(例如“扣减库存”)。这两个操作在同一个本地事务中执行,要么都成功,要么都失败。
- 本地事务提交后,通过一个独立的后台线程(或定时任务)轮询本地消息表,将状态为“待发送”的消息发送到消息中间件(如 RocketMQ、RabbitMQ)。
- 库存服务消费该消息,执行库存扣减操作。扣减成功后,向消息中间件返回确认(手动 ACK),或通过回调接口通知订单服务更新消息状态为“已处理”。
- 如果消费失败(如库存服务宕机),消息会重新投递。库存服务需要保证消费的幂等性,避免重复扣减。
6. 消息事务
基于本地消息表的分布式事务解决方案存在严重的问题,那就是业务侵入,我们要给每个需要支持事务的业务建立一个消息表,并且消息表必须跟业务表在同一个数据库中。有没有跟业务更加松耦合的解决方案呢?
有,就是消息事务!消息事务是 RocketMQ 等消息中间件提供的一种“事务消息”能力,它本质上是本地消息表的一种优化,将消息的发送与本地事务的执行绑定在一起,通过消息中间件来协调事务状态。
我们来看下RocketMQ 事务消息流程:
- 准备阶段(Half消息): 生产者发送一条半事务消息(Half Message) 到Broker。Broker将该消息持久化到内部主题 RMQ_SYS_TRANS_HALF_TOPIC(对消费者不可见),并返回ACK确认。预占消息队列位置,确保消息不丢失。
- 本地事务执行: 生产者收到ACK后,执行本地事务。根据本地事务结果(成功/失败),向Broker发送二次确认指令。本地事务成功,则发送Commit指令,将Half消息转为正式消息,投递给消费者。本地事务失败,则发送Rollback指令,删除Half消息,消息永不投递。
- 事务状态回查(补偿机制): 若生产者未发送二次确认(如宕机或网络故障),Broker会定时回查生产者(默认间隔60秒)。生产者需实现 checkLocalTransaction 方法,检查本地事务状态并返回结果(Commit/Rollback/Unknown)。默认最多回查15次,超时未决则自动回滚。
7. 业务规避
在开发中,很多问题其实都不是技术问题,很多问题的解决也都不需要高深的技术。有时候,通过业务的妥协或者调整优化,一些技术上很难解决的问题,反倒可以轻易解决。所以,不要独立开业务场景,去考虑分布式事务问题。
我们举一个例子:注册“投资用户”流程需要涉及两步操作,一个是创建用户账号,一个是帮用户创建钱包,分别需要调用两个远程接口,createUser(),createWallet(),伪代码如下;
register(userinfo) {
long id = IdGenerator.getId();
createUser(id, userinfo);
createWallet(id, wallet);
}很明显,这里涉及到分布式事务问题,如果createUser成功了,而createWallet失败了,会导致这个用户没有钱包。如果一个用户没有钱包,这个会导致用户没法充值支付,是我们业务上面没法接受的,所以必须要保证创建user和创建wallet的一致性。
前面讲到的各种分布式事务解决方案,都可以解决这个问题,但是要添加比较复杂的逻辑,注册逻辑本来简单清晰,支持分布式事务之后,复杂度升高好几个数量级!其实,对于这个业务场景,我们只需要稍微调整一下代码的顺序,即可完美解决,具体如下所示。
register(userinfo) {
long id = IdGenerator.getId();
createWallet(id, wallet);
createUser(id, userinfo);
}如上代码所示,我们先创建wallet,后创建user:
- 如果创建wallet成功,创建user失败,报错register failed
- 如果wallet创建失败,则直接返回register failed
- 如果wallet创建成功,创建user成功,则register successfully.
你可能会说,上面的执行流程,仍然有可能产生不一致的情况,即只创建了wallet,而没有创建user。不过,我们的业务是可以接受这种不一致的,一个没有主子(user)的野wallet是没有关系的,而一个没有wallet的user是我们不能接受的。即便我们没法接受野wallet,我们也可以通过job拿wallet跟user做reconcile,如果wallet没有主子,则将其删掉。
你看,当你考虑为解决分布式事务问题使用复杂的解决手段时,可以先从业务的角度去分析,是否有更加简单的绕过分布式事务问题的办法。
8. 兜底方案
首先我们要知道,我们解决的是工程问题,而非理论问题。所有的工程问题的解决方案,都是基于特定场景权衡的结果,没有最优、最完美、放之四海皆准的方案。
前面也提到,引入复杂的分布式事务解决方案,会导致代码复杂度升高很多,而仅仅就是为了解决极端情况下的不一致,代价还是稍微有点大的。
所以,我们在引入复杂解决方案之前,一定要好好审视业务,审视极端情况发生的概率,导致的影响有多大,如果接口每天只有很少的访问量,极端情况又很少发生,如此浩大的工程只是为了解决每月一两个的数据不一致,这是否是值得的呢?
当然,也并不是说极少的不一致我们就不处理了,只是不需要动用这么复杂的自动化手段,是不是通过对账或人工兜底,处理起来更加简单高效呢?
其实,我觉得,任何方案只要能满足下面这个最低底线都是好方法:不一致可观测、可发现,可通知,可查,可修复,早于用户发现问题解决问题。要对不一致情况有预期、有预案,清晰的知道有哪些不一致情况发生,发生之后会对业务带来哪些影响,如何恢复,恢复成本多大,这就足够了。
9. 最后总结
本节课我们讲解了多种分布式事务的解决方案,比如2PC、TCC、Saga、本地消息表、消息事务,其中,2PC能保证数据的强一致性,也就是数据不一致的时间窗口很小,TCC、Saga、本地消息表、消息事务能保证数据最终一致,也就是数据不一致的时间窗口可能会很大。不过,2PC性能比较差,对于高并发、高性能要求的互联网应用来说,更倾向于选择只需要满足最终一致性、执行性能更高的TCC、Saga、本地消息表、消息事务等解决方案。当然,如果业务能规避分布式事务肯定是最好的,即便不行,我们也优先考虑使用人工兜底的方法,人工解决极少发生的数据不一致问题,而非引入复杂的分布式事务解决方案。
