跳到主要内容
英文版本为准

本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。

长任务、崩溃与领取方式

本指南选择处理器工作期间队列表如何持有任务,并说明运行数分钟的任务和进程终止的任务会发生什么。

被领取的任务在结算之前必须避开其他领取,在它的进程停止时又必须回到队列。表的描述为此选定三种领取方式之一,处理器在每种方式下都相同。

三种方式​

行锁租约咨询锁
选定方式不需要设置:默认方式#[field(locked_until)]#[inbox(advisory_lock = "..")]
持有任务的方式领取的事务,FOR UPDATE SKIP LOCKED写入行中的到期时间行的键上的锁,由一个会话持有
连接每个处理中的任务一个,占用整个处理器运行期间每条语句一个每个处理中的任务一个
崩溃之后任务立即回来任务在租约到期后回来任务立即回来
崩溃是否消耗一次尝试否是是
数据库Postgres、MySQL、MariaDB全部全部

行锁方式在处理器运行期间保持领取的事务打开,由结算提交它。崩溃进程的事务会回滚,所以它的任务立即回来,尝试次数也不计入。代价是每个处理中的任务占用一个连接。SQLite 没有行锁,所以 SQLite 表要指定另外两种方式之一。

租约方式立即提交领取,所以处理中的任务既不占用连接,也不占用事务。它适合长任务,也是 SQLite 队列所用的方式。咨询锁方式按键持有每个任务:键取 id 时,每个任务单独持有;键取另一列时,共享该键的任务逐个进入处理。

渲染数分钟的报表​

src/main.rs
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS report_jobs (
id INTEGER PRIMARY KEY,
attempt INTEGER NOT NULL DEFAULT 1,
locked_until TEXT,
payload BLOB NOT NULL
)";
src/main.rs
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::Sqlite;
use sqlx::sqlite::SqlitePoolOptions;

/// One row of `report_jobs`: `locked_until` claims it under a lease.
#[derive(Inbox, sqlx::FromRow)]
#[inbox(table = "report_jobs")]
struct RenderReport {
#[field(id, generated)]
id: i64,
#[field(attempt, generated)]
attempt: i16,
#[field(locked_until)]
locked_until: Option<DateTime<Utc>>,
#[field(payload)]
payload: Vec<u8>,
}

/// How long the row of a crashed process stays out of the queue.
const LEASE: Duration = Duration::from_mins(1);

#[subscriber(InboxQueue::<RenderReport>::new("reports").lease(LEASE))]
async fn render(report: &Report, Ctx(attempt): Ctx<keys::Attempt>) -> HandlerOutcome {
if attempt > Some(1) {
tracing::warn!(report.id, ?attempt, "rendering again after a lost attempt");
}
draw(report).await;
tracing::info!(report.id, "report rendered");
HandlerOutcome::ack()
}

/// The service on the database `url` names.
fn service(url: &str) -> RustStream {
let schema_url = url.to_owned();
// Nothing connects here: the broker builds its pool when the service starts.
let broker = SqlxBroker::from_url(url, SqlitePoolOptions::new());
RustStream::new(AppInfo::new("reports", "0.1.0"))
// The table, created before the subscription checks it.
.on_startup(async move |()| {
let mut conn = SqliteConnection::connect(&schema_url).await?;
sqlx::raw_sql(SCHEMA).execute(&mut conn).await?;
conn.close().await
})
.with_broker(broker.route::<RenderReport>("reports"), |b| {
b.include(render);
})
}

领取把租约的到期时间写入 locked_until,并计入这次尝试。render 运行期间,订阅每隔半个租约续约一次,所以在一分钟的租约下,一份报表可以渲染五分钟。到期时间就是投递的所有权令牌:只有该行仍持有这个租约时,确认才会删除它。InboxQueue::lease 设置单个订阅的租约,SqlxBroker::lease 设置 Broker 的租约;未设置时为三十秒,以整秒计。

崩溃时会发生什么​

在报表渲染到一半时终止的进程,不再为它的租约续约。在租约到期之前,该行不在队列中,这里是一分钟,随后下一次领取取走它。这次领取再计入一次尝试,所以 render 看到第二次尝试,并把它记入日志。因此,租约就是崩溃让任务延迟的时长:租约越短,任务回来得越早,代价是续约次数更多。进程仍然存活却停止了续约,例如与数据库断开的时间超过了租约,任务就会落入另一次领取,它迟到的结算以 SqlxBrokerError::LeaseLost 失败。

行锁或咨询锁随它的会话一起结束,所以崩溃会让任务立即回来。shutdown 时,Broker 停止续约:处理中的任务保留它的租约,能结算就结算,否则在租约到期后回来。咨询锁在 shutdown 时释放(关闭)。

每种方式所需的连接池​

行锁或咨询锁为每个处理中的任务占用一个连接,所以一个 workers(8) 的订阅,仅它的任务就需要八个连接。租约只在执行每条语句时占用连接。每个订阅都为处理器、处理器的发布和结算在连接池中留出一个连接(连接)。在 Postgres 上,咨询锁方式需要直接连接,或者会话模式的 PgBouncer,因为锁存在于会话中。在 SQLite 上,进程自己保存处理中的咨询锁键,所以一个数据库文件由一个进程服务。在生产环境中运行一页说明如何确定连接池大小。

测试​

示例文件中的测试在内存中的 SQLite 上运行服务。第一个测试暂停时钟,五分钟的渲染瞬间过去,租约的续约在时钟到达时运行;报表只投递一次。第二个测试写入两行,它们已被另一个进程领取:一行的租约已经到期,另一行的租约仍然有效。只有第一行回来。这些行不唤醒任何订阅,所以这个测试用 TestApp::start_live 在真实时钟上运行,越过轮询间隔。