高并发利器:SingleFlight优化指南(Java版实现与项目实战)

缓存击穿组件:SingleFlight

这里简单介绍一下 SingleFlight,由于这个在项目中使用到了,我简单介绍一下背景和优化思路,大家可以使用 GPT 扩展实现。

引言

SingleFlight的设计目的就是避免在高并发场景下,多个线程同时执行相同的长耗时操作。通过确保只有一个线程执行"重复"的任务(比如从缓存中读取数据或进行复杂计算),其余的线程都会等待结果,避免了重复的工作和资源浪费。 当并发量很大时,使用SingleFlight的效果就非常明显,它能显著减少不必要的重复计算。如果执行的操作非常耗时或资源消耗很大,那么SingleFlight的机制就特别有用,因为它能帮助集中处理计算任务,避免每个请求都进行一次重复的计算。 因此SingleFlight并不适用于所有场景。在并发量较低或计算操作非常快速的情况下,使用SingleFlight的开销可能反而不值得,反而增加了额外的复杂性。 因此在考虑何时使用SingleFlight时,主要可以从以下几个方面来评估:

  • 并发量:如果你的应用会遇到大量并发请求,那么使用SingleFlight可以有效减少冗余的工作。
  • 操作复杂度:如果操作本身非常耗时,比如从数据库或缓存读取数据、进行长时间计算等,那么SingleFlight的优势就非常明显。
  • 业务场景:只有在"同一个key"对应的数据获取逻辑是重复的,且结果不会因为并发请求而发生变化时,SingleFlight才是适用的。

简介

SingleFlight是go语言中sync包中的一个东西。它用于确保在并发环境下某个操作(例如,函数调用)即使被多个goroutine同时请求,也只会被执行一次。这对于防止重复的、昂贵的操作(如数据库查询、HTTP请求等)被不必要地多次执行是非常有用的。

使用 sync.SingleFlight,可以确保对于同一个键的并发请求,在缓存失效的情况下,只有一个请求会去加载数据(例如从数据库中),而其他并发的请求会等待这个加载操作完成,并共享相同的结果。这样,即便缓存失效,也不会因为大量的并发请求而对数据库或后端服务产生压力。

具体来说,当缓存失效时,第一个到达的请求会触发数据加载的操作(如数据库查询),而其他同时到达的请求会等待这个操作的完成。一旦数据被加载,它会被返回给所有等待的请求,并重新被放入缓存中。这个过程 sync.SingleFlight 保证了数据加载函数只被调用一次,避免了不必要的重复处理。

SingleFlight主要提供以下功能:

  • Do(key string, fn func() (interface{}, error)): 这是SingleFlight最核心的方法。当多个goroutine同时调用Do方法时,只有一个会真正执行传入的fn函数,其它等待这个函数执行完成。执行完成后,返回的结果和错误将会被返回给所有调用Do方法的goroutine。这里的key是用来区分不同操作的唯一标识。
  • DoChan(key string, fn func() (interface{}, error)): 与Do类似,但它返回一个channel,你可以从这个channel中读取执行结果。
  • Forget(key string): 这个方法用于清除SingleFlight中缓存的结果,以便于同一个key对应的函数在未来可以再次被执行。
  • DupSuppressed() int64: 返回被SingleFlight机制抑制的重复调用次数。

SingleFlight的一个常见用途是缓存层,避免在缓存失效时由于缓存击穿而导致大量请求直接落到数据库。

如下是在写go语言的时候的使用SingleFight解决缓存击穿的代码。

Go 复制代码
var g singleflight.Group

func getCachedData(key string) (data interface{}, err error) {
    // 使用Do方法确保对于同一个key的请求,函数只会被执行一次
    v, err, _ := g.Do(key, func() (interface{}, error) {
        // 这里是实际的获取数据的操作,比如从数据库读取
        return getDataFromDatabase(key)
    })
    return v, err
}

func getDataFromDatabase(key string) (interface{}, error) {
    // 模拟数据库操作
    // ...
    return data, nil
}

SingleFlight 缺点与优化

SingleFlight 是一种用于减少重复工作的工具,特别是在并发编程中处理类似缓存击穿这样的问题时。尽管它非常有用,但也有一些潜在的缺点和限制:

缺点
  1. 资源锁定:如果用于一个长时间运行的操作,SingleFlight 会阻止其他所有相关的请求,直到这个操作完成。这可能导致长时间的等待,特别是在操作非常耗时的情况下。
  2. 错误传播:如果共享的操作因为某些原因失败了,这个错误会被传播给所有等待的请求。在某些情况下,单独重试可能更合适。
  3. 内存压力:在高并发情况下,如果许多不同的键被请求,SingleFlight 结构可能占用大量内存。
  4. 不适合高变动数据:对于频繁变化的数据,使用 SingleFlight 可能不太有效,因为一旦数据被缓存,就需要等待旧数据失效才能获取新数据。
优化策略
  1. 设置超时:为 SingleFlight 中的操作设置合理的超时时间,可以防止一个慢操作阻塞其他请求过长时间。
  2. 错误重试机制:对于某些操作,特别是网络请求等,实现自动重试逻辑可能会有帮助,而不是直接将一个失败共享给所有请求。
  3. 限制并发数量:可以对 SingleFlight 正在进行的操作数量设置上限,以减少内存压力。
  4. 数据版本控制:对于频繁变化的数据,可以结合数据版本控制,确保即使在数据更新的时候也能获取到最新的数据。

然后这里我就点到为止,提供一个优化的思路以及缺点和优点,具体方案不方便透露,哈哈哈哈,不过我这里可以给大家推荐一个 Github 上基于这个的改造代码,主要实现了请求折叠功能,当时这个我也在代码中实现了,大家感兴趣可以看一下。

请求折叠版本 SingleFlight:https://github.com/Percygu/go_multisingleflight

然后我简单说一下其背景和原因。

SingleFlight 优化背景

面对多次相同的请求,我们可以使用singleflight来合并请求,最终只有一个请求生效,所有相同的请求都是返回的这次请求的结果,但是主要到这里的请求只是单个请求,也就是说并没有批量请求,而在现实中,我们却有很多批量请求的场景,比如获取商品列表,订单列表等等这样的批量查询请求。假设我们有三次批量请求,第一次查询id为1,2,3的商品,第二次查询id为1,3,4的商品,第三次查询id为1,4,5的商品,假设用singleflight,则会认为他们不是相同请求,所以请求不会合并,那么这样id为1的请求就会请求3次,所以针对这样的多个key的处理singleflight并不是合适的选择。

multisingleflight作为项目难点应用

凡是go语言项目,涉及到用到缓存的地方都可以在代码层面用multisingleflight来兜底,比如瑞吉外卖的商家列表查询,订单查询,卖家查询等地方。

  • 问题:go语言官方提供的singleflight 只能针对单个key的请求做到有效的访问合并,针对多个key查询,其中有部分重复key的话,不能有效的降低请求的次数
  • 难点:考虑到查询db耗时较大,性能不好,是有意对热点商品做一次redis缓存,但是但是在遇到例如 redis 抖动或者其他情况可能会导致大量的 cache miss 出现,这个时候大并发的请求可能会瞬间把后端DB压垮。
  • 解决方案:采用go语言提供的singleflight 库,在代码层面做一次请求优化,利用singleflight 的幂等思想,就是将一组相同的请求合并成一个请求,实际上只会去请求一次,然后对所有的请求返回相同的结果,这样来减轻db的访问压力,避免瞬间瞬间把后端DB压垮
  • 优化方案:在go 官方源码的基础改造出适用于批量查询的 multisingleflight

Go 中的 SingleFlight 是什么?

Go 语言的 golang.org/x/sync/singleflight 包提供了一种名为 SingleFlight 的并发抑制机制。它的核心作用是: 对于同一个键(key),无论有多少个并发的请求,只让其中一个请求去执行实际的函数,其他请求则等待这个函数执行完毕,并共享它的结果。

这在很多场景下都非常有用,最典型的就是 缓存击穿 问题:

  1. 一个缓存项过期了。

  2. 此时,大量并发请求同时涌入,都想要获取这个数据。

  3. 它们发现缓存中没有数据,于是全部穿透到后端的数据库或服务。

  4. 这会导致后端资源在短时间内压力剧增,甚至崩溃。

SingleFlight 就像一个聪明的门卫。当大量请求同时索要同一个数据时,它只放行第一个请求去"取回"数据,并让其他请求在门口排队等待。一旦第一个请求拿回了数据,门卫会把这份数据复制给所有正在等待的请求。这样,无论并发多高,对后端资源的实际请求永远只有一个。

