CAS 一篇讲清:原理、Java 用法,以及线上可用的订单状态机幂等方案


并发场景里最常见的一类问题是:多个线程/多次回调同时更新同一份数据。你既不想上大锁把吞吐打崩,又必须保证正确性(尤其是"只处理一次"的幂等逻辑)。CAS(Compare-And-Swap / Compare-And-Set)就是为此准备的底层能力。

这篇文章讲三件事:

  1. CAS 是什么、原理是什么、为什么能无锁更新
  2. Java 里 CAS 怎么用(Atomic 系列)以及何时需要自旋
  3. 一个能直接线上用的例子:订单支付回调幂等 + 状态机(NEW→PAYING→PAID) ,避免重复执行副作用且防止"半成功"

1)CAS 是什么?

CAS 是一个原子操作,语义是:

如果内存中的值 == 期望值 expected,就更新为新值 new;否则失败。

伪代码:

typescript 复制代码
boolean CAS(addr, expected, newValue) {
  if (*addr == expected) {
    *addr = newValue
    return true
  }
  return false
}

CAS 的原子性来自 CPU 指令(例如 x86 的 cmpxchg),JVM 把它封装成 Java API 给我们用。


2)CAS 的典型写法:自旋重试(什么时候需要)

CAS 只返回成功/失败,不会像锁那样阻塞等待。所以你会看到两类用法:

2.1 必须最终成功:需要自旋(比如计数器 +1)

ini 复制代码
while (true) {
    int old = count.get();
    int next = old + 1;
    if (count.compareAndSet(old, next)) break;
}

这段循环的意思是:
如果我更新失败,说明被别人抢先改了,那我重新读最新值再算再试,直到成功为止

这种场景不自旋就会"丢更新"。

2.2 只允许执行一次:不需要自旋(幂等抢占执行权)

比如支付回调、发券、发货这种副作用逻辑,语义是:

只有一个线程能执行一次;其他线程发现已经处理过就直接返回。

这种场景就应该"一枪"CAS:

scss 复制代码
if (!cas(NEW, PAYING)) return; // 抢不到就直接走幂等
doSideEffectsOnce();

失败了不要自旋,因为失败意味着"别人已经在处理/处理过",你不应该再抢。


3)CAS 为什么快?有什么坑?

优点

  • 无阻塞:失败不挂线程,不进入 OS 锁
  • 冲突低时吞吐高、延迟低

缺点

  • 冲突高会导致自旋多,CPU 飙升(计数热点尤其明显)
  • 经典问题:ABA(A→B→A),无锁数据结构里要用版本号解决(AtomicStampedReference)

4)Java 里 CAS 用在哪?

你日常其实一直在用:

  • AtomicInteger/AtomicLong/AtomicReference:核心就是 compareAndSet
  • LongAdder:高并发计数器,减少热点 CAS 冲突
  • ConcurrentHashMap、AQS/ReentrantLock:关键路径都用 CAS

结论:CAS 是很多并发工具的地基


5)线上可用例子:订单支付回调幂等(NEW→PAYING→PAID)

很多人用 CAS 写幂等,会直接 NEW → PAID,这在"纯状态"上没问题,但线上常见坑是:

  • 你把状态改成 PAID 了
  • 但"发券/记账/发MQ"等副作用执行到一半异常
  • 结果变成:状态已终态,但副作用不完整(最难排查)

更稳的工程写法是加一个中间态:PAYING(处理中/抢占态):

  1. NEW → PAYING:CAS 抢占执行权(只有一个线程能成功)
  2. 做副作用(确保只执行一次)
  3. 成功后 PAYING → PAID
  4. 失败则回滚为 NEW 或标记 PAY_FAILED(看业务)

这样能把"执行权抢占"和"副作用完成"解耦,线上更稳。


5.1 状态定义

swift 复制代码
public enum OrderStatus {
    NEW,        // 未支付
    PAYING,     // 支付处理中(抢占执行权)
    PAID,       // 已支付
    PAY_FAILED, // 支付处理失败(可选)
    CANCELED
}

5.2 订单对象:AtomicReference 保存状态

arduino 复制代码
import java.util.concurrent.atomic.AtomicReference;

public class Order {
    private final String orderId;
    private final AtomicReference<OrderStatus> status = new AtomicReference<>(OrderStatus.NEW);

    public Order(String orderId) {
        this.orderId = orderId;
    }

    public String getOrderId() {
        return orderId;
    }

    public OrderStatus getStatus() {
        return status.get();
    }

    public boolean casStatus(OrderStatus expected, OrderStatus update) {
        return status.compareAndSet(expected, update);
    }
}

5.3 支付回调处理器:抢占执行权 + 幂等 + 防半成功

