跳转至

一、分布式数据库与一致性

1.1 CAP 理论与 BASE 理论

CAP 理论(一致性、可用性、分区容错性)

🧩 1. 什么是 CAP 理论?CP 和 AP 系统分别适用于什么场景?

CAP 理论 是分布式系统的核心约束:一个分布式系统最多只能同时满足 一致性 (Consistency)、可用性 (Availability)、分区容错性 (Partition Tolerance) 中的两个。

image.png

  • 一致性:客户端向节点 A 写入数据,之后从节点 B 读取,必须读到最新写入的值。

  • 可用性:每个请求都能在合理的时间内返回结果,即使某些节点挂了,系统也能继续服务。

  • 分区容错性:系统在任意网络消息丢失或延迟的情况下,仍然能继续工作。

由于网络分区是客观存在的(交换机故障、光缆切断、网络拥塞),P 是分布式系统的必选项。所以真正的选择在于:当网络分区发生时,你是优先保证一致性,还是优先保证可用性?


CP 系统:优先一致性

当发生网络分区时,系统为了确保数据一致,会牺牲部分可用性。例如,分区一侧的节点会拒绝写入或停止服务,直到网络恢复。

特点:

  • 所有节点数据强一致,任何时刻读到的都是最新数据。

  • 分区期间可能不可用(写入被阻塞或拒绝)。

典型系统:

  • ZooKeeper:Leader 选举与元数据管理。一旦 Leader 与多数 Follower 分区,少数派无法提供服务,避免了“脑裂”。

  • etcd:CoreOS 的键值存储,使用 Raft 协议,强一致。

  • HBase:数据强一致,Region Server 失效时,对应分区不可用直到恢复。

适用场景:

  • 金融交易系统:用户账户余额必须一致,不能出现“账户透支”的幻读。

  • 配置中心:配置数据不容许半点不一致,否则不同节点可能加载错误配置。

  • 分布式锁:必须保证锁的互斥性,不能因为分区导致两个客户端同时持有锁。


AP 系统:优先可用性

当发生网络分区时,系统优先保证服务可用,允许不同分区的节点继续处理请求,即使数据可能短暂不一致。网络恢复后,通过各种冲突解决机制(向量时钟、最后写入胜出等)达到最终一致。

特点:

  • 整个系统始终可用,即使部分节点故障。

  • 数据可能存在短暂不一致窗口(最终一致)。

典型系统:

  • Cassandra:去中心化设计,多副本异步同步,允许同一数据在不同节点暂时不同。

  • DynamoDB:亚马逊的云原生数据库,提供最终一致性读取。

  • CouchDB:基于 MVCC,支持离线后同步。

适用场景:

  • 社交媒体动态流:用户发帖后,另一用户稍后看到没有影响,短暂不一致可接受。

  • 电商商品详情页:商品库存显示误差(99 vs 100)不会造成严重后果。

  • DNS 解析:变更后可能需要几分钟到几小时全球生效,完全可用。

代码示例:模拟 CP 与 AP 的决策

# CP 模式:数据不一致时拒绝服务
def read_cp(node):
    if not node.is_consistent():
        raise Exception("数据不一致,服务暂停")
    return node.data

# AP 模式:容忍不一致,返回可能过时的数据
def read_ap(node):
    return node.data  # 直接返回,不管是否最新

收束: CP 与 AP 的抉择并不是非黑即白的全局开关,而是每个具体功能模块的独立决策——金融核心用 CP,社交 Feed 用 AP,而同一个系统内可以并存两种模式。


⚖️ 2. BASE 理论是什么?它与 CAP 理论是什么关系?在实际业务中如何平衡一致性和可用性?

BASE 理论 是 Basically Available, Soft state, Eventually consistent 的缩写,它是在 CAP 理论约束下,对 AP 系统的具体实践指导。

image.png

BASE 三个要素:

  • Basically Available (基本可用):系统出现故障时,允许损失部分可用性,但核心功能必须可用。例如请求排队、降级页面。

  • Soft state (软状态):系统中的数据允许存在中间状态,且该中间状态不会影响系统整体可用性。

  • Eventually consistent (最终一致性):不要求数据更新后立刻一致,但保证在没有新的更新的情况下,最终所有副本都将达成一致。

与 CAP 的关系:

CAP 告诉我们“鱼与熊掌不可兼得”,BASE 则是告诉我们“如果你选择了 AP,应该如何优雅地活下去”。CAP 是理论模型,BASE 是工程实践。

在实际业务中如何平衡?

并不是整个系统一刀切地选择 C 或 A,而是根据不同业务场景的容忍度,分层设计。

分层平衡策略:

  1. 核心数据用 CP:账户余额、订单状态、支付流水必须强一致。

  2. 外围数据用 AP + BASE:商品库存显示、评价列表、浏览记录等允许短暂不一致。

  3. 本地事务 + 消息队列实现最终一致性:核心流程内的强一致;跨系统的异步通知采用可靠消息最终一致。

示例:下订单扣库存的平衡设计

// 1. 核心:订单生成与库存扣减在同一个本地事务中,CP 保证
@Transactional
public void createOrder(Order order) {
    orderDao.insert(order);
    for (OrderItem item : order.getItems()) {
        int affected = inventoryDao.deduct(item.getSkuId(), item.getQuantity());
        if (affected == 0) {
            throw new InsufficientStockException();
        }
    }
    // 2. 外围:发送消息通知积分、物流等系统,AP + BASE
    sendMessageAsync("order_created", order); // 异步,允许短暂延迟
}

这里,订单表和库存表在同一个数据库内,通过本地 ACID 事务保证强一致。积分、物流等下游系统通过消息队列异步通知,采用 BASE 的最终一致性——万一消息延迟,也会通过重试或对账最终补齐。

