CAS并发控制-从CPU指令到企业级乐观锁

CAS 并发控制全解:从一条 CPU 指令到企业级乐观锁,彻底搞懂无锁编程

一个 count++ 引发的超卖事故,最后追到 CPU 的一条指令上。

这篇文章把 CAS 从硬件讲到业务代码,配 Python / Java / SQL 可运行示例,看完能直接上手。

文章目录

一、痛点场景:那些「加了锁也没用」的线上事故

先看四个真实味道很重的事故。它们有个共同特征:代码看起来都做了保护,但都没护住。

场景一:库存超卖,两个人买走了最后一件

活动开始 3 秒,库存 1 件,卖出 2 件,仓库发不出货。

python 复制代码
# 错误示范:读和写之间有窗口
def deduct_stock(product_id):
    stock = query_stock(product_id)      # 读到 stock = 1
    if stock > 0:                        # 判断通过
        update_stock(product_id, stock - 1)   # 写回 0

两个请求同时进来,都读到 stock = 1,都判断通过,都写回 0。库存从 1 直接跳到 0,但订单生成了两笔。这在并发领域有个正式名字:丢失更新(Lost Update)。

如果两个请求一前一后把 stock 写成 0 和 -1,那就是更常见的负库存。

场景二:单机锁,在三个 Pod 面前形同虚设

python 复制代码
import threading

lock = threading.Lock()   # 进程内的锁

def deduct_stock(product_id):
    with lock:
        stock = query_stock(product_id)
        if stock > 0:
            update_stock(product_id, stock - 1)

单机压测怎么都复现不出来,一上生产就超卖。原因很简单:这把锁只活在当前进程的内存里。三个 Pod 就是三把锁,互相看不见。锁的边界必须与共享资源的边界一致,共享的是数据库里的行,锁却只覆盖了一个进程。

场景三:状态机重复流转,同一笔订单发了两次货

python 复制代码
def ship(order):
    if order.status == "PAID":
        order.status = "SHIPPED"
        call_logistics(order)

订单支付后进入 PAID,用户申请退款变成 REFUND,退款被驳回又回到 PAID。这时一个延迟的重试请求进来,看到状态还是 PAID,于是又发了一次货。

注意这里的关键:值从 PAID 变成 REFUND 又变回 PAID,判断条件依然成立。这不是锁没加对,是判断依据本身选错了 ------ 这就是后面要讲的 ABA 问题。

场景四:乐观锁用成重试风暴,把数据库打挂

python 复制代码
for _ in range(3):
    row = select_one("SELECT stock, version FROM product WHERE id = 1")
    affected = execute(
        "UPDATE product SET stock = %s, version = version + 1 "
        "WHERE id = 1 AND version = %s",
        (row["stock"] - 1, row["version"]),
    )
    if affected == 1:
        break

思路是对的,但秒杀场景下一万个人抢同一个 version,9999 次更新都是 affected = 0。每个请求重试 3 次,数据库瞬间承受 3 万次无效写入。乐观锁在冲突率高的场景会自我放大压力,这是新手最容易踩的坑。

图 1:三个事故的共同点 ------ 都在「先读后写」,读到的值在写回去之前已经被别人改过了

看清楚这一点,CAS 存在的理由就非常直白了:它要消灭的就是"读"和"写"之间那个窗口。

二、CAS 是什么

一句话定义

CAS(Compare-And-Swap,比较并交换)是一条原子指令:它检查某个内存位置的当前值是否等于你期望的值,相等才写入新值,整个过程不可被打断。

它需要三个操作数:

符号 含义 例子
V 要操作的内存地址(Value address) count 字段在对象中的偏移量
E 期望值(Expected) 你刚才读到的 stock = 1
N 新值(New) 你想写入的 stock = 0

语义可以用一段伪代码表示,但真实实现里这三步是一条指令,中间不允许任何线程插队:

text 复制代码
CAS(V, E, N):
    if V == E:          # 比较
        V = N           # 交换
        return true
    else:
        return false    # 不写,把当前最新值带回去

专业解释

CAS 属于读-改-写(Read-Modify-Write)原子操作,是构建无锁(lock-free)数据结构的基础原语。它在硬件层由 CPU 直接支持:

  • x86 / x86-64:CMPXCHG,配合 LOCK 前缀保证多核间的缓存一致性(走 MESI 协议让其他核心的缓存行失效)
  • ARM:LDREX / STREX(Load-Link / Store-Conditional),ARMv8.1 起也有直接的 CAS 指令
  • RISC-V:LR.W / SC.W

上层语言的封装都建立在它之上:Java 的 AtomicInteger.compareAndSet、C++ 的 std::atomic::compare_exchange_weak、Rust 的 AtomicUsize::compare_exchange。它们的语义完全一致,区别只在内存序(memory order)的可调参数。

