数据库的守护者-单飞锁

单飞锁:从缓存击穿到分布式锁的演进之路

对于单飞锁,相信大家可能有点陌生。心想:这不就是防缓存击穿的一个小技巧嘛,有什么好说的。那么在这里我就提出以下几个问题,看你是否真的了解单飞锁了。

  • 为什么需要单飞锁?它能解决什么问题?
  • 单飞锁和普通的互斥锁有什么区别?为什么叫"单飞"?
  • 单飞锁在实现上有什么坑?如何避免?
  • 在分布式环境下,单飞锁还能用吗?应该用什么锁?
  • Redis 分布式锁、Zookeeper 分布式锁、数据库锁,各自适合什么场景?

相信你看到上述问题,肯定会说,这不就是八股吗?这背一下不就好了。那么我想说的是,这上述问题隐藏着架构和权衡的艺术,相信你看完这篇一定会受益匪浅。


一、为什么需要单飞锁?

1.1 先从一个线上故障说起

某个热点数据的缓存过期了,瞬间大量请求同时打到数据库,导致数据库 CPU 飙升到 100%,整个服务雪崩。

事后复盘时,我们发现这个场景有个专门的名词:缓存击穿

可以想象一个热门餐厅(数据库)门口排着长队(请求)。平时餐厅有个电子菜单(缓存)在外面,顾客看一眼就知道有没有位置,不用进去问。突然有一天电子菜单坏了(缓存过期),所有顾客一窝蜂冲进餐厅问服务员(查数据库),餐厅直接被挤爆。
数据库 缓存 请求3 请求2 请求1 数据库 缓存 请求3 请求2 请求1 par [并发查询数据库] 数据库压力瞬间飙升! 查询数据 失效 查询数据 失效 查询数据 失效 SELECT * FROM table SELECT * FROM table SELECT * FROM table

文字版说明:三个请求同时发现缓存失效,同时去查数据库,导致数据库压力瞬间飙升。实际场景中可能是成百上千个请求。

1.2 缓存击穿的几种解决方案

相信各位开发者都知道,缓存击穿有几种常见的解决方案:

方案1:设置热点数据永不过期

  • 优点:简单粗暴,不会击穿
  • 缺点:数据更新时需要主动刷新缓存,维护成本高;如果忘记刷新,数据就脏了

方案2:加互斥锁(Mutex Lock)

  • 优点:只让一个请求去查数据库,其他请求等待
  • 缺点:所有请求都阻塞等待,响应时间变长;如果查数据库的请求挂了,其他请求一直等

方案3:单飞锁(Singleflight)

  • 优点:只让一个请求去查数据库,其他请求共享结果(不是等待)
  • 缺点:实现稍微复杂一点;需要考虑超时和异常处理

那么问题来了:方案2和方案3有什么区别?为什么单飞锁更好?


二、单飞锁的核心思想

2.1 什么是"单飞"?

单飞锁的核心思想很简单:对于相同的请求,只让一个请求"飞"过去执行,其他请求等待并共享结果

可以想象一群人(请求)要去食堂打饭(查数据库)。如果每个人都排队打饭,食堂阿姨(数据库)会累死。单飞锁的做法是:选一个代表去打饭,其他人等着,代表打完饭回来后,把饭分给大家。
数据库 单飞锁 请求3 请求2 请求1 数据库 单飞锁 请求3 请求2 请求1 等待请求1的结果,共享返回 等待请求1的结果,共享返回 查询key="user:123" 只有请求1去查数据库 返回数据 查询key="user:123" 查询key="user:123" 返回数据 返回数据(共享) 返回数据(共享)

文字版说明:请求1去查数据库,请求2和请求3等待并共享请求1的结果。数据库只被查询一次。

2.2 单飞锁 vs 互斥锁

相信机智的你一下就能想到,这不就是互斥锁吗?有什么区别?

互斥锁

  • 请求1拿到锁,去查数据库
  • 请求2、请求3等待锁
  • 请求1查完释放锁,请求2拿到锁,再去查数据库
  • 请求2查完释放锁,请求3拿到锁,再去查数据库
  • 问题:虽然同一时间只有一个请求查数据库,但每个请求都要查一次,数据库压力还是很大

单飞锁

  • 请求1去查数据库
  • 请求2、请求3等待请求1的结果
  • 请求1查完,把结果返回给请求1、请求2、请求3
  • 优势:数据库只查一次,其他请求共享结果