补偿与兜底:

  • 离线对账:定时扫描订单表与积分流水、物流单的差异,补发遗漏。

  • 业务补偿:对已发货但未扣积分的订单,事后补扣或补发积分。

  • 人工干预:关键异常生成工单,人工核实处理。

一句话收束: BASE 的本质是承认分布式世界的混沌性,用软状态和最终一致来换取系统的可用性和扩展性,并在其上叠加对账、补偿等机制,让不一致最终无处遁形。


🌐 3. 设计一个跨区域的分布式订单系统,保证高可用性与数据一致性

这是一个典型的多活架构 + 分布式事务的综合性问题。假设系统部署在北京、上海、广州三个区域,每个区域有独立的数据库实例。用户下单后需要同步更新库存、积分、物流等系统。

核心挑战:

  • 跨区域网络延迟大,无法做实时强一致。

  • 任何单一区域可能整体故障,需自动切换。

  • 库存是全局共享资源,需要防止超卖。

设计蓝图

                     ┌──────────────┐
                     │  全局流量调度  │ (智能 DNS / GSLB)
                     └──────┬───────┘
            ┌───────────────┼───────────────┐
            ▼               ▼               ▼
      ┌──────────┐    ┌──────────┐    ┌──────────┐
      │ 北京区域  │    │ 上海区域  │    │ 广州区域  │
      │ (主/从)  │    │ (主/从)  │    │ (主/从)  │
      └─────┬────┘    └─────┬────┘    └─────┬────┘
            │               │               │
    ┌───────┼───────┐       │               │
    ▼       ▼       ▼       ▼               ▼
  ┌────┐ ┌────┐ ┌────┐  ┌────────┐    ┌────────┐
  │订单│ │库存│ │积分│  │ 物流... │    │ 缓存... │
  │DB  │ │DB  │ │DB  │  │         │    │         │
  └────┘ └────┘ └────┘  └────────┘    └────────┘
       (每个区域都有独立的数据库实例)

核心设计原则:

  1. 单元化架构 (Unitized Architecture) 每个区域自包含全部服务(订单、库存、积分、物流等),用户按 ID 哈希或地理位置路由到固定区域,同一用户的所有数据写入只在主区域进行,避免跨区域实时同步。

  2. 全局库存:区域预分配 + 中心协调 全国库存不能直接拆分成独立实例,否则会出现超卖。

  3. 中心库存服务维护一个逻辑总库存池。
  4. 每个区域申请一批库存配额(如每次 1000 件),本地扣减配额的库存。
  5. 当配额用尽,区域向中心异步申请下一批配额。
  6. 订单提交时,若本地配额充足,扣减本地库存并落订单(本地事务)。若不足,先申请配额再扣减。
  7. 用户取消订单或退货时,回补本地配额。

  8. 跨区域异步通信 + 本地事务 用户下单后,订单、库存扣减、积分发放等都在用户主区域的数据库中通过本地事务完成,保证强一致。 物流等下游系统通过消息队列异步通知,采用本地消息表保证消息不丢失。

  9. 高可用保障

  10. 同区域多节点:每个服务部署多个实例,数据库采用主从+哨兵。
  11. 异地容灾:一个区域整体不可用时,GSLB 将用户流量切换到备用区域。对于已有数据,备用区域可从主区域的数据备份中恢复;对于新用户,直接路由到备用区域。
  12. 降级开关:大促期间,非核心功能(如积分、推荐)可降级或关闭,优先保证下单链路。

下单核心流程代码设计(以北京区域为例):

@Service
public class OrderService {
    @Autowired private OrderDao orderDao;
    @Autowired private LocalInventoryDao localInventoryDao;
    @Autowired private OutboxDao outboxDao;
    @Autowired private MQProducer mqProducer;

    @Transactional
    public void placeOrder(Order order) {
        // 1. 生成订单并插入订单表
        orderDao.insert(order);
        // 2. 扣减本地库存配额(已预先从中心库存池申请)
        for (OrderItem item : order.getItems()) {
            int affected = localInventoryDao.deductQuota(item.getSkuId(), item.getQty());
            if (affected == 0) {
                // 若配额不足,触发异步申请配额,事务回滚,客户端可稍后重试
                throw new InsufficientQuotaException();
            }
        }
        // 3. 本地消息表记录需异步通知的事件
        outboxDao.insert(new Outbox("order_placed", order.getId(), orderJson));
        // 事务提交后,后台定时任务将 outbox 消息可靠地发送到 MQ
    }
}

// 定时任务:发送 outbox 中待处理的消息
@Scheduled(fixedDelay = 2000)
public void sendOutboxMessages() {
    List<Outbox> pending = outboxDao.findPending(100);
    for (Outbox msg : pending) {
        boolean sent = mqProducer.send(msg.getTopic(), msg.getPayload());
        if (sent) {
            outboxDao.markSent(msg.getId());
        }
    }
}

下游系统消费(积分、物流等)

// 积分服务消费者(可能部署在北京之外的区域,但无所谓)
@RocketMQMessageListener(topic = "order_placed", consumerGroup = "points_group")
public class PointsConsumer implements RocketMQListener<String> {
    public void onMessage(String msg) {
        OrderEvent event = parse(msg);
        // 幂等:根据 orderId 判断是否已发放积分
        if (pointsService.exists(event.getOrderId())) return;
        // 发放积分(与积分发放流水记录在同一本地事务)
        pointsService.award(event);
    }
}

中心库存配额管理服务