用 Java 如何实现 SingleFlight?

在 Java 中,我们可以利用 java.util.concurrent 包下的工具来实现同样的效果。核心思想是使用一个 ConcurrentHashMap 来存储正在进行中的"请求",其 value 则是一个 CompletableFuture ,用来代表这个请求未来的结果。

下面是一个具体的 Java 实现示例:

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

/**
 * Go sync/singleflight 机制的 Java 实现。
 *
 * @param <K> Key 的类型
 * @param <V> Value 的类型
 */
public class SingleFlight<K, V> {

    // 使用 ConcurrentHashMap 来存储正在进行中的任务
    // Key: 任务的唯一标识
    // Value: CompletableFuture,代表任务的未来结果
    private final ConcurrentHashMap<K, CompletableFuture<V>> inFlight = new ConcurrentHashMap<>();

    /**
     * 执行一个任务,如果同样 key 的任务正在执行,则等待其结果。
     *
     * @param key      任务的唯一标识
     * @param supplier 实际执行任务的逻辑,它应该返回类型为 V 的结果
     * @return 任务的结果
     * @throws ExecutionException   如果任务执行过程中抛出异常
     * @throws InterruptedException 如果线程在等待结果时被中断
     */
    public V execute(K key, Supplier<V> supplier) throws ExecutionException, InterruptedException {
        // 使用 computeIfAbsent 原子地检查并创建任务
        // 如果 key 不存在,lambda 表达式会被执行,创建一个新的 CompletableFuture
        // 如果 key 已存在,直接返回已存在的 CompletableFuture
        CompletableFuture<V> future = inFlight.computeIfAbsent(key, k ->
                CompletableFuture.supplyAsync(() -> {
                    try {
                        // 执行耗时的任务
                        return supplier.get();
                    } finally {
                        // 任务完成后,无论成功还是失败,都从 map 中移除
                        inFlight.remove(k);
                    }
                })
        );

        // 等待并返回结果。
        // 如果是第一个创建任务的线程,它会等待 supplyAsync 完成。
        // 如果是后续加入的线程,它会等待已存在的 future 完成。
        return future.get();
    }

    public static void main(String[] args) {
        SingleFlight<String, String> singleFlight = new SingleFlight<>();
        String resourceKey = "user:profile:123";

        // 模拟 10 个并发线程请求同一个资源
        for (int i = 0; i < 10; i++) {
            int threadNum = i + 1;
            new Thread(() -> {
                System.out.printf("线程 %d: 准备请求资源 '%s'%n", threadNum, resourceKey);
                try {
                    // 所有线程都使用同一个 key 调用 execute
                    String result = singleFlight.execute(resourceKey, () -> {
                        // 这个 lambda 表达式是实际的耗时操作
                        // 它只会被执行一次
                        System.out.printf("--- 线程 %d: 缓存未命中,开始从数据库获取资源 '%s'... ---%n", threadNum, resourceKey);
                        try {
                            // 模拟耗时2秒的数据库查询
                            Thread.sleep(2000);
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                        System.out.printf("--- 线程 %d: 资源获取成功! ---%n", threadNum);
                        return "这是用户的个人资料";
                    });
                    System.out.printf("线程 %d: 成功获取到结果: '%s'%n", threadNum, result);
                } catch (ExecutionException | InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}
相关推荐
Alan GEO实施教练2 小时前
实用新型专利申请代理机构选择:关键考量因素与实操要点讲解
java·开发语言·python
gelald2 小时前
JVM - 类加载机制
java·jvm·后端
weixin_449190412 小时前
golang中int8溢出
开发语言·后端·golang
小的~~2 小时前
使用StreamLoad向Doris-4.0.3版本的聚合表导数据超时问题
运维·服务器·数据库
xieliyu.2 小时前
Java 基础:接口核心概念与实战详解
java·开发语言
kyriewen2 小时前
事件流与事件委托:当点击按钮时,浏览器里发生了什么?
前端·javascript·面试
不秃不少年2 小时前
工厂方法模式(Factory Method)
java·面试·工厂方法模式
daxi1502 小时前
C语言从入门到进阶——第17讲:字符串函数
c语言·开发语言·算法·蓝桥杯
AY呀2 小时前
# 从手写 debounce 到企业级实现:我在面试中如何“降维打击”面试官
前端·面试