大白话

我要改一个值,但我怕改错。所以我在改之前先问一句:

「现在这个值,还是我刚才看到的那个吗?」

是 ------ 那说明没人动过,我改。

不是 ------ 那说明有人抢先了,我重新看一眼,再问一次。

就这么简单。CAS 的本质是给自己的写入加一个"基于哪个版本"的前提条件。

生活案例:停车场里的最后一个车位

你开车进地库,看到 B1-08 空着(这是你读到的快照)。但你倒车入库需要十秒,这十秒里可能有别人先停进去了。

有两种做法:

  • 悲观的做法(加锁) :进场时就去管理处把 B1-08 锁下来,挂个牌子"已被预订",别人看到牌子直接走开。安全,但你要跑一趟管理处,别人也被你的牌子挡了一下 ------ 这就是 synchronized。
  • 乐观的做法(CAS):直接倒车,倒进去之前扭头确认一眼"还是空的吗"。是就停(成功),不是就绕一圈重新找(失败重试)。全程不用跑管理处,代价是可能白绕一圈 ------ 这就是 CAS。

现实中的共享单车扫码开锁、电影院选座的"确认订单",都是这个思路:先按看到的状态提交,提交时由系统校验状态是否还成立。

图 2:CAS 的三个操作数与两条分支 ------ 失败不是异常,是"别人比你快"的正常返回

一个容易被忽略的细节:失败时要带回最新值

真正的 CAS 指令在失败时,会把内存里的当前值 写回给你的寄存器(x86 的 cmpxchg 在比较失败时会把目标值装入 rax)。这不是设计冗余,而是为了让你不用再读一次内存就能重算。

AtomicInteger.incrementAndGet 的自旋循环正是靠这个特性写得又短又快。

三、为什么用 CAS:它和锁的本质区别

悲观与乐观,是两种世界观

维度 互斥锁(悲观) CAS(乐观)
假设 冲突一定会发生 冲突很少发生
做法 先独占,再操作 先操作,提交时校验
冲突时 排队等待,线程被挂起 立即失败,自己重试
状态 内核态 / 用户态切换 全程用户态
死锁 可能(需要顺序、超时兜底) 不存在(不持有资源)

三个实打实的好处

1. 没有上下文切换。

synchronized 竞争失败会把线程挂起,交给操作系统调度,一次唤醒的代价在微秒级。CAS 失败只是循环再来一次,全程在用户态。对于"临界区只有一行代码"的场景(比如计数器 +1),这个差距是数量级的。

2. 不会死锁,也不会有优先级反转。

CAS 不持有任何资源,自然不存在"持有 A 等 B"的死锁链条。高优先级线程也不会被一个恰好持锁的低优先级线程卡住。

3. 天然适配"状态只能前进一次"这类业务。

订单从 PAID 到 SHIPPED 只能发生一次,这个语义用锁表达很别扭(要判断、要加锁、要防重入),用 CAS 表达就一句话:

sql 复制代码
UPDATE orders SET status = 'SHIPPED' WHERE id = 1001 AND status = 'PAID';

影响行数 1 就是"我抢到了这次流转",0 就是"别人已经改过了"。判断和修改合并成了一个原子动作,业务语义和并发控制完美重合。

三个必须知道的代价

  1. ABA 问题:值改回来了,CAS 察觉不到中间的变化。这是最隐蔽的坑,第五节专门讲。
  2. 自旋开销:冲突激烈时线程反复空转,CPU 占用飙升,吞吐反而下降。
  3. 只能保护一个变量:两个字段要一起改,单条 CAS 无能为力。

记住一句话:CAS 不是"更好的锁",它是"适用范围更窄但更快"的工具。把它的适用边界搞清楚,比记住它有多快重要得多。

四、它是怎么演进过来的

并发控制这门技术,六十年只做了一件事:把"等待"的范围越缩越小。

图 3:六个阶段,每一代都不是推翻上一代,而是把上一代缩到更小的范围里

第一代:互斥锁(1970s)

多核处理器出现后,最先想到的办法是"排队"。Dijkstra 在 1965 年提出信号量,此后 mutex、monitor、synchronized 一路发展。思路简单粗暴:把临界区围起来,一次只放一个人。

问题也很明显:它保护的是"代码块",而不是"数据"。锁的粒度靠人拍脑袋决定,粒度大了性能差,粒度小了护不住。

第二代:硬件原子指令(1990s)

CPU 厂商意识到,如果"比较并交换"能做成一条指令,软件层的大量锁就可以省掉。于是有了 CMPXCHG(x86)、LL/SC(ARM / POWER)。

这一代的关键突破是:把"读-比较-写"三步压缩成硬件层面不可分割的一步,从根上消灭了窗口。ABA 问题也是在这个阶段被发现的 ------ 它不是你代码写得不好,而是 CAS 语义本身的天花板。

