本文是「架构师的技术基石」系列的第1-4篇。查看系列完整路线图与所有文章目录 :【重磅系列】架构师技术基石全景图:以「增长中台」贯穿16讲硬核实战
当你的操作跨越三个数据库时,"要么全做,要么全不做"从一句口号变成了每晚的噩梦
01 一个看似简单却让团队失眠的需求
凌晨1点,我接到报警:增长中台的"新用户欢迎实验"数据对不上了。
运营后台显示,昨晚有12,854个新用户被分配到了实验组A(应该收到一张8折优惠券)。但优惠券系统后台显示,只有11,920张券被成功发放。差了934张券,消失在哪了?
更诡异的是,用户行为日志显示,有300多个用户既收到了券又点击使用了,但在我们的实验分析报表里,他们却被标记为"未触达用户"。
早上9点,复盘会上,三个团队的负责人面面相觑:
- 实验团队 :"我们严格按照算法分配了用户,记录都写进
allocations表了。" - 触达团队 :"我们收到Kafka消息就发券,
send_coupon_task表里任务状态都是'成功'。" - 数据团队 :"我们每天凌晨跑ETL,从
allocations表和券系统的日志表coupon_logs做关联统计......"
问题逐渐清晰:一个用户参与实验的完整生命周期,被我们拆成了三个独立操作,存进了三个不同的数据库,却没有保证它们的一致性。
- 步骤1(实验服务) :在MySQL的
allocations表记录"用户U进入实验A" - 步骤2(触达服务) :通过Kafka消息触发,在另一个MySQL发券,并写
coupon_logs - 步骤3(分析服务):凌晨批量Join两个表的数据,生成报表
当步骤1成功但步骤2失败时 ,用户明明没收到券,却被计为实验参与者。
当步骤2成功但Kafka消息丢失时 ,用户收到了券,却被计为未触达。
当凌晨ETL任务失败时,所有人都不知道数据对不上。
产品经理听完技术复盘,问了一个灵魂问题:"所以,从用户视角看,他到底是'参与实验收到了券',还是'没参与实验'?系统能给个准话吗?"
今天,我们就来解决增长中台中最棘手的分布式一致性问题。不空谈理论,只看在真实业务压力下,我们如何从"数据一团糟"走到"勉强可用",再到"基本可靠"。
02 从业务场景出发,而不是从理论出发
2.1 先问:我们到底需要什么样的一致性?
在讨论技术方案前,必须回到业务场景,问清楚:这个操作,如果中途失败,最坏的影响是什么?
在增长中台,有几种典型的跨服务操作:
| 操作场景 | 涉及服务 | 一致性要求 | 最坏结果 |
|---|---|---|---|
| 用户进入实验并实时触达 | 实验服务 + 触达服务 | 强一致:用户要么完整进入实验并收到权益,要么完全不进入 | 用户被错误计入实验样本,影响实验结论科学性 |
| 用户行为触发积分奖励 | 行为采集 + 积分服务 | 最终一致:积分可以延迟几分钟到账,但不能丢 | 用户损失积分,可能投诉,但不会影响核心业务 |
| 实验配置同步到推荐引擎 | 实验管理 + 推荐服务 | 最终一致:配置变更可以分钟级延迟生效 | 短期内策略不一致,影响用户体验但可接受 |
我们的"用户进入实验并触达"场景,明显属于强一致要求最高的类别。因为A/B测试的科学性建立在准确的样本分配和效果归因之上。
2.2 再问:我们能接受多大的复杂度与性能代价?
这是架构选择的现实约束:
- 性能要求:实验分配是用户旅程的关键路径,P99延迟必须<100ms
- 团队能力:团队熟悉MySQL和Kafka,对分布式事务框架无经验
- 运维成本:系统已有一定复杂度,新方案必须易于监控和排查
带着这些约束,我们开始评估方案。
03 方案演进史:从"完全不做"到"勉强能用"再到"基本可靠"
阶段一:天真的异步消息(我们曾经这么干过)
这是我们最初的方案,也是问题最多的方案:
java
// 实验服务代码(v1.0 - 问题版本)
@Service
public class ExperimentServiceV1 {
@Transactional // 本地事务
public void enrollUserInExperiment(Long userId, Long experimentId) {
// 1. 记录分配(本地事务保证)
allocationRepository.save(new Allocation(userId, experimentId));
// 2. 发送Kafka消息(在事务提交后)
kafkaTemplate.send("experiment-enrollment",
new EnrollmentEvent(userId, experimentId));
}
}
// 触达服务代码
@KafkaListener(topics = "experiment-enrollment")
public void handleEnrollment(EnrollmentEvent event) {
// 3. 收到消息,发放权益
couponService.grantCoupon(event.getUserId(), event.getExperimentId());
}
这个方案的问题像筛子一样多:
- 消息可能丢失:Kafka发送失败怎么办?
- 消息可能重复:实验服务重试导致重复消息怎么办?
- 时序可能错乱:消息延迟,用户先收到权益再看到实验记录?
- 无法回滚:权益发放失败,实验记录却已提交,怎么办?
我们为此付出的代价是:每周花8小时手动核对数据,运营不再信任系统报表。
阶段二:引入本地事务表(可靠消息模式)
我们意识到,不能依赖"发后即忘"的消息。于是引入了本地事务表模式:
实验服务 - 单个数据库事务
是
否
是
否
是
否
用户请求进入实验
开启事务
插入主记录 allocation
插入消息记录 event_message
状态=PENDING
提交事务
事务提交成功?
异步线程扫描 PENDING 消息
整个操作失败, 无副作用
发送消息到Kafka
发送成功?
更新消息状态为 SENT
等待下次重试
触达服务收到消息
发放权益
发放成功?
发送确认消息
标记为需人工处理
实验服务更新消息状态为 CONFIRMED
对应的核心代码实现:
java
// 实验服务代码(v2.0 - 本地事务表版)
@Service
public class ExperimentServiceV2 {
@Transactional
public void enrollUserInExperiment(Long userId, Long experimentId) {
// 1. 记录分配
Allocation allocation = new Allocation(userId, experimentId);
allocationRepository.save(allocation);
// 2. 在同一个事务中插入消息记录
EventMessage message = new EventMessage();
message.setType("EXPERIMENT_ENROLLMENT");
message.setPayload(buildPayload(userId, experimentId));
message.setStatus(EventMessage.Status.PENDING);
message.setCreatedAt(LocalDateTime.now());
eventMessageRepository.save(message);
// 事务在此提交,要么两条记录都成功,要么都失败
}
// 独立的定时任务,扫描并发送消息
@Scheduled(fixedDelay = 5000)
public void processPendingMessages() {
List<EventMessage> pendingMessages =
eventMessageRepository.findByStatus(EventMessage.Status.PENDING);
for (EventMessage message : pendingMessages) {
try {
kafkaTemplate.send("experiment-enrollment", message.getPayload());
message.setStatus(EventMessage.Status.SENT);
message.setSentAt(LocalDateTime.now());
eventMessageRepository.save(message);
} catch (Exception e) {
log.error("发送消息失败,messageId={}", message.getId(), e);
// 下次重试
}
}
}
}
// 触达服务确认消费后,发回确认消息
@KafkaListener(topics = "experiment-enrollment")
public void handleEnrollment(String payload) {
EnrollmentEvent event = parsePayload(payload);
try {
// 发放权益
couponService.grantCoupon(event.getUserId(), event.getExperimentId());
// 发送确认消息
kafkaTemplate.send("enrollment-confirmed",
new ConfirmationEvent(event.getMessageId()));
} catch (Exception e) {
log.error("处理实验参与事件失败", e);
// 进入死信队列,人工处理
}
}
这个方案进步了,但仍不完美:
- ✅ 保证不丢:只要主记录存在,消息最终会被发出
- ✅ 可重试:发送失败可不断重试
- ❌ 仍可能重复:消息发送后、状态更新前崩溃,会导致重复发送
- ❌ 回滚复杂:权益发放失败后,需要额外机制清理实验记录
阶段三:Saga模式 - 接受最终一致,但明确定义补偿
对于金融级强一致,我们可能要用到Seata这样的分布式事务框架。但在增长中台,我们选择了更务实的Saga模式 :接受最终一致,但为每个正向操作都定义明确的补偿操作。
是
否
是
否
是
否
用户请求进入实验
实验服务: 开启Saga
Saga协调器
执行步骤1: 记录实验分配
成功?
执行步骤2: 发放权益
Saga失败, 结束
成功?
Saga成功, 结束
触发补偿: 删除实验分配记录
补偿成功?
Saga失败但已清理, 结束
Saga失败且未清理
标记为需人工干预
Saga协调器的简化实现:
java
@Component
public class ExperimentEnrollmentSaga {
public SagaResult enrollUser(Long userId, Long experimentId) {
String sagaId = generateSagaId();
SagaContext context = new SagaContext(sagaId, userId, experimentId);
try {
// 步骤1: 预分配实验(可补偿)
StepResult step1 = stepAllocateExperiment(context);
if (!step1.isSuccess()) {
return SagaResult.failed("分配实验失败");
}
// 步骤2: 预占权益(可补偿)
StepResult step2 = stepReserveCoupon(context);
if (!step2.isSuccess()) {
// 补偿步骤1
compensateStep1(context);
return SagaResult.failed("权益预占失败,已回滚");
}
// 步骤3: 确认两者(不可逆)
StepResult step3 = stepConfirmAllocation(context);
if (!step3.isSuccess()) {
// 严重错误,需要人工介入
return SagaResult.failedWithManualIntervention("确认失败");
}
return SagaResult.success();
} catch (Exception e) {
log.error("Saga执行异常,sagaId={}", sagaId, e);
return SagaResult.failedWithManualIntervention("系统异常");
}
}
private StepResult stepAllocateExperiment(SagaContext context) {
// 插入一条状态为 PENDING 的实验分配记录
// 而不是直接 FINISHED
return experimentService.tentativeAllocate(
context.getUserId(),
context.getExperimentId(),
context.getSagaId()
);
}
private void compensateStep1(SagaContext context) {
// 删除或标记为 CANCELLED 那条 PENDING 记录
experimentService.cancelTentativeAllocation(context.getSagaId());
}
}
Saga模式的核心洞察:
- 补偿不是回滚:补偿是执行一个对等的业务操作,而不是技术上的事务回滚
- 设计可补偿操作:每个步骤在设计时就要想好"如果后悔了怎么办"
- 幂等性至关重要:任何操作(包括补偿)都可能被重复执行,必须幂等
04 现阶段的折中选择:分层的混合策略
在实际的智能用户增长中台,我们没有采用单一的方案,而是根据不同的业务场景,选择了混合策略:
场景一:实时实验分配 + 权益发放 → Saga + 本地事务表
- 用户参与实验需要实时反馈(毫秒级)
- 但权益发放可以接受秒级延迟
- 实现:Saga保证分配记录的可补偿性,权益发放通过可靠消息异步完成
场景二:用户行为触发积分 → 纯可靠消息,最终一致
- 积分到账延迟几分钟完全可接受
- 实现:行为采集服务写入本地事务表,异步消息触发积分发放
场景三:实验配置同步 → 版本化 + 最终一致
- 配置变更分钟级同步可接受
- 实现:每次变更生成唯一版本号,消费方按版本号去重和排序
我们的架构现状:
智能用户增长中台 - 事务处理分层架构
├── 强一致层(Saga模式)
│ ├── 实验用户分配
│ └── 核心权益预占
│
├── 可靠异步层(本地事务表)
│ ├── 权益实际发放
│ ├── 积分奖励
│ └── 消息通知
│
└── 最终一致层(事件驱动)
├── 实验数据同步
├── 用户画像更新
└── 分析报表生成
05 我们还在对抗的剩余问题
即使有了这些方案,分布式事务的世界依然不完美:
- 人工对账无法完全避免:每周仍有1%的异常case需要人工核对
- 跨系统时钟漂移:不同服务器的时间差异,导致"先有因还是先有果"的判定困难
- 补偿本身的失败:补偿操作也可能失败,形成"俄罗斯套娃"式的失败链
我们现在每天还在和这些问题斗争。但相比最初"数据一团糟"的状态,至少现在:
- 我们知道问题出在哪里
- 我们有工具定位问题
- 我们知道如何一步步修复问题
06 总结:分布式事务不是技术问题,是业务权衡
回顾这段历程,我最深的体会是:选择分布式事务方案,本质上是选择忍受哪种业务痛苦。
- 选择2PC/Seata,是选择忍受性能损耗和架构复杂度,换取强一致。
- 选择Saga,是选择忍受补偿逻辑的复杂性,换取最终一致和性能。
- 选择可靠消息,是选择忍受延迟和数据不一致窗口,换取简单性。
在智能用户增长中台,我们最终选择了:核心路径Saga化,非核心路径消息化,重要数据人工对账兜底。
这不是最优雅的方案,也不是性能最好的方案,但它是在当前团队能力、业务发展阶段和运维成本约束下,最务实的选择。
下一次当你面临分布式事务的选择时,不要只问"哪个技术最牛",而是问:
- 业务能接受多长的不一致窗口?
- 团队能驾驭多复杂的补偿逻辑?
- 运维能承受多重的对账负担?
想清楚这些,答案自然会浮现。
留给你的问题:在你的系统中,有没有一个分布式事务场景,让你在"强一致、高性能、易实现"这个不可能三角中做出了痛苦的权衡?你最终选择了哪一边,又忍受了哪些代价?