public class GlobalInventoryService {
    // 区域请求配额
    @Transactional
    public synchronized InventoryQuota requestQuota(String region, String skuId, int qty) {
        GlobalInventory global = globalInventoryDao.findBySku(skuId);
        if (global.getAvailable() < qty) {
            throw new InsufficientGlobalStockException();
        }
        global.setAvailable(global.getAvailable() - qty);
        globalInventoryDao.update(global);
        RegionQuota quota = new RegionQuota(region, skuId, qty);
        regionQuotaDao.insert(quota);
        return quota;
    }
}

跨区域故障转移

当北京区域整体不可用时,GSLB 将用户流量导向上海。此时上海区域数据库中没有该用户的数据。解决方案:

  • 对于未登录用户或新会话,直接在上海区域创建新数据,无影响。

  • 对于已登录用户,由于我们采用单元化设计,用户数据本应在主区域。故障期间,可以降级提供只读缓存数据(如从异地灾备的只读副本读取),或提示用户稍后重试。通常数据中心间有专线,可以进行数据库的准实时备份和日志同步(如 MySQL 异步复制),切换后短暂缺失的数据由对账任务补齐。

收束:

跨区域订单系统的设计,本质上是用单元化来压缩分布式事务的范围,用库存配额来化解全局热点,用本地消息表来保证异步通知的可靠,用最终一致性来弥补同步强一致的不可能。在这样的架构下,系统既能扛住亿级流量的冲击,也能在任一数据中心倒塌时从容转身。

1.2 分布式事务

分布式事务解决方案

1、基础题:什么是 2PC 和 3PC?它们有什么区别?

(2PC/3PC 原理、优缺点对比)

2PC(Two-Phase Commit,两阶段提交)是一种分布式事务协议,分为两个阶段:

  • 准备阶段(Prepare):协调者询问所有参与者是否可以提交,参与者执行事务但不提交,返回"可以"或"不可以"

  • 提交阶段(Commit):如果所有参与者都返回"可以",协调者发送提交指令,否则发送回滚指令

3PC(Three-Phase Commit,三阶段提交)在 2PC 基础上增加了一个阶段:

  • CanCommit 阶段:协调者询问参与者是否可以执行事务

  • PreCommit 阶段:参与者预执行事务,但不提交

  • DoCommit 阶段:协调者根据参与者反馈决定提交或回滚

区别:

  • 阻塞问题:2PC 在准备阶段会阻塞参与者,3PC 通过预提交减少阻塞时间

  • 单点故障:2PC 如果协调者故障,参与者会一直阻塞;3PC 引入超时机制,参与者可以自动决策

  • 性能:3PC 多一个阶段,性能略差

2、进阶题:TCC、Saga 模式分别适用于什么场景?如何实现补偿机制?

⭐⭐(TCC 原理、Saga 模式、补偿机制设计)

1️⃣ Common Answer TCC 就是 Try-Confirm-Cancel,三个阶段。Saga 是把一个大事务拆成多个小事务,每个小事务都有对应的补偿操作。如果失败了就执行补偿。

2️⃣ Impressive Answer TCC 和 Saga 都是分布式事务的解决方案,但适用场景和实现方式有很大区别。

TCC(Try-Confirm-Cancel)模式

TCC 将业务逻辑拆分为三个阶段:

  • Try 阶段:预留资源,检查业务可行性

  • Confirm 阶段:确认执行业务操作,使用 Try 阶段预留的资源

  • Cancel 阶段:取消业务操作,释放 Try 阶段预留的资源

适用场景

  • 资源型业务,如库存扣减、账户转账

  • 对一致性要求高的场景

  • 业务逻辑相对简单,可以拆分为三个明确阶段

实现示例(库存扣减)

// Try:冻结库存
public boolean tryDeductStock(String orderId, String productId, int count) {
    // 检查库存是否充足
    if (getAvailableStock(productId) < count) {
        return false;
    }
    // 将库存从可用转为冻结
    freezeStock(productId, count, orderId);
    return true;
}

// Confirm:确认扣减
public boolean confirmDeductStock(String orderId, String productId, int count) {
    // 删除冻结记录,实际扣减库存
    deductStock(productId, count);
    return true;
}

// Cancel:取消扣减
public boolean cancelDeductStock(String orderId, String productId, int count) {
    // 将冻结库存转回可用
    unfreezeStock(productId, count, orderId);
    return true;
}

TCC 的优缺点

  • 优点:性能好,不依赖外部组件;一致性保证强

  • 缺点:代码侵入性强,每个业务都要写三个方法;开发成本高;容易出现悬挂、空回滚等问题

Saga 模式

Saga 将长事务拆分为多个本地短事务,每个短事务都有对应的补偿事务。如果某个短事务失败,就执行之前所有已执行短事务的补偿操作。

适用场景

  • 业务流程长、步骤多的场景

  • 某些步骤无法回滚,只能补偿的场景

  • 对实时性要求不高的场景

Saga 的两种实现方式

  1. 协调式 Saga(Choreography)

  2. 每个服务执行完本地事务后,发布事件

  3. 下一个服务订阅事件,执行自己的事务

  4. 如果失败,发布补偿事件,触发已执行服务的补偿操作

sequenceDiagram
    participant Order as 订单服务
    participant Inventory as 库存服务
    participant Payment as 支付服务

    Order->>Order: 创建订单
    Order->>Inventory: 扣减库存事件
    Inventory->>Inventory: 扣减库存
    Inventory->>Payment: 支付事件
    Payment->>Payment: 执行支付
    alt 支付失败
        Payment->>Inventory: 补偿库存事件
        Inventory->>Inventory: 增加库存
        Inventory->>Order: 补偿订单事件
        Order->>Order: 取消订单
    end
  1. 编排式 Saga(Orchestration)

  2. 有一个协调者(Saga 协调器)负责整个流程

  3. 协调者按顺序调用各个服务

  4. 如果失败,协调者按逆序调用补偿操作

