【转】如何用消息系统避免分布式事务?

本文探讨了在高并发系统中实现转账等操作时面临的数据一致性问题,介绍了本地事务与分布式事务(如两阶段提交协议)的原理及其局限性,并提出了一种利用消息队列实现最终一致性的解决方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

转自 http://www.cnblogs.com/LBSer/p/4715395.html

 

前阵子从支付宝转账1万块钱到余额宝,这是日常生活的一件普通小事,但作为互联网研发人员的职业病,我就思考支付宝扣除1万之后,如果系统挂掉怎么办,这时余额宝账户并没有增加1万,数据就会出现不一致状况了。

  上述场景在各个类型的系统中都能找到相似影子,比如在电商系统中,当有用户下单后,除了在订单表插入一条记录外,对应商品表的这个商品数量必须减1吧,怎么保证?!在搜索广告系统中,当用户点击某广告后,除了在点击事件表中增加一条记录外,还得去商家账户表中找到这个商家并扣除广告费吧,怎么保证?!等等,相信大家或多或多少都能碰到相似情景。

  这些问题本质上都可以抽象为:当一个表数据更新后,怎么保证另一个表的数据也必须要更新成功。

1 本地事务

  还是以支付宝转账余额宝为例,假设有

  支付宝账户表:A(id,userId,amount)  

  余额宝账户表:B(id,userId,amount)

  用户的userId=1;

  从支付宝转账1万块钱到余额宝的动作分为两步:

  1)支付宝表扣除1万:update A set amount=amount-10000 where userId=1;

  2)余额宝表增加1万:update B set amount=amount+10000 where userId=1;

  如何确保支付宝余额宝收支平衡呢?有人说这个很简单嘛,可以用事务解决。

1
2
3
4
5
Begin  transaction
          update  set  amount=amount-10000  where  userId=1;
          update  set  amount=amount+10000  where  userId=1;
End  transaction
commit ;

  非常正确!如果你使用spring的话一个注解就能搞定上述事务功能。

1
2
3
4
5
@Transactional (rollbackFor=Exception. class )
     public  void  update() {
         updateATable();  //更新A表
         updateBTable();  //更新B表
     }

  如果系统规模较小,数据表都在一个数据库实例上,上述本地事务方式可以很好地运行,但是如果系统规模较大,比如支付宝账户表和余额宝账户表显然不会在同一个数据库实例上,他们往往分布在不同的物理节点上,这时本地事务已经失去用武之地。

  既然本地事务失效,分布式事务自然就登上舞台。

2 分布式事务—两阶段提交协议

  两阶段提交协议(Two-phase Commit,2PC)经常被用来实现分布式事务。一般分为协调器C和若干事务执行者Si两种角色,这里的事务执行者就是具体的数据库,协调器可以和事务执行器在一台机器上。

  1) 我们的应用程序(client)发起一个开始请求到TC;

  2) TC先将<prepare>消息写到本地日志,之后向所有的Si发起<prepare>消息。以支付宝转账到余额宝为例,TC给A的prepare消息是通知支付宝数据库相应账目扣款1万,TC给B的prepare消息是通知余额宝数据库相应账目增加1w。为什么在执行任务前需要先写本地日志,主要是为了故障后恢复用,本地日志起到现实生活中凭证 的效果,如果没有本地日志(凭证),容易死无对证;

  3) Si收到<prepare>消息后,执行具体本机事务,但不会进行commit,如果成功返回<yes>,不成功返回<no>。同理,返回前都应把要返回的消息写到日志里,当作凭证。

  4) TC收集所有执行器返回的消息,如果所有执行器都返回yes,那么给所有执行器发生送commit消息,执行器收到commit后执行本地事务的commit操作;如果有任一个执行器返回no,那么给所有执行器发送abort消息,执行器收到abort消息后执行事务abort操作。

  注:TC或Si把发送或接收到的消息先写到日志里,主要是为了故障后恢复用。如某一Si从故障中恢复后,先检查本机的日志,如果已收到<commit >,则提交,如果<abort >则回滚。如果是<yes>,则再向TC询问一下,确定下一步。如果什么都没有,则很可能在<prepare>阶段Si就崩溃了,因此需要回滚。

  现如今实现基于两阶段提交的分布式事务也没那么困难了,如果使用java,那么可以使用开源软件atomikos(http://www.atomikos.com/)来快速实现。

  不过但凡使用过的上述两阶段提交的同学都可以发现性能实在是太差,根本不适合高并发的系统。为什么?

  1)两阶段提交涉及多次节点间的网络通信,通信时间太长!

  2)事务时间相对于变长了,锁定的资源的时间也变长了,造成资源等待时间也增加好多!

  正是由于分布式事务存在很严重的性能问题,大部分高并发服务都在避免使用,往往通过其他途径来解决数据一致性问题。