第三代:语言封装(2004 前后)

cmpxchg 是汇编,普通业务开发用不了。Java 5 引入 java.util.concurrent.atomic 包,把 CAS 自旋循环封在 AtomicInteger 里;C++11 引入 std::atomic;Java 9 之后有了更安全的 VarHandle。

这一代的意义是把并发原语从"系统程序员专属"变成"业务程序员可用" ,count.incrementAndGet() 从此可以放心替代 synchronized。

第四代:补缺陷(2008 之后)

人们发现两个新问题:ABA 和高竞争下的自旋浪费。于是有了 AtomicStampedReference(值 + 版本戳一起 CAS)、LongAdder(把热点拆成多个 Cell 分段累加,最后求和)、以及各种 backoff 策略。

这一代的主题是承认 CAS 有短板,然后在它上面打补丁。

第五代:语义外溢到存储层(2010s)

内存里的 CAS 思路被搬到了数据库和缓存:version 字段乐观锁、Redis 的 WATCH/MULTI 与 Lua 脚本、ZooKeeper 的版本号比较(setData 带 version 参数)。同一个"比较版本再写入"的思想,换了个战场。

第六代:在架构层消灭竞争(2020s)

最彻底的做法不是让冲突变得更快,而是让冲突不发生:按用户 ID 分片、单线程消费队列(Disruptor)、Actor 模型、把扣减改成"预占 + 异步落库"。

这也是我在企业项目里最推荐的思路:能靠架构拆掉的竞争,就不要靠锁去扛。

五、CAS 的三个缺陷,以及怎么补

缺陷一:ABA ------ 值回去了,世界没有回去

线程 T1 读到 V = A,刚准备 CAS,被调度走了。T2 把 A 改成 B,又改回 A。T1 醒来发现 V 还是 A,兴高采烈地 CAS 成功 ------ 但它基于的那个"世界"已经不是原来的世界了。

图 4:ABA 的完整时序 ------ T1 以为自己改的是那个 A,实际上是另一个 A

为什么计数器场景不怕 ABA? 因为 1 → 2 → 1 之后重新加一,结果依然是 2,值相等就是真的相等。但指针和状态机怕:地址相同不代表节点相同,状态字符串相同不代表中间没发生过退款。

三种解法:

解法 原理 典型实现
版本戳(最常用) 值 + 单调递增计数一起 CAS,计数不回退 AtomicStampedReference、数据库 version 字段
加宽原子字 用 CMPXCHG16B 把指针和计数打包成 128 位 无锁栈顶指针、带标记的引用
延迟回收 保证被读的节点不会被释放复用(hazard pointer / epoch) 无锁队列、内存回收器

一句话总结:能让版本号单调递增,就不要只比较业务值。

缺陷二:自旋开销 ------ 空转也是要烧 CPU 的

CAS 失败就重试,冲突越激烈,重试越多。极端情况下几百个线程围着一个变量空转,CPU 100%,吞吐还不如加锁。

图 5:CAS 的完整循环 ------ 循环体只该有「读 - 算 - CAS」,多一行 IO,冲突概率就翻倍

四个工程解法:

  1. 缩短循环体:循环里只放纯计算,绝不放 IO、日志、RPC。
  2. 限制自旋次数 + 退避 :失败 N 次后 sleep 随机毫秒,或直接降级为锁。
  3. 分段(striping) :LongAdder 的思路,把热点拆成多个 Cell,各自 CAS,读取时求和。
  4. 换数据结构:高竞争场景直接用队列串行化,让竞争变成排队。

缺陷三:只能保护一个变量

stock 和 version 要一起改,单条 CAS 做不到。三种绕过方式:

  • 打包 :把多个字段封成不可变对象,用 AtomicReference 换整个引用。
  • 下沉:交给数据库的一条 UPDATE 语句(数据库天然支持多字段原子更新)。
  • 加锁:承认这里不适合无锁,老老实实加锁反而更清晰。

六、怎么用:四种落地的完整代码

先明确一个前提,这决定了 Python 程序员该怎么用 CAS。

6.1 Python 的现实:没有原生 CAS

CPython 有 GIL(全局解释器锁),同一时刻只有一个线程在执行字节码。这带来两个后果:

  1. 大部分"看起来的并发"其实是串行的,很多场景不加锁也碰巧不出错 ------ 这是最危险的错觉。
  2. 语言层没有暴露 CAS 原语 。ctypes.atomic 属于实验性提案,主流项目里不能依赖。

关键认知:GIL 保证的是"单条字节码"原子,不是"一段逻辑"原子 。count += 1 会被拆成 LOAD / ADD / STORE 三条字节码,线程完全可能在中间被切走。