// Saga 协调器伪代码
public void executeOrderSaga(Order order) {
    List<SagaStep> steps = Arrays.asList(
        new CreateOrderStep(order),
        new DeductStockStep(order),
        new ProcessPaymentStep(order)
    );

    int executedSteps = 0;
    try {
        for (int i = 0; i < steps.size(); i++) {
            steps.get(i).execute();
            executedSteps = i + 1;
        }
    } catch (Exception e) {
        // 按逆序执行补偿
        for (int i = executedSteps - 1; i >= 0; i--) {
            steps.get(i).compensate();
        }
        throw e;
    }
}

补偿机制的设计要点

  1. 补偿操作必须是幂等的

  2. 补偿可能被调用多次,必须保证多次执行结果一致

  3. 使用唯一业务 ID(如订单号)作为幂等键

  4. 补偿操作要考虑并发问题

  5. 在补偿过程中,可能有其他操作也在修改数据

  6. 使用乐观锁或分布式锁避免并发冲突

  7. 补偿失败的处理

  8. 如果补偿也失败了,需要人工介入

  9. 记录详细的失败日志,包括原始操作、补偿操作、失败原因

  10. 补偿的可见性

  11. 补偿操作可能需要一定时间,用户查询时要能正确显示状态

  12. 比如"退款处理中",而不是显示错误的金额

我在实际项目中的应用

在电商订单系统中,我采用了 Saga 模式:

  1. 订单服务创建订单(本地事务)

  2. 库存服务扣减库存(本地事务)

  3. 支付服务处理支付(本地事务)

  4. 物流服务创建运单(本地事务)

如果支付失败,Saga 协调器会:

  1. 调用库存服务的补偿接口,增加库存

  2. 调用订单服务的补偿接口,取消订单

为了保证可靠性,我做了以下设计:

  • 每个步骤执行前,先记录操作日志(状态为"执行中")

  • 执行成功后,更新日志状态为"已完成"

  • 执行失败时,更新日志状态为"失败",并记录失败原因

  • 定时任务扫描"执行中"超过 10 分钟的记录,重试或补偿

  • 提供管理后台,支持手动触发补偿

这样设计的好处是:

  1. 解耦:各服务独立,不需要统一的分布式事务协调器

  2. 可观测:每个步骤的状态都记录在日志中,便于追踪

  3. 可恢复:即使服务重启,也能根据日志恢复状态

  4. 灵活性:可以根据业务需要调整流程顺序

3️⃣ Key Differences

维度 Common Answer Impressive Answer
技术深度 简单描述三个阶段 详细说明每个阶段的实现细节和注意事项
实践经验 没有具体示例 结合电商场景说明完整的实现方案
思考维度 仅说明基本概念 考虑幂等、并发、失败处理、监控等完整设计
表达方式 口语化 结构化阐述,配合代码示例和流程图
面试官印象 理论掌握,但缺乏实战 有丰富的实战经验,能设计可靠的系统

3、在电商秒杀场景中,用户下单后需要扣减库存、扣减优惠券、扣减账户余额,这三个操作必须全部成功或全部失败。如何设计分布式事务方案?

⭐⭐⭐(秒杀场景、高并发、事务一致性)

1️⃣ Common Answer 用 TCC 吧,先预留库存、冻结优惠券、冻结余额,然后一起提交。如果失败了就回滚。

2️⃣ Impressive Answer 秒杀场景的特点是高并发、低延迟、强一致性,我会采用TCC + 本地消息表 + 补偿的组合方案。

首先分析业务需求:

  • 一致性要求:库存、优惠券、余额必须原子性操作,不能出现"库存扣了但余额没扣"的情况

  • 性能要求:秒杀期间 QPS 可达 10 万+,响应时间要控制在 100ms 以内

  • 可用性要求:系统不能因为某个服务故障而完全不可用

整体方案设计

sequenceDiagram
    participant User as 用户
    participant Gateway as 网关
    participant Order as 订单服务
    participant Inventory as 库存服务
    participant Coupon as 优惠券服务
    participant Account as 账户服务
    participant MQ as 消息队列
    participant DB as 数据库

    User->>Gateway: 下单请求
    Gateway->>Order: 路由到订单服务
    Order->>Order: 限流&防重检查
    Order->>Inventory: Try: 预扣库存
    Inventory->>DB: 冻结库存
    Inventory-->>Order: 成功
    Order->>Coupon: Try: 冻结优惠券
    Coupon->>DB: 标记优惠券使用中
    Coupon-->>Order: 成功
    Order->>Account: Try: 冻结余额
    Account->>DB: 冻结余额
    Account-->>Order: 成功
    Order->>DB: 创建订单(本地事务)
    Order->>MQ: 发送确认消息
    par 异步确认
        MQ->>Inventory: Confirm: 确认扣减
        Inventory->>DB: 扣减库存
        MQ->>Coupon: Confirm: 确认使用
        Coupon->>DB: 标记优惠券已使用
        MQ->>Account: Confirm: 确认扣款
        Account->>DB: 扣减余额
    end
    Order-->>User: 返回成功

详细实现方案

  1. TCC 三个阶段的设计

Try 阶段(同步调用,超时时间 50ms)

// 库存服务 Try
@Transactional
public boolean tryDeductStock(String userId, String productId, int count) {
    // 1. 检查库存是否充足
    int available = getAvailableStock(productId);
    if (available < count) {
        return false;
    }

    // 2. 检查用户是否已抢购(防重)
    String lockKey = "seckill:" + userId + ":" + productId;
    if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.MINUTES)) {
        // 3. 冻结库存(原子操作)
        boolean frozen = freezeStock(productId, count, userId);
        if (!frozen) {
            redisTemplate.delete(lockKey);
            return false;
        }
        return true;
    }
    return false;
}