3 使用消息队列来避免分布式事务

  如果仔细观察生活的话,生活的很多场景已经给了我们提示。

  比如在北京很有名的姚记炒肝点了炒肝并付了钱后,他们并不会直接把你点的炒肝给你,往往是给你一张小票,然后让你拿着小票到出货区排队去取。为什么他们要将付钱和取货两个动作分开呢?原因很多,其中一个很重要的原因是为了使他们接待能力增强(并发量更高)。

  还是回到我们的问题,只要这张小票在,你最终是能拿到炒肝的。同理转账服务也是如此,当支付宝账户扣除1万后,我们只要生成一个凭证(消息)即可,这个凭证(消息)上写着“让余额宝账户增加 1万”,只要这个凭证(消息)能可靠保存,我们最终是可以拿着这个凭证(消息)让余额宝账户增加1万的,即我们能依靠这个凭证(消息)完成最终一致性。

3.1 如何可靠保存凭证(消息)

  有两种方法:

3.1.1 业务与消息耦合的方式

  支付宝在完成扣款的同时,同时记录消息数据,这个消息数据与业务数据保存在同一数据库实例里(消息记录表表名为message);

1
2
3
4
5
Begin  transaction
          update  set  amount=amount-10000  where  userId=1;
          insert  into  message(userId, amount,status)  values (1, 10000, 1);
End  transaction
commit ;

  上述事务能保证只要支付宝账户里被扣了钱,消息一定能保存下来。

  当上述事务提交成功后,我们通过实时消息服务将此消息通知余额宝,余额宝处理成功后发送回复成功消息,支付宝收到回复后删除该条消息数据。

3.1.2 业务与消息解耦方式

  上述保存消息的方式使得消息数据和业务数据紧耦合在一起,从架构上看不够优雅,而且容易诱发其他问题。为了解耦,可以采用以下方式。

  1)支付宝在扣款事务提交之前,向实时消息服务请求发送消息,实时消息服务只记录消息数据,而不真正发送,只有消息发送成功后才会提交事务;

  2)当支付宝扣款事务被提交成功后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才真正发送该消息;

  3)当支付宝扣款事务提交失败回滚后,向实时消息服务取消发送。在得到取消发送指令后,该消息将不会被发送;

  4)对于那些未确认的消息或者取消的消息,需要有一个消息状态确认系统定时去支付宝系统查询这个消息的状态并进行更新。为什么需要这一步骤,举个例子:假设在第2步支付宝扣款事务被成功提交后,系统挂了,此时消息状态并未被更新为“确认发送”,从而导致消息不能被发送。

  优点:消息数据独立存储,降低业务系统与消息系统间的耦合;

  缺点:一次消息发送需要两次请求;业务处理服务需要实现消息状态回查接口。

3.2 如何解决消息重复投递的问题

  还有一个很严重的问题就是消息重复投递,以我们支付宝转账到余额宝为例,如果相同的消息被重复投递两次,那么我们余额宝账户将会增加2万而不是1万了。

  为什么相同的消息会被重复投递?比如余额宝处理完消息msg后,发送了处理成功的消息给支付宝,正常情况下支付宝应该要删除消息msg,但如果支付宝这时候悲剧的挂了,重启后一看消息msg还在,就会继续发送消息msg。

  解决方法很简单,在余额宝这边增加消息应用状态表(message_apply),通俗来说就是个账本,用于记录消息的消费情况,每次来一个消息,在真正执行之前,先去消息应用状态表中查询一遍,如果找到说明是重复消息,丢弃即可,如果没找到才执行,同时插入到消息应用状态表(同一事务)。

1
2
3
4
5
6
7
8
for  each msg  in  queue
   Begin  transaction
     select  count (*)  as  cnt  from  message_apply  where  msg_id=msg.msg_id;
     if cnt==0  then
       update  set  amount=amount+10000  where  userId=1;
       insert  into  message_apply(msg_id)  values (msg.msg_id);
   End  transaction
   commit ;

  Ebay的研发人员早在2008年就提出了应用消息状态确认表来解决消息重复投递的问题:http://queue.acm.org/detail.cfm?id=1394128

参考文献

Dan PritchettBase: An Acid Alternative,http://queue.acm.org/detail.cfm?id=1394128

程立,大规模SOA系统中的分布式事务处理