用一个类比来理解:

  • 互斥锁:食堂阿姨一次只给一个人打饭,其他人排队,每个人都要打一次饭
  • 单飞锁:选一个代表去打饭,打一大份回来分给大家

2.3 Java 中的 Singleflight 实现

Java 标准库没有直接提供 Singleflight,但我们可以自己实现一个。相信各位开发者都听说过这个概念,但可能没自己写过。

java 复制代码
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class SingleFlightDemo {
    public static void main(String[] args) throws InterruptedException {
        SingleFlight<String> sf = new SingleFlight<>();
        CountDownLatch latch = new CountDownLatch(10);
        AtomicInteger queryCount = new AtomicInteger(0);

        // 模拟 10 个并发请求查询同一个用户
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    String result = sf.doExecute("user:123", () -> {
                        // 模拟查数据库
                        queryCount.incrementAndGet();
                        System.out.println("查询数据库: user_id=123");
                        Thread.sleep(100); // 模拟耗时
                        return "user_123_data";
                    });
                    System.out.println("获取到数据: " + result);
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    latch.countDown();
                }
            }).start();
        }

        latch.await();
        System.out.println("数据库查询次数: " + queryCount.get());
    }
}

输出

复制代码
查询数据库: user_id=123
获取到数据: user_123_data
获取到数据: user_123_data
获取到数据: user_123_data
...(10次)
数据库查询次数: 1

可以看到,虽然 10 个并发请求查询同一个用户,但数据库只被查询了一次


三、单飞锁的实现原理

3.1 核心数据结构

单飞锁的核心数据结构很简单:

java 复制代码
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;

public class SingleFlight<T> {
    private final ConcurrentHashMap<String, Call<T>> calls = new ConcurrentHashMap<>();

    // 内部类:表示一次调用
    private static class Call<T> {
        private final CountDownLatch latch = new CountDownLatch(1);
        private T value;
        private Exception exception;
        private int duplicateCount = 0;
    }
}

说明

  • ConcurrentHashMap 存储 key 到 Call 的映射,线程安全
  • CountDownLatch 用于让等待的线程阻塞,直到结果准备好
  • Call 内部类存储结果、异常和重复请求数量

3.2 Do 方法的执行流程


没有
调用 Do key, fn
m 中是否有 key?
获取已有的 call
创建新的 call
存入 m key -> call
执行 fn
等待 wg.Wait
设置 val 和 err
调用 wg.Done
从 m 中删除 key
返回 val, err

文字版说明

  1. 检查 map 中是否已经有相同 key 的 call
  2. 如果有,等待该 call 完成(共享结果)
  3. 如果没有,创建新的 call,执行 fn,设置结果,通知等待者

3.3 完整实现(简化版)

java 复制代码
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.function.Supplier;

public class SingleFlight<T> {
    private final ConcurrentHashMap<String, Call<T>> calls = new ConcurrentHashMap<>();

    private static class Call<T> {
        private final CountDownLatch latch = new CountDownLatch(1);
        private T value;
        private Exception exception;
    }

    /**
     * 执行函数,对于相同的 key,只执行一次
     * @param key 唯一标识
     * @param supplier 要执行的函数
     * @return 执行结果
     */
    public T doExecute(String key, Supplier<T> supplier) throws Exception {
        // 使用 computeIfAbsent 原子性地获取或创建 Call
        Call<T> call = calls.computeIfAbsent(key, k -> new Call<>());

        // 如果 call 已经有值(latch 已经 countDown),说明是重复请求
        // 这里简化处理,实际应该检查 call 是否已经在执行

        synchronized (call) {
            if (call.value != null || call.exception != null) {
                // 已经有结果了,直接返回
                if (call.exception != null) {
                    throw call.exception;
                }
                return call.value;
            }
        }

        try {
            // 执行函数
            call.value = supplier.get();
            return call.value;
        } catch (Exception e) {
            call.exception = e;
            throw e;
        } finally {
            // 通知等待者
            call.latch.countDown();
            // 清理
            calls.remove(key);
        }
    }

    /**
     * 等待并获取结果
     */
    public T waitAndGet(String key) throws Exception {
        Call<T> call = calls.get(key);
        if (call == null) {
            throw new IllegalStateException("No call for key: " + key);
        }
        call.latch.await(); // 等待结果
        if (call.exception != null) {
            throw call.exception;
        }
        return call.value;
    }
}

关键点

  • 使用 ConcurrentHashMap 保证线程安全
  • 使用 CountDownLatch 让等待的线程阻塞
  • 使用 synchronized 块保证对 Call 对象的原子操作
  • computeIfAbsent 原子性地获取或创建 Call 对象