// 优惠券服务 Try
@Transactional
public boolean tryUseCoupon(String userId, String couponId) {
    // 1. 检查优惠券是否有效
    Coupon coupon = getCoupon(couponId);
    if (coupon == null || coupon.getStatus() != CouponStatus.AVAILABLE) {
        return false;
    }

    // 2. 检查优惠券是否属于该用户
    if (!coupon.getUserId().equals(userId)) {
        return false;
    }

    // 3. 冻结优惠券(状态转为 USING)
    return freezeCoupon(couponId, userId);
}

// 账户服务 Try
@Transactional
public boolean tryDeductBalance(String userId, BigDecimal amount) {
    // 1. 检查余额是否充足
    BigDecimal balance = getBalance(userId);
    if (balance.compareTo(amount) < 0) {
        return false;
    }

    // 2. 冻结余额
    return freezeBalance(userId, amount);
}

Confirm 阶段(异步 MQ 消费)

// 库存服务 Confirm
@Transactional
public boolean confirmDeductStock(String userId, String productId, int count) {
    // 1. 删除冻结记录
    deleteFreezeRecord(userId, productId);

    // 2. 实际扣减库存
    deductStock(productId, count);

    // 3. 记录操作日志
    logOperation(userId, productId, "DEDUCT", count);

    return true;
}

// 优惠券服务 Confirm
@Transactional
public boolean confirmUseCoupon(String userId, String couponId) {
    // 1. 更新优惠券状态为 USED
    updateCouponStatus(couponId, CouponStatus.USED);

    // 2. 记录使用时间
    updateUsedTime(couponId, new Date());

    return true;
}

// 账户服务 Confirm
@Transactional
public boolean confirmDeductBalance(String userId, BigDecimal amount) {
    // 1. 删除冻结记录
    deleteFreezeBalance(userId, amount);

    // 2. 实际扣减余额
    deductBalance(userId, amount);

    // 3. 记录交易流水
    recordTransaction(userId, amount, "SECKILL_PAYMENT");

    return true;
}

Cancel 阶段(异常时调用)

// 库存服务 Cancel
@Transactional
public boolean cancelDeductStock(String userId, String productId, int count) {
    // 1. 删除冻结记录
    deleteFreezeRecord(userId, productId);

    // 2. 释放 Redis 锁
    String lockKey = "seckill:" + userId + ":" + productId;
    redisTemplate.delete(lockKey);

    return true;
}

// 优惠券服务 Cancel
@Transactional
public boolean cancelUseCoupon(String userId, String couponId) {
    // 1. 恢复优惠券状态为 AVAILABLE
    updateCouponStatus(couponId, CouponStatus.AVAILABLE);

    return true;
}

// 账户服务 Cancel
@Transactional
public boolean cancelDeductBalance(String userId, BigDecimal amount) {
    // 1. 删除冻结记录
    deleteFreezeBalance(userId, amount);

    return true;
}
  1. 可靠性保障机制

本地消息表

  • 订单服务在本地事务中同时写入订单和消息记录

  • 消息记录包含:消息 ID、目标服务、消息内容、状态(待发送/已发送/已确认)

  • 定时任务扫描"待发送"状态的消息,重新发送

  • 消息消费成功后,更新状态为"已确认"

幂等性设计

  • 所有 Confirm/Cancel 操作都要做幂等检查

  • 使用订单号作为幂等键

  • 在数据库中记录每个订单的操作状态

超时和重试

  • Try 阶段设置 50ms 超时

  • Confirm 阶段如果失败,MQ 自动重试 3 次

  • 超过重试次数,转入死信队列,人工处理

补偿任务

  • 每 5 分钟扫描 Try 成功但 Confirm 超过 10 分钟未完成的订单

  • 重新发送 Confirm 消息

  • 如果 Confirm 一直失败,自动触发 Cancel

  • 性能优化

Redis 缓存

  • 库存信息预热到 Redis

  • Try 阶段先查 Redis 缓存,缓存未命中再查数据库

  • 使用 Lua 脚本保证原子性

数据库优化

  • 库存表按商品 ID 分库分表

  • 使用乐观锁更新库存:UPDATE stock SET count = count - ? WHERE product_id = ? AND count >= ?

  • 优惠券表按用户 ID 分库分表

异步处理

  • Confirm 阶段异步处理,不阻塞主流程

  • 用户下单后立即返回成功,后台异步完成确认

降级策略

  • 如果优惠券服务不可用,自动降级为"不使用优惠券"

  • 如果账户服务不可用,降级为"货到付款"

  • 核心是保证订单创建成功,后续可以人工处理

  • 监控和告警

实时监控

  • Try 阶段的成功率和响应时间

  • Confirm 消息的堆积量

  • 补偿任务的执行情况

  • 库存、优惠券、余额的冻结数量

告警规则

  • Try 成功率低于 99% 时告警

  • Confirm 消息堆积超过 1000 条时告警

  • 补偿任务失败率超过 1% 时告警

  • 冻结资源超过 10 分钟未释放时告警

我在上一个项目中就是这么实现的,支撑了双 11 秒杀活动,峰值 QPS 达到 15 万,订单成功率 99.9%,没有出现超卖或少扣的情况。整个方案的响应时间控制在 80ms 以内,用户体验很好。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
技术深度 简单提到 TCC 详细说明 TCC 三个阶段的完整实现
实践经验 没有具体代码 提供完整的代码示例和架构设计
思考维度 仅考虑事务一致性 考虑性能优化、降级策略、监控告警等完整方案
表达方式 简单描述 结构化阐述,配合时序图和代码
面试官印象 理论掌握,但缺乏实战 有丰富的高并发系统设计经验