scss 复制代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class PaymentCallbackHandler {
    private static final Logger log = LoggerFactory.getLogger(PaymentCallbackHandler.class);

    public void onPaid(Order order, String paymentNo) {
        // 1) CAS 抢占执行权:只有一个线程能把 NEW 改成 PAYING
        boolean won = order.casStatus(OrderStatus.NEW, OrderStatus.PAYING);
        if (!won) {
            // 幂等点:重复回调/并发处理都直接返回
            log.info("ignore duplicate/parallel callback. orderId={}, status={}, paymentNo={}",
                    order.getOrderId(), order.getStatus(), paymentNo);
            return;
        }

        // 2) 只有抢到执行权的线程能执行副作用
        try {
            log.info("won execution. start side effects. orderId={}, paymentNo={}",
                    order.getOrderId(), paymentNo);

            doSideEffectsOnce(order, paymentNo);

            // 3) 副作用完成后再推进终态
            order.casStatus(OrderStatus.PAYING, OrderStatus.PAID);
            log.info("paid success. orderId={}, finalStatus={}", order.getOrderId(), order.getStatus());

        } catch (Exception e) {
            // 4) 失败处理:可以回滚为 NEW 让后续重试,或标记失败态
            order.casStatus(OrderStatus.PAYING, OrderStatus.PAY_FAILED);
            log.warn("paid handling failed. orderId={}, finalStatus={}, err={}",
                    order.getOrderId(), order.getStatus(), e.toString());

            // 线上通常还会:记录失败原因、报警、人工补偿/重试
        }
    }

    private void doSideEffectsOnce(Order order, String paymentNo) {
        // 示例:记账、发券、发消息、写流水......
        // 这里要确保这些动作要么可幂等,要么保证只在"won"的线程执行
        // 例如:写一条支付流水(唯一键 orderId),重复写会失败 -> 幂等
        // 或者调用下游时带幂等号 paymentNo / requestId

        // 模拟业务
        if (paymentNo.endsWith("7")) {
            // 模拟偶发异常,验证"PAYING -> PAY_FAILED"不会出现 PAID 半成功
            throw new RuntimeException("simulate downstream failure");
        }
    }
}

这个方案解决了什么?

  • 只执行一次:NEW→PAYING 的 CAS 只有一个线程能成功,其它线程直接返回
  • 不自旋:抢不到就是幂等返回,不会烧 CPU
  • 防半成功:状态只有在副作用完成后才推进到 PAID
  • 失败可观测:PAY_FAILED 可以用于报警、补偿、重试策略

5.4 并发测试:证明只有一个线程能"抢到执行权"

ini 复制代码
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CasOrderDemo {
    public static void main(String[] args) throws Exception {
        Order order = new Order("O20251221");
        PaymentCallbackHandler handler = new PaymentCallbackHandler();

        int n = 20;
        ExecutorService pool = Executors.newFixedThreadPool(8);
        CountDownLatch latch = new CountDownLatch(n);

        for (int i = 0; i < n; i++) {
            String paymentNo = "P" + i;
            pool.submit(() -> {
                try {
                    handler.onPaid(order, paymentNo);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        pool.shutdown();

        System.out.println("final status: " + order.getStatus());
    }
}

你会观察到:

  • 只有一个线程打印 "won execution"
  • 其它都是 "ignore duplicate/parallel callback"
  • 如果你故意让 "副作用" 抛异常,最终状态会落在 PAY_FAILED 而不是 PAID

6)补一句真实线上:跨实例一致怎么办?

上面例子是 JVM 内存态,适合单实例内控制或中间态控制。真正订单最终要落库,跨实例一致常用数据库乐观锁(版本号):

ini 复制代码
UPDATE orders
SET status='PAYING', version=version+1
WHERE order_id=? AND status='NEW' AND version=?;

影响行数=1 才算抢到执行权,0 表示被别人抢先处理/状态已变,直接幂等返回。

本质上这就是存储层的 CAS。


7)总结

  • CAS 是"比较并交换"的原子操作,是无锁并发的基础
  • 自旋 CAS 适用于"必须最终成功"的场景(计数器、累加)
  • 幂等抢占执行权适用于"只允许执行一次"的副作用场景(支付回调、发券、发货)------不需要自旋
  • 工程上更稳的做法是引入中间态:NEW → PAYING → PAID,避免"状态终态但副作用半成功"
相关推荐
王中阳Go2 小时前
我辅导400+学员拿Go Offer后发现:突破年薪50W,常离不开这10个实战技巧
后端·面试·go
Tortoise2 小时前
OpenTortoise:开箱即用的Java调用LLM中间件,一站式解决配置、调用、成本监控和智能记忆
后端
摸鱼仙人~3 小时前
Flask-SocketIO 连接超时问题排查与解决(WSL / 虚拟机场景)
后端·python·flask
Lisonseekpan3 小时前
@Autowired 与 @Resource区别解析
java·开发语言·后端
chenyuhao20244 小时前
Linux系统编程:线程概念与控制
linux·服务器·开发语言·c++·后端
IT_陈寒4 小时前
Redis 性能优化实战:5个被低估的配置项让我节省了40%内存成本
前端·人工智能·后端
qq_12498707534 小时前
基于springboot的智能医院挂号系统(源码+论文+部署+安装)
java·人工智能·spring boot·后端·毕业设计
木木一直在哭泣4 小时前
ThreadLocal 讲清楚:它是什么、为什么会“内存泄漏”、线程池复用为什么会串号
后端
艺杯羹4 小时前
Thymeleaf模板引擎:让Spring Boot页面开发更简单高效
java·spring boot·后端·thymeleadf