python 复制代码
import threading
import time

count = 0

def add():
    global count
    for _ in range(1000):
        tmp = count          # 读
        time.sleep(0)        # 一次主动让出:真实业务里这里是一行日志、一次查库
        count = tmp + 1      # 写

ts = [threading.Thread(target=add) for _ in range(8)]
for t in ts:
    t.start()
for t in ts:
    t.join()

print(count)    # 期望 8000,实测往往只有 1400 左右

这段代码每次跑出来的数都不一样,但都远小于 8000。砍掉的那几千次自增,就是被"读和写之间"的窗口吃掉的。

这里有个特别值得说清楚的细节:如果你把 time.sleep(0) 换成 count += 1 的紧凑循环,结果往往是对的。 因为 CPython 的 GIL 切换点通常在循环回边,紧凑循环里几乎不会被打断。

这恰恰是 Python 并发最危险的地方 ------ 错的代码在测试里能跑通 。等你哪天在中间加了一行日志、一次 Redis 查询、一个 time.sleep,事故就出现了。所以判断标准不能是"我测了没问题",而是"这段逻辑在任意时刻被打断,结果还对不对"。

所以 Python 里用 CAS 思想,有三条现实路径:用锁模拟 CAS 语义 、用共享内存 + 锁做跨进程 、把 CAS 下沉到数据库或 Redis 。生产项目里,第三条是绝对主流。

6.2 路径一:用锁实现一个语义等价的 CAS 容器

这不是为了性能,是为了让"读-改-写"变成一个不可分割的动作,把自旋逻辑封在类内部,调用方就再也不用担心窗口问题。

python 复制代码
import threading


class AtomicValue:
    """CAS 容器的教学实现:语义与硬件 CAS 一致,性能不等价"""

    def __init__(self, value):
        self._value = value
        self._mutex = threading.Lock()

    def get(self):
        with self._mutex:
            return self._value

    def compare_and_set(self, expect, update):
        """等价于 CAS(V, E, N),返回是否写入成功"""
        with self._mutex:
            if self._value != expect:
                return False
            self._value = update
            return True

    def update(self, func):
        """自旋 CAS:把「读 - 算 - CAS」打包成一个原子动作"""
        while True:
            current = self.get()
            target = func(current)
            if self.compare_and_set(current, target):
                return target


