具有大共享状态的 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(),
});
}
然后数据服务可以只监听传入的消息,提取查询并处理它,然后将结果发送回它刚收到的通道。
我的问题
如果你还在我身边,我真的很感激。
我的问题是:
mspc 通道是否有很大的开销会减慢我的程序通过 mspc 通道传输大量数据的速度?
是否可以按照我希望允许双向通信的方式发送回调?如果不是,公认的做法是什么?
我知道这是一个见仁见智的问题,但这两种方法中哪一种是解决此类问题的更标准方法,还是仅归结为个人喜好/技术要求问题?
一个。我会完全忽略 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 构建的,但他们已经疏远到不再有任何有意义的关系)。我个人不使用演员,因为他们往往是一个模糊的抽象层,但这取决于你。它可能比直接访问数据慢,而且您仍然需要在内部决定上面提到的并发机制。
我有以下问题:
我需要构建一个高性能、多线程的 HTTP 服务器,能够以极低的延迟处理大量数据。
整个数据集非常非常大 (10+ GB),但大多数请求只需要访问该数据的一个子集。等待DB访问会太慢,数据必须保存在内存中。
每个网络请求只会对数据执行读取操作,但是会有一个后台工作线程负责定期管理数据更新。
我的基本做法:
我选择了 actix 网络服务器,因为它有一个很好的功能集,而且在我看过的基准测试中似乎表现最好。
我的主要想法是将启动时的所有数据加载到某种共享状态,加载到针对读取操作进行了大量优化的数据结构中。
然后我想提供某种接口,每个请求处理程序都可以使用该接口来查询该数据,并根据需要获取对数据不同部分的不可变引用。
这应该避免竞争条件(因为只有工作线程具有写访问权限)以及避免昂贵的数据复制操作。
架构 A
我最初的方法是在模块内创建此数据:
static mut DATA: ProgramData;
然后公开 public 访问它的方法,但在阅读了足够多的有关静态内存的警告后,我放弃了这种方法。
架构 B
这就是我目前的工作。我在程序的主函数中创建了一个像这样的空结构(其中 ProgramData 是一个自定义结构):
struct ProgramDataWrapper {
data_loaded: bool,
data: ProgramData,
}
然后我将 Arc
所以这个数据应该在程序的整个生命周期中持续存在,因为 main 方法总是有一个对它的引用并且它永远不应该被删除。
我已经在此结构上实现了 public 方法,以启用查询数据并根据 HTTP 请求的输入参数取回数据的不同部分。
然后我将 Arc
路由处理程序通过取消引用 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(),
});
}
然后数据服务可以只监听传入的消息,提取查询并处理它,然后将结果发送回它刚收到的通道。
我的问题
如果你还在我身边,我真的很感激。
我的问题是:
mspc 通道是否有很大的开销会减慢我的程序通过 mspc 通道传输大量数据的速度?
是否可以按照我希望允许双向通信的方式发送回调?如果不是,公认的做法是什么?
我知道这是一个见仁见智的问题,但这两种方法中哪一种是解决此类问题的更标准方法,还是仅归结为个人喜好/技术要求问题?
一个。我会完全忽略 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 构建的,但他们已经疏远到不再有任何有意义的关系)。我个人不使用演员,因为他们往往是一个模糊的抽象层,但这取决于你。它可能比直接访问数据慢,而且您仍然需要在内部决定上面提到的并发机制。