四、单飞锁的坑与最佳实践

4.1 坑1:长时间阻塞

场景:如果 fn 执行时间很长,所有等待的请求都会阻塞。

问题

  • 假设 fn 执行 10 秒,100 个请求都在等
  • 这 100 个请求的响应时间都会变成 10 秒
  • 用户可能会超时、重试,导致更多请求堆积

解决方案

java 复制代码
import java.util.concurrent.*;
import java.util.function.Supplier;

public class SingleFlightWithTimeout<T> {
    private final ConcurrentHashMap<String, CompletableFuture<T>> calls = new ConcurrentHashMap<>();
    private final ExecutorService executor = Executors.newCachedThreadPool();

    /**
     * 带超时控制的执行
     */
    public T doExecuteWithTimeout(String key, Supplier<T> supplier, long timeout, TimeUnit unit) 
            throws Exception {
        // 获取或创建 Future
        CompletableFuture<T> future = calls.computeIfAbsent(key, k -> 
            CompletableFuture.supplyAsync(supplier::get, executor)
        );

        try {
            // 等待结果,带超时
            return future.get(timeout, unit);
        } catch (TimeoutException e) {
            // 超时后移除 key,让后续请求重试
            calls.remove(key);
            throw new RuntimeException("操作超时", e);
        } finally {
            // 完成后清理
            calls.remove(key);
        }
    }
}

说明

  • 使用 CompletableFuture 支持异步执行
  • 使用 future.get(timeout, unit) 支持超时控制
  • 超时后移除 key,让后续请求可以重试

4.2 坑2:错误传播

场景:如果 fn 返回错误,所有等待的请求都会收到相同的错误。

问题

  • 假设 fn 因为临时故障(网络抖动)失败
  • 所有等待的请求都失败,没有重试机会
  • 可能导致大面积服务不可用

解决方案

java 复制代码
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.function.Supplier;

public class SingleFlightWithErrorHandling<T> {
    private final ConcurrentHashMap<String, Call<T>> calls = new ConcurrentHashMap<>();

    private static class Call<T> {
        private final CountDownLatch latch = new CountDownLatch(1);
        private T value;
        private Exception exception;
    }

    public T doExecute(String key, Supplier<T> supplier) throws Exception {
        Call<T> call = calls.computeIfAbsent(key, k -> new Call<>());

        synchronized (call) {
            if (call.value != null || call.exception != null) {
                if (call.exception != null) throw call.exception;
                return call.value;
            }
        }

        try {
            call.value = supplier.get();
            return call.value;
        } catch (Exception e) {
            call.exception = e;
            // 失败时立即删除 key,让后续请求重试
            calls.remove(key);
            throw e;
        } finally {
            call.latch.countDown();
        }
    }
}

4.3 坑3:内存泄漏

场景:如果 key 的数量无限增长,map 会越来越大。

问题

  • 假设每个请求的 key 都不一样(比如用户ID)
  • map 会无限增长,导致内存泄漏

解决方案

  • 定期清理长时间未完成的 call
  • 使用 LRU 缓存限制 map 大小
  • 或者干脆不用单飞锁,改用其他方案

五、分布式环境下应该用什么锁?

5.1 单飞锁的局限性

相信机智的你已经发现了,单飞锁只能在单机环境下使用。如果部署了多个实例,每个实例都有自己的单飞锁,无法跨实例共享。

可以想象多个食堂(服务实例),每个食堂都有自己的代表去打饭。虽然每个食堂内部只有一个人去打饭,但所有食堂加起来,还是会有多个人去打饭。
数据库 实例C的单飞锁 实例B的单飞锁 实例A的单飞锁 请求3->>实例C 请求2->>实例B 请求1->>实例A 数据库 实例C的单飞锁 实例B的单飞锁 实例A的单飞锁 请求3->>实例C 请求2->>实例B 请求1->>实例A 数据库还是被查了3次! 查询key="user:123" 实例A去查数据库 查询key="user:123" 实例B也去查数据库 查询key="user:123" 实例C也去查数据库

文字版说明:每个实例都有自己的单飞锁,无法跨实例共享,数据库还是会被多次查询。

5.2 分布式锁的几种方案

在分布式环境下,需要使用分布式锁。常见的方案有:

方案 优点 缺点 适用场景
Redis 分布式锁 性能高、实现简单 需要处理锁过期、主从切换问题 高并发、对一致性要求不高
Zookeeper 分布式锁 强一致性、可靠性高 性能较低、实现复杂 对一致性要求高
数据库锁 简单、不需要额外组件 性能最低、对数据库压力大 并发量低、已有数据库

5.3 Redis 分布式锁的实现

Redis 分布式锁是最常用的方案,核心是 SET key value NX PX timeout 命令。

java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

public class RedisDistributedLock {
    private final RedisTemplate<String, String> redisTemplate;

    public RedisDistributedLock(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 尝试获取锁
     * @param key 锁的 key
     * @param value 锁的 value(用于标识锁的持有者)
     * @param timeout 过期时间
     * @param unit 时间单位
     * @return 是否获取成功
     */
    public boolean tryLock(String key, String value, long timeout, TimeUnit unit) {
        // NX: key 不存在时才设置
        // PX: 设置过期时间(毫秒)
        Boolean result = redisTemplate.opsForValue()
                .setIfAbsent(key, value, timeout, unit);
        return Boolean.TRUE.equals(result);
    }

    /**
     * 释放锁(使用 Lua 脚本保证原子性)
     * @param key 锁的 key
     * @param value 锁的 value
     * @return 是否释放成功
     */
    public boolean unlock(String key, String value) {
        // Lua 脚本:只有 value 匹配时才删除
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "return redis.call('del', KEYS[1]) " +
                        "else return 0 end";
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        Long result = redisTemplate.execute(redisScript, 
                Collections.singletonList(key), value);
        return result != null && result == 1L;
    }
}

使用示例

java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

public class UserService {
    private final RedisTemplate<String, String> redisTemplate;
    private final RedisDistributedLock lock;
    private final CacheService cache;
    private final DatabaseService db;

    public User getUserWithDistributedLock(String userId) throws Exception {
        String lockKey = "lock:user:" + userId;
        String lockValue = UUID.randomUUID().toString();

        // 尝试获取锁
        if (!lock.tryLock(lockKey, lockValue, 5, TimeUnit.SECONDS)) {
            throw new RuntimeException("获取锁失败");
        }

        try {
            // 查缓存
            User data = cache.get(userId);
            if (data != null) {
                return data;
            }

            // 查数据库
            data = db.query(userId);
            cache.set(userId, data, 3600);
            return data;
        } finally {
            // 释放锁
            lock.unlock(lockKey, lockValue);
        }
    }
}

5.4 Redis 分布式锁的坑

坑1:锁过期时间设置不当

  • 设置太短:业务还没执行完,锁就过期了,其他请求拿到锁,导致并发问题
  • 设置太长:如果服务挂了,锁要等很久才过期,影响可用性

解决方案

  • 使用"看门狗"机制,自动续期
  • 或者使用 Redisson 等成熟框架

坑2:主从切换导致锁丢失

  • 客户端A在主节点拿到锁
  • 主节点挂了,从节点升级为主节点
  • 客户端B在新的主节点也拿到锁
  • 两个客户端同时持有锁

解决方案

  • 使用 Redlock 算法(多节点投票)
  • 或者使用 Zookeeper

5.5 Zookeeper 分布式锁的实现

Zookeeper 通过临时顺序节点实现分布式锁,天然支持锁的有序性和可靠性。


创建临时顺序节点
获取所有子节点
自己是最小节点?
获得锁
监听前一个节点
前一个节点删除
执行业务
删除自己的节点

文字版说明

  1. 每个请求创建一个临时顺序节点
  2. 判断自己是否是最小节点
  3. 如果是,获得锁
  4. 如果不是,监听前一个节点
  5. 前一个节点删除时,自己成为最小节点,获得锁

优点

  • 强一致性(ZAB 协议)
  • 锁释放可靠(临时节点自动删除)
  • 支持"公平锁"(按顺序获取锁)

缺点

  • 性能较低(需要创建节点、监听节点)
  • 实现复杂
  • 依赖 Zookeeper 集群

六、如何选择合适的锁?

6.1 选型决策树

单机
分布式





需要加锁?
单机还是分布式?
需要共享结果?
对一致性要求?
单飞锁
互斥锁/读写锁
Zookeeper 分布式锁
Redis 分布式锁
数据库锁
性能最优
简单可靠
强一致性
性能与一致性平衡
简单但性能差

6.2 具体场景分析

场景1:防止缓存击穿(单机)

  • 选择:单飞锁
  • 理由:只让一个请求查数据库,其他请求共享结果,性能最优

场景2:防止缓存击穿(分布式)