if __name__ == "__main__":
    counter = AtomicValue(0)

    def work():
        for _ in range(50000):
            counter.update(lambda v: v + 1)

    threads = [threading.Thread(target=work) for _ in range(8)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    print(counter.get())    # 稳定输出 400000

这段代码有两个值得注意的设计:

  • update 里没有 break 之外的分支,失败就静默重来。调用方看到的接口是"原子自增",看不到重试。
  • func 必须是纯函数 。如果 func 里有副作用(写日志、发消息),重试就会重复执行 ------ 这是把 CAS 用到业务代码里最常见的 bug。

6.3 路径二:跨进程的自增,用共享内存

多进程场景下 threading.Lock 完全无效(每个进程一把)。Python 标准库提供了带锁的共享内存,这是最省事的做法:

python 复制代码
import multiprocessing as mp


def worker(counter, lock, times):
    for _ in range(times):
        with lock:                  # 跨进程的互斥量
            counter.value += 1


if __name__ == "__main__":
    counter = mp.Value("i", 0)      # 共享内存中的一个 int
    lock = mp.Lock()
    procs = [mp.Process(target=worker, args=(counter, lock, 20000)) for _ in range(4)]

    for p in procs:
        p.start()
    for p in procs:
        p.join()

    print(counter.value)            # 80000

要注意 mp.Value 默认 lock=True,它自带一把锁;上面显式再传一把锁,是为了让"读-改-写"整体受保护 ------ 只保护单个读取没用,窗口依然存在。

6.4 路径三(生产主流):把 CAS 下沉到数据库

这是我在所有企业项目里的默认选择:应用层不做并发控制,让数据库的一条语句承担原子性。

先建表,version 就是那个"不会回退的戳记":

sql 复制代码
CREATE TABLE product (
    id      BIGINT PRIMARY KEY,
    name    VARCHAR(64) NOT NULL,
    stock   INT NOT NULL,
    version INT NOT NULL DEFAULT 0
);

第一步:复现一次真实的版本冲突。

python 复制代码
import sqlite3

conn = sqlite3.connect("cas_demo.db")
conn.executescript("""
DROP TABLE IF EXISTS product;
CREATE TABLE product (id INTEGER PRIMARY KEY, stock INTEGER, version INTEGER);
INSERT INTO product VALUES (1, 10, 3);
""")
conn.commit()

# 两个请求几乎同时读到同一份快照
row_a = conn.execute("SELECT stock, version FROM product WHERE id = 1").fetchone()
row_b = conn.execute("SELECT stock, version FROM product WHERE id = 1").fetchone()
print("A 读到", row_a, " B 读到", row_b)      # 都是 (10, 3)

# B 先提交:版本号 3 -> 4
cur = conn.execute(
    "UPDATE product SET stock = ?, version = version + 1 WHERE id = 1 AND version = ?",
    (row_b[0] - 1, row_b[1]),
)
conn.commit()
print("B 影响行数", cur.rowcount)            # 1,提交成功

# A 拿着过期的 version=3 提交,必然失败
cur = conn.execute(
    "UPDATE product SET stock = ?, version = version + 1 WHERE id = 1 AND version = ?",
    (row_a[0] - 1, row_a[1]),
)
conn.commit()
print("A 影响行数", cur.rowcount)            # 0,说明快照已过期
print("最终数据", conn.execute("SELECT stock, version FROM product WHERE id = 1").fetchone())
# 最终 (9, 4):只扣了一次,超卖被挡住

WHERE version = 3 这一行就是 CAS 的 V == E。影响行数为 0 不是错误,是"你看到的世界过期了"的信号。

第二步:把重试封装好,别让业务代码满地 for 循环。

python 复制代码
import random
import sqlite3
import time
from contextlib import contextmanager


class VersionConflict(Exception):
    """版本冲突:快照过期,需要重新读取"""


@contextmanager
def transaction(conn):
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise


def deduct_stock(conn, product_id, qty, max_retry=3):
    """带退避的乐观锁扣减,返回是否成功"""
    for attempt in range(1, max_retry + 1):
        row = conn.execute(
            "SELECT stock, version FROM product WHERE id = ?", (product_id,)
        ).fetchone()
        if row is None:
            return False, "商品不存在"
        stock, version = row

        if stock < qty:
            return False, "库存不足"          # 业务失败,不重试

        with transaction(conn):
            cur = conn.execute(
                "UPDATE product SET stock = stock - ?, version = version + 1 "
                "WHERE id = ? AND version = ? AND stock >= ?",
                (qty, product_id, version, qty),
            )
        if cur.rowcount == 1:
            return True, f"第 {attempt} 次尝试成功"

        # 冲突:指数退避 + 随机抖动,避免所有线程同时重来
        time.sleep((2 ** attempt) * 0.001 * random.random())

    return False, f"重试 {max_retry} 次仍冲突,请稍后再试"

三个细节决定了这段代码在生产里能否活下来:

  • max_retry 必须有限,并且返回明确的失败语义。无限重试在高峰期会把数据库彻底压垮。
  • 退避要带随机抖动,否则所有失败线程会在同一时刻再次涌入,形成"重试风暴"的驻波。
  • 重试必须幂等。扣减类操作天然幂等(每次都基于最新库存减),但"创建订单"这类操作必须有幂等键,否则重试就是重复下单。

第三步:能用条件更新,就别用版本号。

很多时候业务规则本身就够用,不需要 version:

sql 复制代码
-- 原子扣减:库存不足时影响行数为 0,天然防超卖
UPDATE product SET stock = stock - 1 WHERE id = 1001 AND stock >= 1;

这条语句比"查出来判断再写回"可靠一百倍,而且并发性能更好 ------ 数据库只锁一次行,不需要额外的版本字段维护。判断条件能写进 WHERE,就说明它根本不需要在应用层加锁。

6.5 Java:AtomicInteger 源码里的自旋

看懂了 Python 的模拟版,再看 JDK 的原生实现,会发现结构一模一样。

java 复制代码
// java.util.concurrent.atomic.AtomicInteger (JDK 8)
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// sun.misc.Unsafe#getAndAddInt
public final int getAndAddInt(Object o, long offset, int delta) {
    int prev;
    do {
        prev = getIntVolatile(o, offset);   // 读,并且保证可见性
    } while (!compareAndSwapInt(o, offset, prev, prev + delta));  // 失败就重来
    return prev;
}

再往下就是一行汇编,LOCK 前缀让操作在多核之间原子:

text 复制代码
lock cmpxchg dword ptr [rdx], ecx

两个关键点:

  • value 必须声明为 volatile 。CAS 只保证原子性,不保证写完立刻对其他线程可见;volatile 补上可见性,两者配合才是完整的。
  • valueOffset 是字段在对象内存中的偏移量。Java 不能直接传指针,所以用"对象引用 + 偏移量"来定位内存地址。

Java 里修 ABA 的标准写法:

java 复制代码
import java.util.concurrent.atomic.AtomicStampedReference;

public class OrderState {
    // 值 + 版本戳,一起 CAS
    private final AtomicStampedReference<String> state =
            new AtomicStampedReference<>("PAID", 0);

    public boolean ship() {
        int[] stampHolder = new int[1];
        while (true) {
            String current = state.get(stampHolder);        // 值和戳一起读
            int stamp = stampHolder[0];
            if (!"PAID".equals(current)) {
                return false;                               // 状态不对,直接拒绝
            }
            // 值相等还不够,戳也必须相等
            if (state.compareAndSet(current, "SHIPPED", stamp, stamp + 1)) {
                return true;
            }
        }
    }
}

6.6 分布式:Redis 里的 CAS

单机 CAS 只能护住一个进程,跨服务就得靠 Redis。两种写法,优先用 Lua。

写法一:WATCH + MULTI(乐观事务)

python 复制代码
import redis

r = redis.Redis(decode_responses=True)

def cas_set(key, expect, update):
    with r.pipeline() as pipe:
        while True:
            try:
                pipe.watch(key)                 # 开始监视
                current = pipe.get(key)
                if current != expect:
                    pipe.unwatch()
                    return False
                pipe.multi()                    # 进入事务
                pipe.set(key, update)
                pipe.execute()                  # key 被改过则抛 WatchError
                return True
            except redis.WatchError:
                continue                        # 重来

WATCH 的语义和 CAS 完全一致:监视的 key 在 EXEC 之前被改过,事务就整体失败。

写法二:Lua 脚本(推荐)

Redis 执行 Lua 是单线程原子的,脚本内部不会有任何命令插进来,天然就是 CAS。

lua 复制代码
-- cas_set.lua
local key    = KEYS[1]
local expect = ARGV[1]
local update = ARGV[2]

local current = redis.call('GET', key)
if current == expect or (current == false and expect == '__NIL__') then
    redis.call('SET', key, update)
    return 1
end
return 0
python 复制代码
import redis

r = redis.Redis(decode_responses=True)
with open("cas_set.lua", encoding="utf-8") as f:
    cas_script = r.register_script(f.read())

# 抢占一个分布式任务:只有把 PENDING 改成 RUNNING 的那个实例才执行
acquired = cas_script(keys=["job:9527:status"], args=["PENDING", "RUNNING"])
if acquired == 1:
    run_job(9527)
else:
    print("已被其他实例抢走,本次跳过")

这个"状态抢占"模式是企业项目里 CAS 最高频的用法:定时任务多副本部署时,靠它保证同一时刻只有一个实例在跑。

七、企业项目里怎么用:三层接力

真实项目里 CAS 从来不是单点技术,而是三层配合。上层的目标是"挡住绝大部分竞争",下层的目标是"兜住所有漏网的并发"。

图 6:应用层挡同进程、缓存层挡流量洪峰、存储层兜最终正确,兜底层负责收口

第一层:应用层(进程内)

  • 计数器 / 限流令牌 / 序号发号 :AtomicLong、AtomicInteger。别用 synchronized 包一个 ++,那是杀鸡用牛刀。
  • 配置热更新:用不可变对象 + 引用替换。读侧永远拿到一个完整快照,不需要加锁,也不会读到"改了一半"的配置。
  • 状态位 :带版本戳的 AtomicStampedReference,或者干脆把状态迁移收敛到数据库。

注意边界:这一层只在单进程内有效。多副本部署时它挡不住任何跨进程的竞争,别拿它当分布式锁用。

第二层:缓存层(跨进程)

  • 热点扣减前置:秒杀场景先在 Redis 用 Lua 脚本原子扣减,扣成功才放行到数据库,把 99% 的无效请求挡在 DB 之外。
  • 分布式任务抢占 :SETNX 或上面的 Lua CAS,保证同一时刻只有一个实例执行。
  • 幂等控制 :用业务唯一键做 SET NX,重复请求直接短路。

注意:Redis 的原子扣减和数据库不是同一个事务,必须有"预扣成功但落库失败"的补偿逻辑(回滚预扣 + 对账)。

第三层:存储层(最终真相)

  • UPDATE ... WHERE version = ?:低冲突场景的标准解法。
  • UPDATE ... WHERE stock > 0:条件更新,比版本号更简单,优先考虑。
  • 唯一索引 / 状态机约束:最后的物理防线。重复插入直接被数据库拒绝,比任何应用层判断都可靠。

兜底层:对账 + 补偿 + 幂等键

任何一层都可能因为网络超时、进程被杀而"看起来失败、其实成功"。最终一致性靠这一层收口,不靠重试次数硬扛。 我见过太多项目把 max_retry 从 3 调到 10 来"解决"并发问题,结果只是把故障从"偶发超卖"变成了"数据库雪崩"。

八、竞品对比:六种并发控制手段怎么选

维度 CAS 原子类 互斥锁 synchronized / Lock 数据库悲观锁 FOR UPDATE 数据库乐观锁 version 分布式锁 Redis / ZK 串行化(队列 / 单线程)
阻塞行为 不阻塞,失败重试 阻塞,线程挂起 阻塞,等行锁 不阻塞,失败返回 阻塞或快速失败 排队,但调用方通常异步
跨进程 否 否 是 是 是 是
冲突高时 差(空转、CPU 飙高) 一般(上下文切换多) 较好(排队有序) 差(重试风暴) 一般(依赖 Redis 性能) 好(天然削峰)
死锁风险 无 有 有(需固定加锁顺序) 无 有(需过期 + 续约) 无
性能 极高(纳秒级) 中(微秒级) 低(毫秒级,含 IO) 中(一次 UPDATE) 中低(一次网络往返) 中(取决于消费速度)
一致性强度 单变量强一致 单机强一致 强一致 强一致(提交成功即生效) 依赖实现,可能脑裂 最终一致
实现复杂度 低(用现成类) 低 低 中(要写重试) 高(要考虑续约、误删) 高(要建链路、处理堆积)
典型场景 计数、状态位、限流 单机临界区 转账、强互斥 库存、资料编辑、状态流转 定时任务抢占、跨服务互斥 秒杀、订单创建
最大的坑 ABA、自旋 锁粒度、死锁 长事务拖垮连接池 高冲突重试风暴 锁过期业务没跑完 消息堆积、延迟

图 7:选型只看两个变量 ------ 冲突有多密,失败有多贵

一句口诀记牢:冲突少就赌一把,冲突多先拆热点,代价高就别赌。

九、常用场景:这八个地方最适合 CAS

1. 计数器与统计

QPS 统计、点赞数、播放量。低冲突用 AtomicLong,高冲突换 LongAdder(分段累加,读取时求和)。

java 复制代码
LongAdder qps = new LongAdder();
qps.increment();          // 高并发下比 AtomicLong 快数倍
long total = qps.sum();

2. 状态机的一次性流转

订单、工单、审批流。核心写法就一句 UPDATE ... WHERE id = ? AND status = ?,用影响行数判断"这次流转归不归我"。

3. 库存扣减与优惠券核销

优先用条件更新(WHERE stock >= 1),需要记录版本轨迹时才加 version 字段。

4. 分布式定时任务抢占

多副本部署时,用 Redis Lua 把 PENDING 改成 RUNNING,只有一个实例能改成功。

5. 一次性初始化

懒加载单例、缓存预热标记、配置加载开关。用 AtomicBoolean.compareAndSet(false, true) 保证只执行一次。

java 复制代码
private final AtomicBoolean initialized = new AtomicBoolean(false);

public void init() {
    if (initialized.compareAndSet(false, true)) {
        loadConfig();       // 只有一个线程能进来
    }
}

6. 配置热更新

把配置封成不可变对象,改的时候构造新对象、整体替换引用。读侧完全无锁,也不会读到半成品。

7. 限流与令牌桶

令牌数的增减用 CAS,避免为了一次减法去抢一把锁。

8. 无锁数据结构

无锁队列、无锁栈、RingBuffer。这类场景建议直接用成熟库(Disruptor、JCTools),自己写无锁结构在生产环境翻车概率极高 ------ 内存序、伪共享、安全回收,每一个都能让你排查一周。

十、面试高频题(附答题要点)

Q1:CAS 是什么?三个操作数分别是什么?原子性由谁保证?

比较并交换。V 是内存地址,E 是期望值,N 是新值。相等则写入 N 返回成功,否则不写返回失败。原子性由 CPU 指令 保证(x86 的 lock cmpxchg、ARM 的 LDREX/STREX),不是软件层的锁。

Q2:CAS 为什么比 synchronized 快?什么时候反而更慢?

快在不阻塞 :没有线程挂起/唤醒,没有内核态切换,失败只是循环重试。慢在高冲突 :大量线程空转,CPU 占用飙升,缓存行来回同步(伪共享),吞吐反而不如锁。判断标准是临界区长度 和冲突概率:临界区越短、冲突越少,CAS 越划算。

Q3:ABA 问题是什么?怎么解决?

值从 A 变成 B 又变回 A,CAS 检查值相等就成功,但中间状态已经变了。计数器场景无害(相等就是相等),指针和状态机场景有害 (地址相同不代表节点相同)。解法是加版本戳:AtomicStampedReference、数据库 version 字段、CMPXCHG16B 打包 128 位。

Q4:CAS 只能操作一个变量,要同时改两个字段怎么办?

三种:把字段封成不可变对象用 AtomicReference 换整个引用;下沉到数据库用一条 UPDATE;直接加锁。别硬凹无锁,复杂度会失控。

Q5:Java 里 CAS 的底层实现是什么?为什么要加 lock 前缀?

Unsafe.compareAndSwapInt 是 native 方法,最终编译成 lock cmpxchg。lock 前缀做两件事:保证指令的原子性(多核之间通过缓存一致性协议独占缓存行),并提供内存屏障语义(禁止指令重排、保证写入对其他核心可见)。

Q6:CAS 能保证可见性吗?为什么 value 还要 volatile?

CAS 保证的是原子性 和有序性 ,不保证"写完立刻被其他线程看到"。volatile 负责可见性和禁止重排。AtomicInteger 内部的 value 就是 volatile 的,两者配合才完整。

Q7:LongAdder 为什么比 AtomicLong 快?

AtomicLong 所有线程抢同一个变量,冲突高时大量 CAS 失败。LongAdder 继承 Striped64,内部维护一个 Cell 数组,不同线程按 probe 值落到不同 Cell 上各自 CAS,把热点分散开;读取时把 base 和各 Cell 求和。代价是 sum() 不是强一致的快照。

Q8:数据库乐观锁怎么实现?影响行数为 0 怎么办?

表上加 version 字段,更新时 WHERE version = 旧值,同时 version = version + 1。影响行数 1 表示成功,0 表示快照过期。处理方式:有限次重试 + 指数退避 + 随机抖动,超过次数返回明确的业务失败。绝不能无限重试。

Q9:为什么说"条件更新比乐观锁更好"?

如果业务规则能写进 WHERE(比如 stock >= 1),就不需要维护 version 字段,也不需要重试循环 ------ 数据库一次原子 UPDATE 就完成了判断和修改。乐观锁是"不得不比较多个字段"时的退路。

Q10:分布式场景下 CAS 怎么落地?Redis Lua 为什么是原子的?

用 Redis 的 Lua 脚本:脚本里先 GET 比较再 SET。Redis 单线程执行命令,脚本执行期间不会插入其他命令,所以整个"比较 + 写入"是原子的。WATCH/MULTI 也能实现,但在高并发下 WatchError 重试率更高,Lua 更稳。

Q11:CAS 和自旋锁是什么关系?

自旋锁是用 CAS 实现的一把锁 :while (!state.compareAndSet(0, 1)) {}。区别在于 CAS 是原语(一次操作),自旋锁是用原语构建的同步结构(保护一段临界区)。自旋锁适合临界区极短的场景,否则空转浪费 CPU。

Q12:什么场景你会主动放弃 CAS,改用锁?

四种:临界区里有 IO 或 RPC(自旋会等到天荒地老);需要同时改多个变量且无法打包;冲突率极高(重试成本超过阻塞成本);逻辑复杂到需要人读懂(可维护性也是性能)。

十一、收尾:一张能贴到工位上的决策清单

看到并发问题,按这个顺序问自己四句话:

  1. 能不能不改共享状态? ------ 用不可变对象、用消息传递、按 key 分片,把竞争从源头拆掉。这是最优解。
  2. 能不能用一条 SQL 解决? ------ 条件更新、唯一索引、WHERE version = ?。数据库的原子性最可靠,也最好维护。
  3. 是不是只在单进程内? ------ 是就用 AtomicXxx;有 ABA 风险就上 AtomicStampedReference;冲突高就换 LongAdder。
  4. 是不是跨服务? ------ 用 Redis Lua 做抢占或预扣,但一定要配补偿和对账。

最后送一句我在生产环境里反复验证过的话:

乐观锁不是"不加锁",而是"把冲突检测推迟到提交那一刻"。

推迟得好,你省下了等待;推迟得不好,你只是把等待换成了重试风暴。

CAS 的全部威力,都藏在这句话里。


本文为原创文章,如需转载,请联系作者获得授权,并注明出处。

相关推荐
qq_426003961 小时前
UI自动化高频次登录login限流解决方案
python·ui·自动化
情 九1 小时前
MCP 实战 06:把 HTTP 接口接成工具,让 AI 帮你查数据
人工智能·网络协议·http·mcp
Sakura_j_h_1 小时前
EasyEDA PCB 快速设计Skills
c++·python·github
码农小旋风2 小时前
Ray分布式计算引擎介绍
人工智能·架构
火山引擎和TA的超级拍档们2 小时前
产品情报局 08 | AI 内容助手:全网热点驱动,一站式打通内容生产全链路
人工智能
Binovate缤江数智2 小时前
水泵远程开关之后,还需要管什么?数智开关在无人值守泵站中的监测、保护与能耗分析
人工智能·物联网
素男2 小时前
两个月不是六十天
人工智能·agent·self-becoming·ai长期记忆·ai自我介绍
byte轻骑兵2 小时前
【LMP】规范精讲[10]: 语音专线开线记——SCO 固定占座与 eSCO 限时重传
人工智能·人机交互·lmp·蓝牙链路管理·蓝牙外设