新闻详情

新闻详情

首页 / 资讯中心 / 详情

OutBox模式实战:解决分布式事务中本地事务与消息发送的原子性难题

发布时间:2026/9/26 18:03:58来源:尧图网络
OutBox模式实战:解决分布式事务中本地事务与消息发送的原子性难题
1. 从一次线上事故说起为什么我们需要OutBox模式去年冬天的一个凌晨我被一通报警电话叫醒。订单系统在凌晨两点到四点之间产生了三百多笔“已支付但未发货”的异常订单客服群已经炸了。排查过程不算复杂支付服务在完成本地数据库事务后需要发送一条消息到消息队列通知订单服务发货但那天消息队列集群恰好发生了短暂的网络抖动导致部分消息发送失败。而支付服务的代码逻辑是“先提交本地事务再发送消息”事务提交成功后消息发送失败数据就出现了不一致。这个问题在分布式系统中非常典型。本地事务与消息发送的原子性是每个涉及异步通信的系统都绕不开的坎。你可能会想把发送消息放在事务里面不就行了但消息队列的操作通常不参与数据库事务即使参与跨系统的两阶段提交性能代价也极高。那先发消息再提交事务呢如果消息发出去了但事务回滚了下游服务就会收到一条“幽灵消息”处理了根本不存在的业务。OutBox模式就是为解决这个矛盾而生的。它的核心思路非常朴素既然本地事务和消息发送没法保证原子性那就把“发消息”这件事也变成数据库操作的一部分。具体来说在业务数据库中建一张OutBox表也叫发件箱表业务数据变更和待发送的消息记录在同一个本地事务中写入这张表然后由一个独立的MessageRelay消息中继器异步读取OutBox表中的记录并投递到消息队列。这样本地事务的原子性天然保证了“业务变更”和“消息记录”要么都成功要么都失败而消息的最终投递则由Relay来保证。这套方案适合谁任何在微服务架构中处理异步消息的开发者尤其是那些被“数据不一致”折磨过的后端工程师。不管你用的是MySQL、PostgreSQL还是其他关系型数据库不管你用的是Kafka、RabbitMQ还是RocketMQOutBox模式的思路都是通用的。接下来我会从设计思路、核心细节、实操落地和问题排查四个维度把OutBox模式彻底讲透。2. OutBox模式整体设计与思路拆解2.1 核心矛盾本地事务与消息发送为什么无法天然原子要理解OutBox模式的价值得先搞清楚问题的根源。在单体应用时代数据库事务能覆盖所有操作原子性由数据库保证。但一旦引入消息队列情况就变了。消息队列是一个独立的外部系统它的写入操作和数据库事务分属两个不同的资源管理器没有共享的事务上下文。常见的错误做法有三种。第一种是“先写库再发消息”这是最直觉的方案但消息发送失败时本地事务已经提交数据不一致。第二种是“先发消息再写库”消息发出后本地事务回滚下游收到无效消息。第三种是“把消息发送放在事务内”看似合理但消息队列的客户端调用通常不参与数据库事务即使某些MQ支持事务消息其实现复杂度、性能开销和运维成本都让人望而却步。这里有个容易被忽略的细节即使消息发送成功网络超时也可能导致客户端认为发送失败而重试造成消息重复。所以OutBox模式不仅要解决原子性还要考虑幂等性。OutBox模式的精妙之处在于它把“跨系统”的问题转化成了“单系统”的问题。业务数据和消息记录都在同一个数据库里用同一个本地事务保证原子性这就把分布式事务的难题降维成了本地事务。至于消息最终有没有投递到MQ那是Relay的事Relay可以重试、可以补偿因为消息记录已经持久化了不会丢。2.2 OutBox表的字段设计与选型考量OutBox表的设计直接决定了整个方案的可靠性和灵活性。我见过不少团队在这张表上踩坑要么字段不够用要么索引设计不合理导致Relay扫描性能差。下面是我在实际项目中总结的字段设计以MySQL为例CREATE TABLE outbox_message ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, message_id VARCHAR(64) NOT NULL COMMENT 全局唯一消息ID用于幂等, aggregate_type VARCHAR(64) NOT NULL COMMENT 聚合类型如ORDER、PAYMENT, aggregate_id VARCHAR(64) NOT NULL COMMENT 聚合根ID如订单号, event_type VARCHAR(64) NOT NULL COMMENT 事件类型如ORDER_CREATED, payload JSON NOT NULL COMMENT 消息体JSON格式, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待发送 1-已发送 2-发送失败, retry_count INT NOT NULL DEFAULT 0 COMMENT 重试次数, next_retry_at DATETIME DEFAULT NULL COMMENT 下次重试时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_status_next_retry (status, next_retry_at), INDEX idx_aggregate (aggregate_type, aggregate_id), UNIQUE KEY uk_message_id (message_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个关键字段的选型理由值得展开说。message_id必须是全局唯一的通常用UUID或者雪花算法生成它的作用是让下游消费者做幂等去重。aggregate_type和aggregate_id是为了支持按业务聚合查询和顺序消费比如同一个订单的所有事件需要按顺序处理。status字段用TINYINT而不是枚举字符串是为了索引效率和状态机清晰。next_retry_at是实现指数退避重试的关键避免失败消息被立即反复重试打爆MQ。注意payload字段用JSON类型还是TEXT类型取决于数据库版本和查询需求。MySQL 5.7以上建议用JSON可以享受JSON函数的便利如果只是存储不查询TEXT也够用但要注意字符集和长度限制。索引设计上idx_status_next_retry是Relay扫描的核心索引Relay的查询条件是status 0 AND (next_retry_at IS NULL OR next_retry_at NOW())这个复合索引能让扫描走索引而不是全表扫。uk_message_id的唯一索引既保证消息不重复也方便按消息ID查询。2.3 MessageRelay的三种实现模式对比MessageRelay是OutBox模式的心脏它的职责是轮询OutBox表把待发送的消息投递到MQ并更新消息状态。根据触发方式和部署形态我把它分为三种模式模式触发方式优点缺点适用场景定时轮询定时任务每隔N秒扫描实现简单无额外依赖实时性差空轮询浪费资源消息量小、实时性要求低独立Relay服务独立进程持续轮询实时性好可水平扩展需要额外部署和监控消息量大、实时性要求高CDC捕获监听数据库binlog实时性最好无轮询开销实现复杂依赖CDC工具已有CDC基础设施的团队定时轮询是最容易上手的用Spring的Scheduled或者Quartz就能实现适合刚起步的团队。独立Relay服务是我最推荐的方案它本质上就是一个消费者从OutBox表“消费”消息再“生产”到MQ可以独立扩缩容故障隔离也好。CDC模式用Debezium之类的工具监听binlog把OutBox表的变更直接转成MQ消息实时性最好但运维复杂度高适合已经有CDC体系的团队。选择哪种模式核心看两个指标消息的实时性要求和团队的运维能力。如果业务能容忍秒级延迟定时轮询完全够用如果要求毫秒级那就得上CDC。我个人的经验是大多数业务场景下独立Relay服务配合1秒轮询间隔已经能满足99%的需求。3. 核心细节解析与实操要点3.1 业务代码如何写入OutBox表业务代码的改造是OutBox模式落地的第一步也是最容易出错的地方。核心原则是业务数据变更和OutBox记录必须在同一个本地事务中完成。以订单创建为例用Spring的Transactional注解可以这样写Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private OutboxMapper outboxMapper; Transactional(rollbackFor Exception.class) public void createOrder(OrderCreateCmd cmd) { // 1. 业务数据写入 Order order buildOrder(cmd); orderMapper.insert(order); // 2. OutBox消息记录写入与业务数据同一事务 OutboxMessage message new OutboxMessage(); message.setMessageId(UUID.randomUUID().toString()); message.setAggregateType(ORDER); message.setAggregateId(order.getOrderNo()); message.setEventType(ORDER_CREATED); message.setPayload(JSON.toJSONString(buildEvent(order))); message.setStatus(0); outboxMapper.insert(message); } }这段代码看起来简单但有几个坑必须注意。第一Transactional的rollbackFor要设为Exception.class否则受检异常不会触发回滚。第二OutBox记录的写入不能放在异步线程里必须在同一个事务线程中。第三payload的序列化要处理好日期格式、枚举值等细节避免下游反序列化失败。实操心得我习惯把OutBox记录的构建封装成一个OutboxRecorder工具类业务代码只需要调用recorder.record(aggregateType, aggregateId, eventType, payload)这样既减少了重复代码也统一了message_id的生成策略和payload的序列化配置。还有一个细节是事务的传播行为。如果业务方法本身就在一个事务中OutBox的写入要用Propagation.REQUIRED加入当前事务如果业务方法是独立事务那OutBox写入也要独立事务。千万不要用REQUIRES_NEW那会开一个新事务破坏原子性。3.2 MessageRelay的轮询与投递逻辑实现MessageRelay的核心逻辑可以用四步概括查、发、更、退。查是查询待发送消息发是投递到MQ更是更新消息状态退是失败后的退避重试。下面是一个基于Spring Boot的Relay实现骨架Component public class OutboxRelay { Autowired private OutboxMapper outboxMapper; Autowired private KafkaTemplateString, String kafkaTemplate; private static final int BATCH_SIZE 100; private static final int MAX_RETRY 5; Scheduled(fixedDelay 1000) public void relay() { ListOutboxMessage messages outboxMapper.selectPending(BATCH_SIZE); for (OutboxMessage msg : messages) { try { kafkaTemplate.send(msg.getEventType(), msg.getPayload()).get(3, TimeUnit.SECONDS); outboxMapper.markSent(msg.getId()); } catch (Exception e) { handleFailure(msg, e); } } } private void handleFailure(OutboxMessage msg, Exception e) { int retryCount msg.getRetryCount() 1; if (retryCount MAX_RETRY) { outboxMapper.markFailed(msg.getId(), retryCount); // 触发告警 alertService.sendAlert(msg, e); } else { long delaySeconds (long) Math.pow(2, retryCount); outboxMapper.scheduleRetry(msg.getId(), retryCount, delaySeconds); } } }查询待发送消息的SQL要利用索引避免全表扫描SELECT * FROM outbox_message WHERE status 0 AND (next_retry_at IS NULL OR next_retry_at NOW()) ORDER BY id ASC LIMIT #{batchSize}这里用ORDER BY id ASC而不是ORDER BY created_at是因为自增主键的排序效率更高而且能保证消息的大致顺序。kafkaTemplate.send().get()是同步等待发送结果超时设为3秒避免Relay线程被长时间阻塞。如果追求吞吐量可以用异步发送加回调但要注意回调线程和状态更新的并发问题。注意Relay的轮询间隔和批量大小需要根据消息量调优。消息量大时批量大小可以调到500甚至1000但要注意单次处理时间不能超过轮询间隔否则会造成任务堆积。我一般会监控Relay的单次处理耗时如果接近轮询间隔就调大批量或增加Relay实例。3.3 幂等性设计让重复消息不再可怕OutBox模式保证了消息不丢但无法保证消息不重复。Relay在更新状态前如果崩溃重启后会重新发送已经投递成功的消息。所以下游消费者必须实现幂等。幂等的实现方式有三种我逐一分析。第一种是数据库唯一约束。消费者在处理消息前先把message_id插入一张去重表如果插入成功就处理业务插入失败说明消息已处理过直接跳过。这种方式简单可靠但每次消费都要写一次去重表有一定性能开销。第二种是Redis去重。用SETNX message_id判断是否已处理设置合理的过期时间。性能好但Redis故障时可能丢失去重记录需要配合数据库兜底。第三种是业务状态机。比如订单状态从“待支付”到“已支付”如果订单已经是“已支付”再次收到支付消息就直接忽略。这种方式最优雅但要求业务状态设计得足够严谨。幂等方案实现复杂度性能可靠性适用场景数据库唯一约束低中高通用场景强一致要求Redis去重中高中高并发可容忍少量重复业务状态机高高高状态流转明确的业务我通常推荐数据库唯一约束作为兜底Redis去重作为前置过滤两者结合既保证性能又保证可靠性。业务状态机则是在业务层面再加一道保险。4. 实操过程与核心环节实现4.1 环境准备与依赖配置假设你用的是Spring Boot MySQL Kafka这套经典组合先把依赖加上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.kafka/groupId artifactIdspring-kafka/artifactId /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependency数据库连接池建议用HikariCP配置上要注意两点一是maximumPoolSize要留出Relay的查询连接二是connectionTimeout不要设得太短避免Relay查询超时。Kafka的生产者配置里acks设为all保证消息不丢retries设为Integer.MAX_VALUE让客户端自动重试enable.idempotence设为true避免生产者重试导致的消息重复。spring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 30000 kafka: producer: acks: all retries: 2147483647 properties: enable.idempotence: true max.in.flight.requests.per.connection: 1max.in.flight.requests.per.connection设为1是为了保证消息顺序虽然会降低吞吐但在OutBox场景下顺序性往往比吞吐更重要。4.2 完整代码实现从业务写入到消息投递把前面的片段串起来形成一个完整的可运行示例。首先是OutboxMessage实体和MapperEntity Table(name outbox_message) public class OutboxMessage { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String messageId; private String aggregateType; private String aggregateId; private String eventType; Column(columnDefinition JSON) private String payload; private Integer status; private Integer retryCount; private Date nextRetryAt; private Date createdAt; private Date updatedAt; // getter/setter省略 }Mapper用MyBatis或者JPA都可以关键是SQL要利用索引。下面是MyBatis的Mapper接口和XMLpublic interface OutboxMapper { int insert(OutboxMessage message); ListOutboxMessage selectPending(Param(limit) int limit); int markSent(Param(id) Long id); int markFailed(Param(id) Long id, Param(retryCount) int retryCount); int scheduleRetry(Param(id) Long id, Param(retryCount) int retryCount, Param(delaySeconds) long delaySeconds); }select idselectPending resultTypeOutboxMessage SELECT * FROM outbox_message WHERE status 0 AND (next_retry_at IS NULL OR next_retry_at lt; NOW()) ORDER BY id ASC LIMIT #{limit} /select update idscheduleRetry UPDATE outbox_message SET retry_count #{retryCount}, next_retry_at DATE_ADD(NOW(), INTERVAL #{delaySeconds} SECOND), updated_at NOW() WHERE id #{id} /update然后是Relay的完整实现加上并发控制和优雅停机Component public class OutboxRelay { private static final Logger log LoggerFactory.getLogger(OutboxRelay.class); private static final int BATCH_SIZE 100; private static final int MAX_RETRY 5; private static final long SEND_TIMEOUT_SECONDS 3; Autowired private OutboxMapper outboxMapper; Autowired private KafkaTemplateString, String kafkaTemplate; private final AtomicBoolean running new AtomicBoolean(true); Scheduled(fixedDelay 1000) public void relay() { if (!running.get()) { return; } ListOutboxMessage messages outboxMapper.selectPending(BATCH_SIZE); if (messages.isEmpty()) { return; } log.info(Relay picked {} messages, messages.size()); for (OutboxMessage msg : messages) { if (!running.get()) { break; } try { kafkaTemplate.send(msg.getEventType(), msg.getMessageId(), msg.getPayload()) .get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS); outboxMapper.markSent(msg.getId()); } catch (Exception e) { log.error(Send message failed, messageId{}, msg.getMessageId(), e); handleFailure(msg, e); } } } private void handleFailure(OutboxMessage msg, Exception e) { int retryCount msg.getRetryCount() 1; if (retryCount MAX_RETRY) { outboxMapper.markFailed(msg.getId(), retryCount); log.error(Message exhausted retries, messageId{}, msg.getMessageId()); } else { long delaySeconds (long) Math.pow(2, retryCount); outboxMapper.scheduleRetry(msg.getId(), retryCount, delaySeconds); } } PreDestroy public void shutdown() { running.set(false); } }PreDestroy里的running.set(false)是为了优雅停机避免Relay在处理消息时被强制中断导致状态不一致。kafkaTemplate.send的第二个参数是key用messageId作为key可以保证同一消息总是路由到同一分区配合生产者的幂等性配置能有效避免重复。4.3 参数计算轮询间隔、批量大小与重试策略这些参数不是拍脑袋定的背后有计算逻辑。先说轮询间隔。假设你的消息峰值是每秒1000条Relay单次处理100条耗时200毫秒那么理论上每秒能处理5批共500条不够。这时候要么调大批量到200条要么增加Relay实例到2个要么缩短轮询间隔。但轮询间隔不能太短否则空轮询会浪费数据库连接。我的经验公式是轮询间隔 单次处理耗时 × 2。比如单次处理200毫秒轮询间隔就设400毫秒到1秒。这样既能保证及时处理又不会空转太频繁。批量大小的计算要考虑两个约束一是单次处理时间不能超过轮询间隔二是批量查询的结果集不能太大导致内存压力。我一般从100起步根据监控调整。如果Relay的CPU和内存都很空闲就调大如果单次处理时间接近轮询间隔就调小或加实例。重试策略用指数退避公式是delay base × 2^retryCount。base设为1秒那么重试延迟依次是2秒、4秒、8秒、16秒、32秒。最大重试次数设为5次超过就标记为失败并告警。这个策略的合理性在于短暂故障如网络抖动在几次重试内就能恢复而持久故障如MQ宕机重试太多次也没用不如早点告警让人介入。参数推荐值调整依据轮询间隔1秒单次处理耗时的2倍批量大小100单次处理时间不超过轮询间隔最大重试次数5覆盖短暂故障避免无效重试重试基础延迟1秒指数退避的起点发送超时3秒避免Relay线程长时间阻塞5. 常见问题与排查技巧实录5.1 消息积压Relay处理不过来怎么办消息积压是OutBox模式最常见的告警。表现是OutBox表中status0的记录持续增长Relay的日志显示每次都能查到满批量的消息。排查思路分三步先看Relay的处理速度再看MQ的投递速度最后看数据库的查询速度。如果Relay处理速度慢先看是不是单次处理时间过长。用日志打印每批的处理耗时如果超过轮询间隔说明批量太大或MQ响应慢。这时候可以调小批量或者增加Relay实例。增加实例时要注意多个Relay同时查询会竞争同一批消息需要用SELECT ... FOR UPDATE SKIP LOCKED或者分布式锁来避免重复处理。SELECT * FROM outbox_message WHERE status 0 AND (next_retry_at IS NULL OR next_retry_at NOW()) ORDER BY id ASC LIMIT #{batchSize} FOR UPDATE SKIP LOCKEDSKIP LOCKED是MySQL 8.0和PostgreSQL 9.5以上支持的特性它让多个Relay实例可以并行处理不同的消息互不阻塞。如果数据库版本不支持那就得用分布式锁比如Redis的SETNX但性能和可靠性都不如SKIP LOCKED。如果MQ投递速度慢看Kafka的send().get()耗时。如果经常接近超时时间说明MQ集群压力大或者网络有问题。这时候可以调大发送超时或者联系MQ运维排查。如果数据库查询慢看selectPending的执行计划确认走了idx_status_next_retry索引。如果没走索引可能是统计信息过期执行ANALYZE TABLE更新一下。避坑技巧我习惯在OutBox表上加一个监控指标统计status0的记录数和最老记录的创建时间。如果最老记录超过5分钟还没发送就触发告警。这个指标比单纯看积压数量更能反映问题严重程度。5.2 消息重复下游收到多条相同消息消息重复的根源通常是Relay在更新状态前崩溃重启后重新发送。排查时先看OutBox表中是否有status0但实际已投递的消息。如果有说明Relay的“发送成功”和“更新状态”之间出现了中断。这时候要检查Relay的日志看是否有异常堆栈。解决消息重复的根本方法是下游幂等。但如果重复率很高也要优化Relay。一个技巧是在发送消息前先更新状态为“发送中”发送成功后再更新为“已发送”。这样即使崩溃重启后也不会重复发送“发送中”的消息而是由补偿任务处理。// 先标记为发送中 outboxMapper.markSending(msg.getId()); try { kafkaTemplate.send(...).get(...); outboxMapper.markSent(msg.getId()); } catch (Exception e) { // 发送失败回退状态 outboxMapper.markPending(msg.getId()); handleFailure(msg, e); }这个方案增加了状态机的复杂度但能有效降低重复率。代价是如果Relay在“发送中”状态崩溃消息会卡住需要补偿任务扫描超时的“发送中”消息并重置为“待发送”。5.3 数据库连接耗尽Relay把连接池打满了Relay的轮询查询会占用数据库连接如果轮询间隔太短或者批量太大连接池很快就被打满业务查询拿不到连接。表现是业务接口超时日志里出现Connection is not available。排查时先看HikariCP的监控指标activeConnections是否持续接近maximumPoolSize。如果是先调大连接池但这只是治标。治本的方法是优化Relay的查询确保走索引、减少批量大小、延长轮询间隔。另外Relay的查询可以用单独的连接池和业务查询隔离避免互相影响。spring: datasource: hikari: maximum-pool-size: 20 relay: datasource: hikari: maximum-pool-size: 5 connection-timeout: 10000给Relay单独配一个数据源连接数少一点超时短一点这样即使Relay查询慢也不会拖垮业务。5.4 常见问题速查表问题现象可能原因排查方法解决方案消息积压Relay处理慢/MQ慢/查询慢看处理耗时、MQ响应、执行计划调大批量、加实例、优化索引消息重复Relay崩溃后重发查OutBox状态和Relay日志下游幂等、发送中状态、补偿任务连接耗尽轮询太频繁/批量太大看连接池监控独立连接池、优化查询、调参消息丢失事务未提交/Relay未启动查业务日志和OutBox记录检查事务注解、Relay健康检查顺序错乱多实例并发/分区策略查消息key和分区用messageId做key、单实例消费6. 进阶优化与生产环境经验6.1 分区消费与顺序性保证OutBox模式默认不保证消息顺序因为Relay可能多实例并发MQ也可能多分区。但有些业务场景对顺序有要求比如订单状态流转必须按创建、支付、发货的顺序处理。保证顺序的方法有两种一是单实例Relay加单分区MQ简单但吞吐低二是按aggregate_id分区同一聚合的消息路由到同一分区由同一个消费者顺序处理。Kafka的分区策略可以用aggregate_id作为key这样同一订单的消息总是进同一分区。消费者端用单线程消费每个分区就能保证顺序。但要注意如果某个订单的消息量特别大会导致分区倾斜需要监控分区消息量。kafkaTemplate.send(topic, msg.getAggregateId(), msg.getPayload());用aggregate_id而不是messageId作为key是为了让同一聚合的消息进同一分区。messageId是唯一的用它做key会导致消息均匀分布到所有分区失去顺序性。6.2 监控告警体系搭建OutBox模式的监控指标至少包括四个待发送消息数、最老待发送消息年龄、发送成功率、重试次数分布。待发送消息数反映积压程度最老消息年龄反映延迟发送成功率反映Relay健康度重试次数分布反映MQ稳定性。这些指标可以用Micrometer暴露到Prometheus再用Grafana做面板。告警规则可以设待发送消息数超过1000持续5分钟、最老消息年龄超过5分钟、发送成功率低于99%持续10分钟。告警要分级积压告警是P2成功率告警是P1因为成功率低意味着消息可能丢失。实操心得我在生产环境会给OutBox表加一个定时清理任务把status1且创建时间超过7天的记录归档到历史表避免OutBox表无限增长。归档用INSERT INTO ... SELECT ...加DELETE分批执行避免大事务锁表。6.3 从OutBox模式看分布式事务的取舍OutBox模式本质上是最终一致性方案它放弃了强一致性换来了性能和可用性。这和TCC、Saga、最大努力通知等方案是同一类思路但OutBox的独特之处在于它把消息发送也纳入了本地事务用数据库的原子性解决了跨系统的原子性问题。它的局限也很明显消息延迟取决于Relay的轮询间隔通常是秒级OutBox表会增加数据库写入压力Relay的运维需要额外投入。所以它适合那些能容忍秒级延迟、追求最终一致性的场景。如果业务要求强一致那还得用两阶段提交或者TCC但代价是性能和复杂度。我在实际项目中的体会是OutBox模式最大的价值不是技术本身而是它提供了一种“把复杂问题降维”的思路。分布式事务很难但本地事务很简单把分布式问题转化成多个本地问题再用补偿机制串联起来这就是OutBox模式的精髓。踩过几次坑之后我现在设计任何涉及异步消息的系统第一反应就是先画OutBox表再想Relay怎么实现。这套方案不一定是最优的但一定是最稳妥的之一。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

video-use:用ffmpeg和Claude Code搭建自动化视频处理流水线 2026/9/26 19:42:49

video-use:用ffmpeg和Claude Code搭建自动化视频处理流水线

1. 从“video-use”这个标题说起:它到底想解决什么问题第一次看到“video-use”这个标题,我脑子里蹦出来的不是某个具体工具,而是一类需求:用代码和命令行把视频处理这件事自动化起来。结合热搜词里高频出现的 Claude Code、ffmpe…

阅读更多 →
Substrate区块链开发框架入门:从核心概念到本地链实操 2026/9/26 19:42:43

Substrate区块链开发框架入门:从核心概念到本地链实操

1. 从零认识 Substrate:它到底是什么,能解决什么问题第一次听到 Substrate 这个词,很多人会以为是某个前端框架或者构建工具。其实不是。Substrate 是一个用于构建区块链的开发框架,由 Parity Technologies 团队打造,最…

阅读更多 →
DeepOpen × Banking77 复现指南:Laya 决策引擎的 77 类银行意图分类实战 2026/9/26 19:42:30

DeepOpen × Banking77 复现指南:Laya 决策引擎的 77 类银行意图分类实战

【免费下载链接】deepopen 非自回归System 1决策引擎,专为结构化类型决策场景设计 DeepOpen Multilingual, non-autoregressive System 1 decision engine. 项目地址: https://gitcode.com/gh_mirrors/de/deepopen 点击查看 免费下载 本指南完整讲解在…

阅读更多 →
Arthas 已接入 MCP:用 JSON-RPC 打通 JVM 线上问题定位链路 2026/9/26 19:42:17

Arthas 已接入 MCP:用 JSON-RPC 打通 JVM 线上问题定位链路

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

阅读更多 →
AI 说得很流畅,不代表它说得对-CSDN博客 2026/9/26 19:42:11

AI 说得很流畅,不代表它说得对-CSDN博客

首屏导读 本教程配套付费专栏: 大模型工程师修炼手记 19.9 元(AI 编程 / Agent 实战 | 本文同主题系统课程) AI时代程序员的自我提升 49.9 元(AI 时代成长方法论)。 单篇不过瘾?订阅解锁全量源…

阅读更多 →
CRM系统选型与落地:从通信集成到客户管理实战 2026/9/26 19:41:58

CRM系统选型与落地:从通信集成到客户管理实战

前因我在一次销售运营复盘会上第一次注意到 DeskcommCRM。当时团队的数据是这样的:外呼量上去了,商机数却没涨,翻客户跟进记录时,电话内容在手机通话记录里,邮件往来散落在个人邮箱,报价单和合同在另一个文…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