  • 选择:Redis 分布式锁 + 本地缓存
  • 理由:分布式锁保证只有一个实例查数据库,本地缓存减少网络开销

场景3:库存扣减(高并发)

  • 选择:Redis 分布式锁 + Lua 脚本
  • 理由:性能要求高,用 Lua 脚本保证原子性

场景4:订单创建(强一致性)

  • 选择:Zookeeper 分布式锁
  • 理由:订单不能重复创建,需要强一致性

场景5:定时任务调度(防止重复执行)

  • 选择:数据库锁(乐观锁或悲观锁)
  • 理由:并发量低,不需要额外组件

七、面试常见问题

7.1 单飞锁相关

Q1:单飞锁和互斥锁有什么区别?

单飞锁是"共享结果",互斥锁是"排队执行"。单飞锁只让一个请求执行,其他请求等待并共享结果;互斥锁让每个请求都执行一次,只是排队执行。单飞锁适合读多写少的场景,互斥锁适合写场景。

Q2:单飞锁有什么坑?

主要有三个坑:

  1. 长时间阻塞:如果 fn 执行时间长,所有请求都会阻塞
  2. 错误传播:如果 fn 失败,所有请求都会失败
  3. 内存泄漏:如果 key 无限增长,map 会越来越大

Q3:单飞锁在分布式环境下还能用吗?

不能直接用,因为每个实例都有自己的单飞锁,无法跨实例共享。需要配合分布式锁使用,或者使用分布式单飞锁(如 groupcache)。

7.2 分布式锁相关

Q4:Redis 分布式锁如何实现?

核心是 SET key value NX PX timeout 命令,NX 保证只有 key 不存在时才设置,PX 设置过期时间。释放锁时用 Lua 脚本保证原子性。

Q5:Redis 分布式锁有什么问题?

主要问题:

  1. 锁过期时间设置不当
  2. 主从切换导致锁丢失
  3. 没有可重入性

解决方案:使用 Redisson 等成熟框架,或使用 Redlock 算法。

Q6:Redis 分布式锁和 Zookeeper 分布式锁有什么区别?

Redis 优点:性能高、实现简单

Redis 缺点:需要处理锁过期、主从切换问题

Zookeeper 优点:强一致性、可靠性高、支持公平锁

Zookeeper 缺点:性能较低、实现复杂、依赖 Zookeeper 集群


八、总结

相信你看完这篇,对单飞锁和分布式锁有了更深入的理解。最后总结一下核心观点:

1. 单飞锁的核心是"共享结果"

不是让请求排队执行,而是让请求共享结果。这样既减少了数据库压力,又不会让请求等太久。

2. 单飞锁有坑,需要谨慎使用

长时间阻塞、错误传播、内存泄漏,这三个坑都需要处理。使用 DoChan 方法可以解决超时问题。

3. 分布式环境下需要分布式锁

单飞锁只能在单机使用,分布式环境需要 Redis 分布式锁或 Zookeeper 分布式锁。

4. 选型要看场景

没有银弹,只有权衡。单机用单飞锁,分布式用 Redis 或 Zookeeper,具体看一致性和性能要求。

5. 不要过度设计

如果并发量不高,用数据库锁就够了;如果只是防止缓存击穿,单飞锁就够了。不要为了用分布式锁而用分布式锁。

相关推荐
神奇小汤圆3 小时前
每次重启能救下几十万个请求:Cloudflare 如何用 Rust 实现零停机升级
后端
用户298698530143 小时前
Java 统计 Word 文档中的单词数量
java·后端
fliter3 小时前
为什么我要杀掉 syn:Rust 编译速度之战与 unsynn 的诞生
后端
huzhongqiang4 小时前
扩展 Python 事件机制:支持等待事件消失
后端·python
_Evan_Yao4 小时前
大学自学能力怎么练?慕课、B站、书籍资源清单
后端·学习
SimonKing4 小时前
从惊艳到踩坑:AI结对编程的真实复盘
java·后端·程序员
咖啡八杯4 小时前
GoF设计模式——原型模式
java·后端·设计模式·原型模式
IT_陈寒4 小时前
Python多线程居然不加速?这个坑我踩得明明白白
前端·人工智能·后端
ZC跨境爬虫4 小时前
模块化烹饪小程序开发日记 Day3:(Flask后端初始化、数据库配置与自定义日志系统搭建)
前端·javascript·数据库·后端·python·flask