mysql两阶段提交,http://blog.youkuaiyun.com/jesseyoung/article/details/37970271

 

原创文章,非作者同意,禁止转载!

转载请标明源地址:http://www.cnblogs.com/LBSer
<think>首先,用户的问题是关于“本地消息表实现分布式事务的原理与具体实现步骤”。我需要基于系统级指令来构建我的回答。 系统级指令关键点: - 所有行内数学表达式用 $...$ 格式。 - 独立公式用 $$...$$ 格式并单独成段。 - LaTeX语法正确。 - 使用中文回答。 - 生成相关问题。 - 在回答中引用的段落末尾自然地添加引用标识,如 [^1]。 - 回答结构清晰,帮助用户逐步解决问题。 - 保证回答真实可靠。 用户提供的引用: - 引用[1]:本地消息表的核心思想是将业务操作和消息记录放在同一个本地事务中。 - 引用[2]:本地消息表通过拆分分布式事务为本地事务和异步消息实现最终一致性。 - 引用[3]:详细步骤:本地事务、消息投递、消费确认、消息清理。 - 引用[4]:事务消息的实现,如半消息提交。 - 引用[5]:TiKV的分布式事务问题,但不太相关。 用户的问题焦点是“本地消息表实现分布式事务的原理与具体实现步骤”,所以我需要基于引用[1]、[2]、[3]来回答。 回答结构: 1. **介绍原理**:解释本地消息表的基本原理。 2. **详细实现步骤**:分步描述如何实现。 3. **优缺点**:简要提及优缺点。 4. **相关问题**:生成相关问题。 在回答中,如果有引用,就在末尾添加引用标识。例如,如果描述原理时参考了引用[1],就加 [^1]。 确保使用中文。 现在,规划回答内容: - **原理**:本地消息表是一种分布式事务解决方案,通过将业务操作和消息记录在同一个本地事务中,确保它们原子性执行。然后,异步发送消息来实现最终一致性。参考引用[1]和[2]。 - **实现步骤**: 1. **业务操作与消息记录**:在本地事务中执行业务操作并插入消息到本地消息表。 2. **消息投递**:后台任务轮询消息表,发送消息到消息队列。 3. **消息消费**:消费者处理消息并确认。 4. **消息清理**:定期删除已处理的消息。 参考引用[3]。 - **优缺点**:优点如简单、可靠;缺点如依赖数据库、延迟。 - **相关问题**:生成3-5个相关问题。 在回答中,自然地添加引用。例如: - "本地消息表的核心思想是将业务操作和消息记录放在同一个本地事务中[^1]。" 最后,生成相关问题。 确保没有数学表达式需要处理,因为用户的问题是关于分布式事务的,不涉及数学公式。所以,可能不需要使用 $...$ 或 $$...$$,除非有变量等。但为了遵守指令,如果有变量,就用行内格式。例如,如果有变量如消息ID,可以用 $message_id$。 在引用[3]中,有SQL代码,但我需要确保LaTeX语法正确。SQL代码不是数学公式,所以可能不需要用LaTeX。系统指令是针对数学表达式的,所以我可以直接用代码块或文本。 系统指令说:"所有行内数学表达式(包括变量、不等式、方程等)必须使用$...$格式",但对于代码,示例中使用了代码块。在示例中,有Python代码块。所以,我应该使用代码块来显示SQL或代码。 在回答中: - 用中文。 - 结构清晰:分步骤。 - 添加引用标识。 - 生成相关问题。 最终输出格式: - 回答部分。 - §§相关问题§§部分。</think>### 本地消息表实现分布式事务的原理与具体实现步骤 本地消息表是一种经典的分布式事务解决方案,它通过将分布式事务拆分为多个本地事务,并借助异步消息机制实现最终一致性。这种方法的核心原理是:在业务操作和消息记录之间建立原子性,确保它们要么同时成功,要么同时失败。然后,通过后台任务异步发送消息到消息队列(如Kafka),由消费者处理消息来完成跨服务事务。这种方案避免了复杂的分布式锁或两阶段提交协议,适用于需要高可靠性和最终一致性的场景[^1][^2]。下面我将逐步解释原理和实现步骤。 #### 原理 本地消息表的原理基于以下关键点: - **原子性保证**:业务操作(如更新库存)和消息记录(如插入消息表)在同一个本地数据库事务中执行。这确保了如果业务操作失败,消息记录也会回滚;反之亦然,从而保证了本地事务的原子性[^1][^3]。 - **异步消息机制**:消息记录后,由独立的后台任务(消息调度器)轮询本地消息表,将消息发送到消息队列。消费者从队列中获取消息并处理业务逻辑(如创建订单)。如果消息发送失败,调度器会重试,确保最终一致性[^2][^3]。 - **最终一致性**:系统不保证实时一致性,而是通过消息重试和确认机制,在有限时间内达到所有服务的数据一致。这降低了分布式事务的复杂度,但引入了轻微延迟[^3][^4]。 本地消息表特别适合业务逻辑相对简单、对延迟不敏感的场景,例如电商订单处理和库存扣减。然而,它依赖于本地数据库的事务能力,如果消息队列或消费者故障,可能需要人工干预[^1][^2]。 #### 具体实现步骤 实现本地消息表涉及四个主要步骤:业务操作与消息记录、消息投递、消息消费和消息清理。每个步骤都需要在代码和数据库层面进行设计。以下是一个典型的实现流程,基于引用中的SQL示例(假设使用关系型数据库如MySQL和消息队列如Kafka): 1. **业务操作与消息记录** 在同一个本地事务中,执行业务操作(如扣减库存)并插入消息到本地消息表。这确保了原子性:如果业务操作失败,整个事务回滚;成功则消息被持久化。 ```sql BEGIN TRANSACTION; -- 业务操作:扣减库存 UPDATE stock SET quantity = quantity - 1 WHERE product_id = 1001; -- 消息记录:插入到本地消息表 INSERT INTO outbox (message_id, content, status) VALUES ('order_123', '{"order_id": 123}', 'pending'); COMMIT; ``` 这里,`outbox` 表是本地消息表,字段包括 `message_id`(唯一标识)、`content`(消息体,如JSON格式的业务数据)、`status`(状态,初始为 'pending')。事务提交后,消息状态变为可发送[^3]。 2. **消息投递** 启动一个后台任务(如定时任务或守护进程),定期轮询本地消息表,查找状态为 'pending' 的消息。任务将消息发送到消息队列,并更新消息状态为 'sending' 或 'sent' 以防止重复发送。 示例伪代码(Python风格): ```python import time from kafka import KafkaProducer producer = KafkaProducer(bootstrap_servers='localhost:9092') while True: # 轮询消息表 messages = db.query("SELECT * FROM outbox WHERE status = 'pending' LIMIT 100") for msg in messages: try: # 发送消息到Kafka producer.send('order_topic', msg['content']) db.execute("UPDATE outbox SET status = 'sent' WHERE message_id = %s", msg['message_id']) except Exception as e: # 失败重试,记录日志 logger.error(f"Message send failed: {e}") db.execute("UPDATE outbox SET status = 'failed' WHERE message_id = %s", msg['message_id']) time.sleep(5) # 轮询间隔 ``` 此步骤需要处理网络故障:如果发送失败,任务会重试多次(可设置最大重试次数),直到消息状态更新为 'sent' 或 'failed'[^3][^4]。 3. **消息消费** 消费者服务(如订单服务)订阅消息队列,处理消息并执行业务逻辑(如创建订单)。处理成功后,消费者调用接口确认消息状态,避免重复消费。 示例伪代码(Python风格): ```python from kafka import KafkaConsumer consumer = KafkaConsumer('order_topic', group_id='order_group') for message in consumer: data = json.loads(message.value) try: # 业务逻辑:创建订单 create_order(data['order_id']) # 确认消息(可选:调用消息表接口更新状态) consumer.commit() except Exception as e: # 处理失败,记录并重试 logger.error(f"Order creation failed: {e}") # 可设置死信队列处理持续失败的消息 ``` 消费者应实现幂等性:多次处理相同消息不会导致数据错误(例如,通过唯一ID检查订单是否已存在)[^3][^4]。 4. **消息清理** 定期清理已处理的消息(状态为 'sent' 或 'confirmed'),以减轻数据库负担。这可以通过定时任务执行SQL删除操作。 ```sql DELETE FROM outbox WHERE status = 'sent' AND created_at < NOW() - INTERVAL '7 days'; ``` 清理周期可根据业务需求设置(如保留7天),避免数据丢失风险[^3]。 #### 优缺点总结 - **优点**:实现简单,依赖现有数据库事务,减少分布式协调的开销;高可靠性,通过重试机制保证最终一致性;适用于微服务架构[^1][^2]。 - **缺点**:消息延迟可能导致短暂不一致;依赖数据库性能(轮询可能成为瓶颈);需要额外处理消费者故障(如死信队列)[^2][^3]。 通过以上步骤,本地消息表能有效解决分布式事务问题。在实际应用中,还需结合具体框架(如Spring Cloud或RocketMQ的事务消息)优化性能[^4]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值