一、风控锁的困境:为什么高并发下锁成了瓶颈?从CAS到悲观锁,我们经历了什么?
做风控系统这些年,我踩过最大的坑,就是锁。
你想想看,一个典型的交易风控场景——用户下单,系统要查他的历史行为、当前设备指纹、关联账户、黑名单……这些数据分散在不同的缓存和数据库里。为了保证数据一致性,我们不得不上锁。但一上锁,性能就崩了。
为什么会这样?
说白了,锁的本质是「串行化」。你把并行操作强行排成队,CPU 利用率自然上不去。我见过最夸张的一次,线上风控接口的 TP99 从 5ms 飙到了 500ms,查了半天,就是一把 Redis 分布式锁在作祟。
1.1 从 CAS 开始:乐观锁的「轻」与「痛」
最早我们尝试用 CAS(Compare And Swap)来解决并发问题。CAS 是 CPU 级别的原子操作,不加锁,靠硬件保证「比较-交换」的原子性。
// Java 中的 CAS 示例
AtomicLong counter = new AtomicLong(0);
counter.incrementAndGet(); // 底层就是 CAS
CAS 的好处很明显——轻量、无阻塞。但我在项目中遇到过它的三个致命问题:
- ABA 问题:值从 A 变成 B 又变回 A,CAS 检测不到变化。虽然可以用版本号解决,但增加了复杂度。
- 自旋开销:高并发下 CAS 失败率极高,线程不断重试,CPU 空转。我记得有一次压测,CAS 自旋占了 40% 的 CPU 时间片。
- 只能保证单个变量:风控规则往往涉及多个数据源,CAS 搞不定跨资源的原子操作。
我的经验:CAS 适合「冲突概率极低」的场景,比如计数器、状态标记。但在风控这种高冲突场景下,CAS 的自旋会让你怀疑人生。
1.2 悲观锁:简单粗暴,但代价高昂
既然 CAS 搞不定,那就上悲观锁吧。数据库的 SELECT ... FOR UPDATE,或者 Java 的 synchronized,都是典型代表。
// 数据库悲观锁示例
BEGIN;
SELECT * FROM user_risk WHERE user_id = 123 FOR UPDATE;
-- 业务逻辑处理
UPDATE user_risk SET risk_score = 80 WHERE user_id = 123;
COMMIT;
悲观锁的思路很简单:我怀疑你会跟我抢,所以先锁住,谁也别动。嗯,这里要注意——这种「先锁再说」的策略,在低并发下没问题,但一旦流量上来,就是灾难。
我曾经接手过一个风控系统,每天凌晨的结算任务会把数据库锁死。查日志发现,一把 FOR UPDATE 锁住了整张表,所有请求都在排队等锁释放。那场面,就像早高峰的地铁——所有人都挤在门口,谁也进不去。
避坑指南:我曾经在线上用过 SELECT FOR UPDATE 做风控额度扣减,结果死锁频发。后来发现,不同线程获取锁的顺序不一致,导致循环等待。解决方案是强制按「用户ID哈希」排序加锁。
1.3 锁的瓶颈到底在哪?一张图看懂
为了让你更直观地理解,我画了一张流程图。它展示了从请求进入风控系统到最终返回,锁可能卡在哪些环节。
从这张图你能看到,一个请求从进入风控系统到返回,可能要经过三层锁。每一层都在消耗时间,而且层与层之间还会互相影响。我见过最极端的案例——分布式锁超时了,数据库锁还在持有,结果整个系统雪崩。
1.4 我们到底经历了什么?
回顾一下,从 CAS 到悲观锁,其实是一个「从乐观到悲观」的演变过程:
| 方案 | 核心思想 | 优点 | 缺点 | 风控场景适用性 |
|---|---|---|---|---|
| CAS | 乐观,无锁 | 轻量、无阻塞 | ABA、自旋、单变量 | 低冲突场景(如计数器) |
| 分布式锁 | 外部协调 | 跨进程、跨机器 | 网络IO、锁超时、脑裂 | 资源竞争场景(如额度扣减) |
| 数据库悲观锁 | 先锁后操作 | 强一致性、简单 | 死锁、性能差、锁升级 | 低频、强一致性场景 |
| JVM内部锁 | 线程互斥 | 轻量、无网络开销 | 单机、锁膨胀 | 本地资源竞争 |
我的建议:不要迷信任何一种锁方案。CAS 不是万能的,悲观锁也不是一无是处。关键是要根据你的业务场景——冲突概率、数据一致性要求、延迟容忍度——来选择最合适的锁策略。
嗯,说到这里,你可能会问:那到底该怎么优化?别急,后面的章节我会逐一拆解。但今天你得先记住一个核心观点——锁的瓶颈不在于锁本身,而在于你对并发的理解。
我个人习惯把风控系统的锁优化分为三个层次:
- 减少锁的持有时间:能快进快出,就别磨蹭。
- 降低锁的粒度:能锁一行,就别锁一张表。
- 避免不必要的锁:能用无锁数据结构,就别加锁。
这三个层次,我会在后续章节里用真实案例一一展开。今天这一章,就当是开胃菜吧。