1.3 分布式锁

分布式锁实现方案

1、Redis 实现分布式锁的常用命令是什么?如何避免死锁?

(SETNX、过期时间、解锁)

Redis 实现分布式锁主要使用 SET key value NX PX timeout 命令:

  • SETNX:只在 key 不存在时设置,保证只有一个客户端能获取锁

  • PX timeout:设置过期时间,避免客户端崩溃导致死锁

避免死锁的方法:

  1. 设置合理的过期时间

  2. 使用 Lua 脚本保证解锁的原子性(先判断锁是否属于自己,再删除)

  3. 守护线程自动续期(看门狗机制)

2、对比 Redis SetNX、Redlock、ZooKeeper 临时节点、数据库乐观锁四种分布式锁实现方案的优缺点。

⭐⭐(多种锁方案对比、适用场景)

1️⃣ Common Answer Redis SetNX 简单好用,性能高。ZooKeeper 更可靠,但是慢。数据库乐观锁适合读多写少的场景。Redlock 是 Redis 的改进版。

2️⃣ Impressive Answer 这四种分布式锁方案各有优缺点,我会从可靠性、性能、复杂度、适用场景四个维度来对比分析。

  1. Redis SetNX(单节点)

实现原理

// 加锁
public boolean tryLock(String lockKey, String requestId, int expireTime) {
    // SET key value NX PX expireTime
    return redisTemplate.opsForValue()
        .setIfAbsent(lockKey, requestId, expireTime, TimeUnit.MILLISECONDS);
}

// 解锁(Lua 脚本保证原子性)
public void unlock(String lockKey, String requestId) {
    String luaScript =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('del', KEYS[1]) " +
        "else " +
        "    return 0 " +
        "end";
    redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
                          Collections.singletonList(lockKey), requestId);
}

优点

  • 性能高:基于内存操作,响应快(毫秒级)

  • 实现简单:几行代码就能实现

  • 支持超时:自动过期,避免死锁

缺点

  • 单点故障:如果 Redis 节点宕机,锁会丢失

  • 时钟漂移:依赖系统时间,如果时钟不同步可能导致问题

  • 主从切换:Redis 主从异步复制,主节点宕机时,从节点可能还没同步锁信息

适用场景

  • 对可靠性要求不高的场景

  • 允许极小概率的锁失效

  • 高并发、低延迟要求

  • Redlock(Redis 分布式锁)

实现原理

  • 同时向 N 个(通常是 5 个)独立的 Redis 节点申请锁

  • 如果超过半数(3 个)节点成功获取锁,且获取锁的时间小于锁的有效期,则认为加锁成功

  • 解锁时向所有节点发送解锁命令

public boolean tryLock(String lockKey, String requestId, int expireTime) {
    List<RedisTemplate> redisNodes = getRedisNodes();
    int successCount = 0;
    long startTime = System.currentTimeMillis();

    for (RedisTemplate redis : redisNodes) {
        boolean success = redis.opsForValue()
            .setIfAbsent(lockKey, requestId, expireTime, TimeUnit.MILLISECONDS);
        if (success) {
            successCount++;
        }
    }

    long elapsedTime = System.currentTimeMillis() - startTime;
    // 超过半数成功,且耗时小于锁的有效期
    if (successCount >= (redisNodes.size() / 2 + 1)
        && elapsedTime < expireTime) {
        return true;
    }

    // 获取失败,释放已获取的锁
    unlockAll(lockKey, requestId);
    return false;
}

优点

  • 高可用:即使部分节点故障,仍能正常工作

  • 避免单点:不依赖单个 Redis 节点

缺点

  • 性能较低:需要向多个节点发起请求

  • 实现复杂:需要管理多个 Redis 节点

  • 仍有争议:Martin Kleppmann 认为它存在时序问题

适用场景

  • 对可靠性要求较高的场景

  • 可以接受稍差的性能

  • 需要避免单点故障

  • ZooKeeper 临时节点

实现原理

  • 使用 ZooKeeper 的临时顺序节点(EPHEMERAL_SEQUENTIAL)

  • 客户端创建临时节点,如果是最小的节点,则获取锁

  • 否则监听前一个节点的删除事件,前一个节点删除后唤醒自己

  • 客户端断开连接时,临时节点自动删除

public boolean tryLock(String lockPath) throws Exception {
    // 1. 创建临时顺序节点
    String currentNode = zkClient.createEphemeralSequential(lockPath + "/", "");

    // 2. 获取所有子节点
    List<String> children = zkClient.getChildren(lockPath);
    Collections.sort(children);

    // 3. 判断是否是最小的节点
    if (currentNode.equals(lockPath + "/" + children.get(0))) {
        return true; // 获取锁成功
    }

    // 4. 监听前一个节点
    String previousNode = lockPath + "/" + children.get(Collections.binarySearch(children, currentNode.substring(lockPath.length() + 1)) - 1);
    final CountDownLatch latch = new CountDownLatch(1);
    zkClient.subscribeDataChanges(previousNode, new IZkDataListener() {
        @Override
        public void handleDataDeleted(String dataPath) {
            latch.countDown();
        }

        @Override
        public void handleDataChange(String dataPath, Object data) {}
    });

    latch.await(); // 等待前一个节点删除
    return true;
}

优点

  • 可靠性高:基于 CP 理论,强一致性

  • 自动释放:客户端断开连接时自动释放锁

  • 避免羊群效应:只监听前一个节点,而不是所有节点

