具有大共享状态的 Web 应用程序架构

Architecture of web app with large shared state

我有以下问题:

我需要构建一个高性能、多线程的 HTTP 服务器,能够以极低的延迟处理大量数据。

整个数据集非常非常大 (10+ GB),但大多数请求只需要访问该数据的一个子集。等待DB访问会太慢,数据必须保存在内存中。

每个网络请求只会对数据执行读取操作,但是会有一个后台工作线程负责定期管理数据更新。

我的基本做法:

我选择了 actix 网络服务器,因为它有一个很好的功能集,而且在我看过的基准测试中似乎表现最好。

我的主要想法是将启动时的所有数据加载到某种共享状态,加载到针对读取操作进行了大量优化的数据结构中。

然后我想提供某种接口,每个请求处理程序都可以使用该接口来查询该数据,并根据需要获取对数据不同部分的不可变引用。

这应该避免竞争条件(因为只有工作线程具有写访问权限)以及避免昂贵的数据复制操作。

架构 A

我最初的方法是在模块内创建此数据:

static mut DATA: ProgramData;

然后公开 public 访问它的方法,但在阅读了足够多的有关静态内存的警告后,我放弃了这种方法。

架构 B

这就是我目前的工作。我在程序的主函数中创建了一个像这样的空结构(其中 ProgramData 是一个自定义结构):

struct ProgramDataWrapper {
  data_loaded: bool,
  data: ProgramData,
}

然后我将 Arc 智能指针传递给 DataService(负责异步加载它,并管理数据随时间的刷新),Arc 指针的另一个副本是 Actix 网络状态。

所以这个数据应该在程序的整个生命周期中持续存在,因为 main 方法总是有一个对它的引用并且它永远不应该被删除。

我已经在此结构上实现了 public 方法,以启用查询数据并根据 HTTP 请求的输入参数取回数据的不同部分。

然后我将 Arc 传递到 Actix web 状态,这样每个处理程序都可以只读访问它,并且可以使用 public 函数查询数据(内部数据不是 public).

路由处理程序通过取消引用 Arc 然后从 RwLock 获取读锁,然后调用一些方法如 is_ready()。

那么,例如,我有一个端点 /ready,它将 return true/false 与负载均衡器进行通信,数据在内存中并且该实例已准备好开始接收请求.

我注意到,当工作线程在数据结构上获得写锁时,其他路由处理程序无法访问它,因为它们被阻止并且整个应用程序冻结,直到数据被更新。这是因为整个 ProgramDataWrapper 结构都被锁定,包括它的 public 方法。

我想我可以通过将 RwLock 放在 ProgramData 对象本身上来解决这个问题,这样当工作线程正在组装新数据时,数据的其他部分仍然可以在 ProgramDataWrapper 对象上获得读取锁并访问public界面。

然后应该是很短的时间,一旦数据准备好,就可以对数据进行写锁定,只复制新的数据位,然后立即释放。

架构 C

我的另一个想法是使用 mpsc 频道。

当我创建DataService 时,它​​可以创建一个发送-接收对,保留recv 端并将发送端传回main 方法。然后可以将发送通道克隆到 Actix 网络状态,以便每个路由处理程序都有一种方法将数据发送到数据服务。

我当时想的是创建一个这样的结构:

TwoWayData<T, U> {
  query: T,
  callback: std::sync::misc::Sender<U>,
}

然后在路由处理程序中,我可以创建上述类型的发送-接收对。

我可以向数据服务发送消息(因为我可以从顶部主函数访问指向发送方克隆的指针),并将对象作为有效负载包含在内,以将数据发送回路由处理程序.

类似于:

#[get("/stuff")]
pub async fn data_ready(data: web::Data<Arc<Sender<TwoWayData<DataQuery, DataResponse>>>>) -> impl Responder {
  let (sx, rx): (Sender<TwoWayData<DataQuery, DataResponse>>, Receiver<TwoWayData<DataQuery, DataResponse>>) = channel();

  data.send(TwoWayData {
    query: "Get me some data",
    callback: sx.clone(),
  });

}

然后数据服务可以只监听传入的消息,提取查询并处理它,然后将结果发送回它刚收到的通道。

我的问题

如果你还在我身边,我真的很感激。

我的问题是:

  1. mspc 通道是否有很大的开销会减慢我的程序通过 mspc 通道传输大量数据的速度?

  2. 是否可以按照我希望允许双向通信的方式发送回调?如果不是,公认的做法是什么?

  3. 我知道这是一个见仁见智的问题,但这两种方法中哪一种是解决此类问题的更标准方法,还是仅归结为个人喜好/技术要求问题?

一个。我会完全忽略 static mut,因为它是 unsafe 并且很容易出错。我认为它的唯一方法是 static DATA: RwLock<ProgramData>,但它与选项 B 相同,只是它对测试、离散数据集等的灵活性较低

乙。使用 Arc<RwLock> 是一种非常常见且易于理解的模式,在跨线程共享可变数据时,我会将其视为我的第一选择。如果您将关键部分写得很小,这也是一个非常高效的选择。如果为每个更新克隆整个数据集是不可行的并且 in-place 更新很长 and/or non-trivial,您可能会寻求其他一些并发 data-structure。在超过 10 GB 的数据中,我必须仔细查看您的数据、访问和更新模式,以决定“最佳”行动方案。也许您可以在您的结构中使用许多较小的锁,或使用 DashMap,或它们的组合。有许多可用的工具,如果您正在努力实现最低延迟,您可能需要定制一些东西。

C.这看起来有点令人费解,但掩盖细节几乎是一个“演员模型”,或者至少是基于消息传递的原则。如果您希望数据表现为一个单独的“服务”,可以自我管理并提供对查询处理方式的更多控制,那么您可以使用像 Actix 这样的参与者框架(最初是为 Actix-Web 构建的,但他们已经疏远到不再有任何有意义的关系)。我个人不使用演员,因为他们往往是一个模糊的抽象层,但这取决于你。它可能比直接访问数据慢,而且您仍然需要在内部决定上面提到的并发机制。