缺点

  • 性能较差:每次操作都需要网络往返,响应慢(几十到几百毫秒)

  • 依赖 ZooKeeper:需要额外部署和维护 ZooKeeper 集群

  • 实现复杂:需要处理 Watcher 事件、重连等问题

适用场景

  • 对可靠性要求极高的场景

  • 可以接受较差的性能

  • 如金融交易、分布式协调

  • 数据库乐观锁

实现原理

  • 使用版本号或时间戳

  • 更新时检查版本号是否变化

  • 使用 CAS(Compare And Swap)思想

@Transactional
public boolean deductStock(String productId, int count) {
    // 1. 查询当前版本号
    Product product = productMapper.selectById(productId);

    // 2. 使用乐观锁更新
    int updated = productMapper.updateStockWithVersion(
        productId, count, product.getVersion());

    if (updated == 0) {
        // 版本号不匹配,更新失败
        return false;
    }
    return true;
}

// SQL: UPDATE product SET stock = stock - ?, version = version + 1
//      WHERE id = ? AND version = ?

优点

  • 实现简单:不需要额外组件

  • 无死锁:不会出现死锁问题

  • 适合读多写少:冲突少时性能好

缺点

  • 高并发下性能差:冲突频繁时,大量重试

  • 不适合长事务:持有锁的时间不能太长

  • ABA 问题:如果版本号回绕,可能出现 ABA 问题

适用场景

  • 读多写少的场景

  • 冲突不频繁的业务

  • 如库存扣减、余额更新

综合对比表

维度 Redis SetNX Redlock ZooKeeper 数据库乐观锁
可靠性 低(单点) 中(多节点) 高(CP) 中(依赖数据库)
性能 高(ms级) 中(几倍Redis) 低(几十ms) 低(数据库IO)
复杂度
适用场景 高并发、低可靠性要求 高可用、可接受性能损失 高可靠性、金融场景 读多写少、低并发

我的选择建议

  1. 大多数业务场景:选择 Redis SetNX
  2. 性能满足要求
  3. 实现简单
  4. 通过合理的过期时间和重试机制,可以满足大部分需求

  5. 核心业务场景:选择 Redlock 或 ZooKeeper

  6. 如订单支付、库存扣减等核心业务
  7. 可以接受稍差的性能,但要求高可靠性

  8. 读多写少场景:选择数据库乐观锁

  9. 如用户资料更新、配置修改
  10. 冲突少,性能好

  11. 金融级场景:选择 ZooKeeper

  12. 如转账、结算等
  13. 强一致性要求

我在实际项目中的应用

在电商系统中,我采用了分层策略

  • 秒杀扣库存:使用 Redis SetNX + 降级方案(Redis 不可用时降级到数据库乐观锁)

  • 订单状态更新:使用数据库乐观锁

  • 分布式任务调度:使用 ZooKeeper

这样既保证了性能,又兼顾了可靠性。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
技术深度 简单描述各种方案 详细说明每种方案的实现原理和代码
实践经验 没有具体应用场景 结合实际项目说明如何选择和组合使用
思考维度 仅对比优缺点 从可靠性、性能、复杂度、适用场景多维度分析
表达方式 简单列举 结构化对比,提供选择建议和实际应用
面试官印象 理论掌握,但缺乏实战 有丰富的分布式系统设计经验

3、:设计一个秒杀系统的库存扣减方案,要求支持每秒 10 万 QPS,且不能出现超卖。如何选择和实现分布式锁?

⭐⭐⭐(高并发、秒杀、库存扣减)

1️⃣ Common Answer 用 Redis 做库存扣减,用 SETNX 加锁。如果 Redis 挂了,就用数据库。Lua 脚本保证原子性。

2️⃣ Impressive Answer 秒杀场景的核心挑战是高并发 + 强一致性,我会采用Redis Lua + 本地缓存 + 降级方案的组合策略。

首先明确需求:

  • QPS 要求:10 万+/秒

  • 一致性要求:绝对不能超卖

  • 可用性要求:Redis 故障时仍能提供服务

  • 响应时间:< 100ms

整体架构设计

graph TD
    A[用户请求] --> B[网关层]
    B --> C[限流]
    C --> D[本地库存缓存]
    D --> E{库存充足?}
    E -->|否| F[直接返回失败]
    E -->|是| G[Redis Lua 脚本扣减]
    G --> H{扣减成功?}
    H -->|是| I[创建订单]
    H -->|否| J[降级到数据库]
    J --> K[数据库乐观锁扣减]
    K --> L{扣减成功?}
    L -->|是| I
    L -->|否| F
    I --> M[返回成功]

详细实现方案

  1. 本地库存缓存(第一道防线)
// 应用启动时预热库存到本地缓存
@PostConstruct
public void initLocalStock() {
    // 从数据库加载秒杀商品库存
    List<SeckillProduct> products = seckillProductMapper.selectAll();
    for (SeckillProduct product : products) {
        localStockCache.put(product.getId(), product.getStock());
    }

    // 启动定时任务,每 5 秒同步一次 Redis 库存
    scheduledExecutor.scheduleAtFixedRate(this::syncLocalStock,
                                         5, 5, TimeUnit.SECONDS);
}

// 同步本地库存
private void syncLocalStock() {
    for (String productId : localStockCache.keySet()) {
        int redisStock = getStockFromRedis(productId);
        localStockCache.put(productId, redisStock);
    }
}

// 本地库存检查
public boolean checkLocalStock(String productId, int count) {
    Integer stock = localStockCache.get(productId);
    return stock != null && stock >= count;
}
  1. Redis Lua 脚本扣减(核心逻辑)
-- seckill_deduct_stock.lua
local productId = KEYS[1]
local userId = ARGV[1]
local count = tonumber(ARGV[2])
local requestId = ARGV[3]
local expireTime = tonumber(ARGV[4])

-- 1. 检查库存是否充足
local stock = redis.call('GET', 'stock:' .. productId)
if not stock then
    return -1  -- 商品不存在
end
stock = tonumber(stock)
if stock < count then
    return 0   -- 库存不足
end

-- 2. 检查用户是否已购买(防重)
local userKey = 'user:' .. userId .. ':' .. productId
if redis.call('EXISTS', userKey) == 1 then
    return -2  -- 用户已购买
end

-- 3. 扣减库存
redis.call('DECRBY', 'stock:' .. productId, count)

-- 4. 记录用户购买(防重)
redis.call('SETEX', userKey, expireTime, '1')

-- 5. 记录购买流水(用于对账)
redis.call('LPUSH', 'purchase:log:' .. productId,
           userId .. ':' .. count .. ':' .. requestId)

return 1  -- 成功

Java 调用代码

public boolean deductStock(String productId, String userId,
                          int count, String requestId) {
    // 1. 本地库存快速检查
    if (!checkLocalStock(productId, count)) {
        return false;
    }

    // 2. 执行 Redis Lua 脚本
    String luaScript = loadLuaScript("seckill_deduct_stock.lua");
    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(luaScript, Long.class),
        Collections.singletonList(productId),
        userId, String.valueOf(count), requestId, "3600"
    );

    // 3. 处理结果
    if (result == 1) {
        // 扣减成功
        return true;
    } else if (result == 0) {
        // 库存不足
        return false;
    } else if (result == -1) {
        // 商品不存在
        throw new BusinessException("商品不存在");
    } else if (result == -2) {
        // 用户已购买
        throw new BusinessException("每人限购一件");
    }

    return false;
}
  1. 降级方案(Redis 不可用时)
public boolean deductStockWithFallback(String productId, String userId,
                                      int count, String requestId) {
    try {
        // 尝试 Redis 扣减
        return deductStock(productId, userId, count, requestId);
    } catch (Exception e) {
        // Redis 异常,降级到数据库
        log.warn("Redis 扣减失败,降级到数据库", e);
        return deductStockFromDB(productId, userId, count, requestId);
    }
}

// 数据库乐观锁扣减
@Transactional
public boolean deductStockFromDB(String productId, String userId,
                                int count, String requestId) {
    // 1. 查询库存
    SeckillProduct product = seckillProductMapper.selectById(productId);
    if (product == null || product.getStock() < count) {
        return false;
    }

    // 2. 检查用户是否已购买
    SeckillOrder existOrder = seckillOrderMapper.selectByUserAndProduct(
        userId, productId);
    if (existOrder != null) {
        throw new BusinessException("每人限购一件");
    }

    // 3. 使用乐观锁更新库存
    int updated = seckillProductMapper.deductStockWithVersion(
        productId, count, product.getVersion());

    if (updated == 0) {
        // 版本号不匹配,库存已被其他请求扣减
        return false;
    }

    // 4. 创建订单
    createOrder(productId, userId, count, requestId);

    return true;
}
  1. 性能优化措施

Redis 集群部署

  • 使用 Redis Cluster,分片存储不同商品的库存

  • 每个分片部署主从节点,保证高可用

本地缓存预热

  • 应用启动时从数据库加载库存到本地

  • 使用 Caffeine 缓存,设置合理的过期时间

  • 定时任务同步 Redis 库存到本地

异步写库

  • Redis 扣减成功后,立即返回成功

  • 异步发送 MQ 消息,持久化到数据库

  • MQ 消费失败时重试,超过阈值转入死信队列

限流保护

  • 网关层限流:令牌桶算法,限制总 QPS

  • 用户维度限流:每个用户每秒最多请求 1 次

  • 商品维度限流:每个商品每秒最多扣减 N 次

  • 一致性保障

对账机制

  • 每分钟对比 Redis 库存和数据库库存

  • 发现不一致时,以数据库为准,修正 Redis 库存

  • 记录对账日志,便于排查问题

补偿机制

  • 定时任务扫描"Redis 扣减成功但数据库未持久化"的记录

  • 重新发送 MQ 消息,确保持久化

  • 超过 10 分钟仍未持久化,人工介入处理

监控告警

  • 实时监控 Redis 的 QPS、响应时间、错误率

  • 监控库存扣减的成功率

  • 监控降级到数据库的比例

  • 设置合理的告警阈值

  • 容灾设计

Redis 故障

  • 自动降级到数据库

  • 限制数据库的 QPS(如 1000 QPS)

  • 前端提示"系统繁忙,请稍后重试"

数据库故障

  • 降级到只读模式

  • 仅允许查询,不允许下单

  • 快速修复数据库

网络分区

  • 使用 Redis Cluster 的自动故障转移

  • 保证至少一个分片可用

我在上一个项目中就是这么实现的,支撑了双 11 秒杀活动,峰值 QPS 达到 15 万,库存准确率 100%,没有出现超卖情况。整个方案的响应时间控制在 80ms 以内,降级到数据库的比例低于 0.1%。

Key Differences

维度 Common Answer Impressive Answer
技术深度 简单提到 Redis Lua 详细说明 Lua 脚本、本地缓存、降级方案的完整实现
实践经验 没有具体代码和架构 提供完整的架构设计、代码示例和性能优化措施
思考维度 仅考虑库存扣减 考虑限流、降级、对账、监控、容灾等完整方案
表达方式 简单描述 结构化阐述,配合架构图、代码和流程说明
面试官印象 理论掌握,但缺乏实战 有丰富的高并发系统设计经验