Rust in Everything
基于 sea-orm 2.0.4

本站怎么用 sea-orm

主线位置:三条主线都在,跑在 sea-orm 1.1.20 上。定义主线:crates/core/src/entities/ 的 11 个展开写法实体;查询主线:各模块 server.rs 里的 find / paginate / count / find_also_related;写入主线:insert + on_conflict、update_many().col_expr(..)、三个事务。连接池和 sqlx 那一层见 sqlx 卷的本站怎么用 sqlx,本页不重讲。

flowchart TB
  ent["11 个实体"] --> rd["读:find / paginate"]
  ent --> wr["写:insert / update_many"]
  rd --> inq["手写 IN 批量取作者"]
  rd --> also["一处 find_also_related"]
  wr --> oc["三处 on_conflict"]
  wr --> tx["三个事务"]
  wr --> ce["col_expr 计数"]
  mig["9 个迁移"] --> one["一个事务"]
  inq --> pool["每条语句借一次连接"]
  also --> pool
  oc --> pool
  tx --> conn["事务独占一条连接"]

本页的实验 crate 是 exp-seaorm-site:依赖 sea-orm = "=1.1.20"(sqlx-postgres、runtime-tokio-rustls、macros、with-chrono,加上默认 feature,和本站 crates/core 一样),从 89a61b1 拷来 crates/core/src/entities/ 的 11 个文件原样不动,再把 crates/migration 整个拷成一个子 crate,在实验库自己的 schema 里用它建表。下面引用的查询代码都是从各模块的 server.rs 里拷出来的,只去掉了鉴权、ServerFnError 包装和结果转换。展示的 SQL 来自一个打印每条语句的 Postgres 协议代理(src/wire.rs),就是服务端收到的文本,参数是 $N。平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。

依赖:哪些 crate 用了 sea-orm

工作区 Cargo.toml 声明 sea-orm = { version = "1.1.4", features = [ "sqlx-postgres", "runtime-tokio-rustls", "macros" ] },Cargo.lock 解到 1.1.20;crates/core/Cargo.toml 再加一个 with-chrono(默认 feature 里本来就有)。用到 sea-orm 的是 app-core(实体、连接池、登录)、migration,以及 admin、comments、course、forum、moderation、payment、search 七个模块,都只在 server feature 下编译,浏览器端的 wasm 构建里没有 sea-orm。

实体:11 个,全是展开写法

每个实体一个文件,#[derive(DeriveEntityModel)] 的 Model、手写的 Relation 枚举和 impl Related,正是关系一页里的 1.x 写法。按主键和关系列出来:

实体表主键Relation
userusersid: i32has_many user_identity
user_identityuser_identitiesid: i32belongs_to user
commentcommentsid: i32belongs_to user
topictopicsid: i32belongs_to user,has_many topic_reply
topic_replytopic_repliesid: i32belongs_to topic、user
annotationannotationsid: i64belongs_to user
orderordersid: i64,out_trade_no 唯一belongs_to user
moderation_queuemoderation_queueid: i64空枚举
course_progresscourse_progress(user_id, course_slug, lesson_path)belongs_to user
entitlemententitlements(user_id, course_slug)belongs_to user
membershipmembershipsuser_idbelongs_to user

复合主键和 membership 的主键都写了 auto_increment = false,所以 Insert::exec 的 last_insert_id 是整个主键元组(下面 course_progress 的 upsert 返回 (2, "rust-basics", "01/02"))。moderation_queue 的 user_id 在迁移里有外键(ON DELETE SET NULL),实体里却是空的 Relation,这不影响什么,因为代码从不经由它 join。

写了 Related 的十个关系里,代码真正用到的只有一个:评论列表的 find_also_related(user::Entity)。其余需要「顺带取作者」的地方都是先查一页主记录,把 user_id 收集、排序、去重,再 user::Column::Id.is_in(..) 查一次,在内存里建 HashMap。没有 find_related、find_with_related,也没有 LoaderTrait。手写的这一步就是 Loader 做的事(一条 IN、去重、空输入不发语句),只是没交给 load_one。

查询:发了什么 SQL

把六个读路径原样跑一遍(src/bin/queries_wire.rs,种子数据是 3 个用户、3 条评论、3 个话题、3 条回复)。以论坛列表为例,list_topics 的数据库部分:

let q = q.order_by_desc(topic::Column::LastReplyAt).order_by_desc(topic::Column::CreatedAt);

let rows: Vec<topic::Model> = q
  .paginate(&db, PAGE_SIZE)
  .fetch_page(page)
  .await
  .map_err(|e| ServerFnError::new(e.to_string()))?;

// 收集 user_id 一次性查 nickname/avatar
let mut user_ids: Vec<i32> = rows.iter().map(|r| r.user_id).collect();
user_ids.sort();
user_ids.dedup();
// ...
user_entity::Entity::find()
  .filter(user_entity::Column::Id.is_in(user_ids))
  .all(&db)

代理看到的全部语句:

--- comments::get_comments("welcome") ---
  wire> SELECT "comments"."id" AS "A_id", "comments"."blog_id" AS "A_blog_id", "comments"."user_id" AS "A_user_id", "comments"."content" AS "A_content", "comments"."created_at" AS "A_created_at", "users"."id" AS "B_id", "users"."nickname" AS "B_nickname", "users"."avatar_url" AS "B_avatar_url", "users"."role" AS "B_role", "users"."token_version" AS "B_token_version", "users"."created_at" AS "B_created_at", "users"."updated_at" AS "B_updated_at" FROM "comments" LEFT JOIN "users" ON "comments"."user_id" = "users"."id" WHERE "comments"."blog_id" = $1 ORDER BY "comments"."created_at" DESC
comment 2 by Some("carol")
comment 1 by Some("bob")
--- forum::list_topics(None, page 0) ---
  wire> SELECT "topics"."id", "topics"."title", "topics"."tag", "topics"."content", "topics"."user_id", "topics"."reply_count", "topics"."last_reply_at", "topics"."ref_kind", "topics"."ref_path", "topics"."created_at", "topics"."updated_at" FROM "topics" ORDER BY "topics"."last_reply_at" DESC, "topics"."created_at" DESC LIMIT $1 OFFSET $2
  wire> SELECT "users"."id", "users"."nickname", "users"."avatar_url", "users"."role", "users"."token_version", "users"."created_at", "users"."updated_at" FROM "users" WHERE "users"."id" IN ($1, $2, $3)
topics in order: [("t2", false), ("t3", true), ("t1", true)]
users: 3
--- admin::admin_list_users(page 0) ---
  wire> SELECT COUNT(*) AS num_items FROM (SELECT "users"."id", "users"."nickname", "users"."avatar_url", "users"."role", "users"."token_version", "users"."created_at", "users"."updated_at" FROM "users") AS "sub_query"
  wire> SELECT "users"."id", "users"."nickname", "users"."avatar_url", "users"."role", "users"."token_version", "users"."created_at", "users"."updated_at" FROM "users" ORDER BY "users"."created_at" DESC LIMIT $1 OFFSET $2
  wire> SELECT "user_identities"."id", "user_identities"."user_id", "user_identities"."provider", "user_identities"."provider_uid", "user_identities"."refresh_token", "user_identities"."created_at" FROM "user_identities" WHERE "user_identities"."user_id" IN ($1, $2, $3)
total 3, rows 3, identities 3
--- forum::get_topic(1) ---
  wire> SELECT "topics"."id", "topics"."title", "topics"."tag", "topics"."content", "topics"."user_id", "topics"."reply_count", "topics"."last_reply_at", "topics"."ref_kind", "topics"."ref_path", "topics"."created_at", "topics"."updated_at" FROM "topics" WHERE "topics"."id" = $1 LIMIT $2
  wire> SELECT "users"."id", "users"."nickname", "users"."avatar_url", "users"."role", "users"."token_version", "users"."created_at", "users"."updated_at" FROM "users" WHERE "users"."id" = $1 LIMIT $2
  wire> SELECT "topic_replies"."id", "topic_replies"."topic_id", "topic_replies"."user_id", "topic_replies"."content", "topic_replies"."created_at" FROM "topic_replies" WHERE "topic_replies"."topic_id" = $1 ORDER BY "topic_replies"."created_at" ASC
  wire> SELECT "users"."id", "users"."nickname", "users"."avatar_url", "users"."role", "users"."token_version", "users"."created_at", "users"."updated_at" FROM "users" WHERE "users"."id" IN ($1, $2)
replies 2
--- admin::admin_overview (one of the 7 counts) ---
  wire> SELECT COUNT(*) AS num_items FROM (SELECT "users"."id", "users"."nickname", "users"."avatar_url", "users"."role", "users"."token_version", "users"."created_at", "users"."updated_at" FROM "users" WHERE "users"."role" = $1) AS "sub_query"
admins 1
--- forum::list_tags ---
  wire> SELECT "topics"."tag", COUNT("topics"."id") AS "cnt" FROM "topics" GROUP BY "topics"."tag" ORDER BY COUNT("topics"."id") DESC
[TagRow { tag: "rust", cnt: 2 }, TagRow { tag: "web", cnt: 1 }]

逐个看:

路径语句数说明
get_comments1find_also_related,一条 LEFT JOIN,列名带 A_ / B_ 前缀(join 与 find_related)
list_topics2只调 fetch_page,不数总数;作者一条 IN
admin_list_users3num_items 的 COUNT(*) 子查询去掉了 ORDER BY 和 LIMIT(分页与游标);身份一条 IN
get_topic4话题、楼主、回复、回复者,回复者是一条 IN
admin_overview7七个 count() 用 tokio::try_join! 同时发;每条语句各借一条连接(一次 find 的全过程),同一时刻占池里 7 条
list_tags1select_only + GROUP BY,COUNT 读进 i64

没有 N+1。 每个列表页的语句数是常数:主查询一条,每种关联一条 IN。get_topic 的楼主单独查了一次,可以并进回复者的那条 IN(四条变三条),但这不是随数据增长的问题。IN 的参数个数等于本页去重后的用户数,分页的列表最多 20 或 50 个;参数超限要到 65 535 个,碰不到。

有多余的列。 sea-orm 的 find() 总是列出实体的每一列。list_topics 每页取回 20 个话题的 content(text 列,话题正文),而 topic_to_summary 只用标题、标签、作者和计数,正文一个字都不用;后台的 admin_list_topics、list_my_topics 同样如此。取作者的 IN 拿回 users 的 7 列,用到的只有 nickname、avatar_url。话题正文是这里唯一可能变大的列:要省掉它,用 select_only().columns([..]) 加一个 FromQueryResult 结构体,或者 DerivePartialModel(部分列与自定义结果)。

排序里的 NULL。 输出里 t2 排在第一,它是唯一没有回复的话题(last_reply_at 为 NULL),比 t3 还早创建。Postgres 的 DESC 默认 NULLS FIRST,order_by_desc 不写 NULLS 子句,所以所有没人回复的话题都排在有回复的前面,不管多旧。迁移里的 idx_topics_recency 是 ("last_reply_at" DESC, "created_at" DESC),和这个顺序一致。这是不是想要的效果,本页判断不了;要改成「有回复的按最近回复排在前」,sea-orm 有 order_by_with_nulls(.., Order::Desc, NullOrdering::Last),索引也要跟着改。

find_also_related:作者解码失败会变成「已注销」

评论列表是全站唯一一处 find_also_related:

let results = comment::Entity::find()
  .filter(comment::Column::BlogId.eq(&blog_id))
  .find_also_related(user::Entity)
  .order_by_desc(comment::Column::CreatedAt)
  .all(&db)
  .await
  .map_err(|e| ServerFnError::new(e.to_string()))?;

let comments = results
  .into_iter()
  .map(|(c, u)| Comment {
    // ...
    author: u.as_ref().map(|u| u.nickname.clone()).unwrap_or_else(|| "已注销".to_string()),

comments.user_id 是 NOT NULL 外键,ON DELETE CASCADE:用户被删时评论一起删,LEFT JOIN 的右边按说永远有一行,「已注销」这个分支本不该走到。可右边 Model 是用 from_query_result_optional 读的,默认实现是 Ok(Self::from_query_result(res, pre).ok()),任何解码错误都变成 None(机制见 also-related-swallows-errors)。用站点的实体演示:把 users.token_version 改成 bigint,模拟库和二进制不同步(src/bin/also_related.rs,改完换一个新池,免得撞上缓存的语句):

before: Ok([(2, "carol"), (1, "bob")])
after ALTER token_version TYPE bigint (new pool):
  get_comments      : Ok([(2, "已注销"), (1, "已注销")])
  user find_by_id(2): Err(Query(SqlxError(ColumnDecode { index: "\"token_version\"", source: "mismatched types; Rust type `core::option::Option<i32>` (as SQL type `INT4`) is not compatible with SQL type `INT8`" })))

同一个解码错误,单独查用户时是 ColumnDecode,放在 find_also_related 的右边就成了「所有评论作者都已注销」,接口照常返回成功。在本站,这种不同步出现在回滚二进制(迁移已经跑过、旧代码读新表)或者手工改表之后。站点不需要 LEFT JOIN 的语义:外键保证作者存在,换成先查评论、再 IN 查作者(和论坛一样)就能让错误照常冒出来。

写入:三处 on_conflict

on_conflict 都在 crates/modules/course/src/server.rs:

调用点冲突目标冲突时执行方法
grant_entitlement_internal,source == "purchase"(user_id, course_slug)do_nothingexec_without_returning
同上,其他来源(user_id, course_slug)update_columns([Source, GrantedAt])exec_without_returning
grant_membershipuser_idupdate_columns(四列)exec
mark_lesson_complete三列主键update_columns([Completed, UpdatedAt])exec

do_nothing 配 Insert::exec 在冲突时报 RecordNotInserted(on-conflict-do-nothing)。站点在 do_nothing 这一支上用的是 exec_without_returning:它不带 RETURNING,1.1.20 的 exec_insert_without_returning 只是 db.execute(..) 再返回 rows_affected(),0 行不算错。每个调用点各跑两次(src/bin/upserts_wire.rs,函数体拷自上表三处,grant 返回 exec_without_returning 的值),最后一段把 do_nothing 那一支换成 exec 作对照:

--- purchase, purchase ---
  wire> INSERT INTO "entitlements" ("user_id", "course_slug", "source", "granted_at") VALUES ($1, $2, $3, $4) ON CONFLICT ("user_id", "course_slug") DO NOTHING
Ok(1)
  wire> INSERT INTO "entitlements" ("user_id", "course_slug", "source", "granted_at") VALUES ($1, $2, $3, $4) ON CONFLICT ("user_id", "course_slug") DO NOTHING
Ok(0)
--- admin_grant over it, then purchase again ---
  wire> INSERT INTO "entitlements" ("user_id", "course_slug", "source", "granted_at") VALUES ($1, $2, $3, $4) ON CONFLICT ("user_id", "course_slug") DO UPDATE SET "source" = "excluded"."source", "granted_at" = "excluded"."granted_at"
Ok(1)
  wire> INSERT INTO "entitlements" ("user_id", "course_slug", "source", "granted_at") VALUES ($1, $2, $3, $4) ON CONFLICT ("user_id", "course_slug") DO NOTHING
Ok(0)
  wire> SELECT "entitlements"."user_id", "entitlements"."course_slug", "entitlements"."source", "entitlements"."granted_at" FROM "entitlements" WHERE "entitlements"."user_id" = $1 AND "entitlements"."course_slug" = $2 LIMIT $3
source now: Some("admin_grant")
--- membership, membership ---
  wire> INSERT INTO "memberships" ("user_id", "tier", "expires_at", "source", "updated_at") VALUES ($1, $2, $3, $4, $5) ON CONFLICT ("user_id") DO UPDATE SET "tier" = "excluded"."tier", "expires_at" = "excluded"."expires_at", "source" = "excluded"."source", "updated_at" = "excluded"."updated_at" RETURNING "user_id"
Ok(2)
  wire> INSERT INTO "memberships" ("user_id", "tier", "expires_at", "source", "updated_at") VALUES ($1, $2, $3, $4, $5) ON CONFLICT ("user_id") DO UPDATE SET "tier" = "excluded"."tier", "expires_at" = "excluded"."expires_at", "source" = "excluded"."source", "updated_at" = "excluded"."updated_at" RETURNING "user_id"
Ok(2)
--- progress(true), progress(false) ---
  wire> INSERT INTO "course_progress" ("user_id", "course_slug", "lesson_path", "completed", "position_seconds", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT ("user_id", "course_slug", "lesson_path") DO UPDATE SET "completed" = "excluded"."completed", "updated_at" = "excluded"."updated_at" RETURNING "user_id", "course_slug", "lesson_path"
Ok((2, "rust-basics", "01/02"))
  wire> INSERT INTO "course_progress" ("user_id", "course_slug", "lesson_path", "completed", "position_seconds", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT ("user_id", "course_slug", "lesson_path") DO UPDATE SET "completed" = "excluded"."completed", "updated_at" = "excluded"."updated_at" RETURNING "user_id", "course_slug", "lesson_path"
Ok((2, "rust-basics", "01/02"))
--- what if purchase used exec() instead ---
  wire> INSERT INTO "entitlements" ("user_id", "course_slug", "source", "granted_at") VALUES ($1, $2, $3, $4) ON CONFLICT ("user_id", "course_slug") DO NOTHING RETURNING "user_id", "course_slug"
Err(RecordNotInserted)
  • 购买重复回调:第二次 Ok(0),站点丢掉这个值,函数返回 Ok(()),支付回调的发货是幂等的。
  • 管理员授予之后再来一次购买:DO NOTHING,source 还是 admin_grant,注释里说的「购买不覆盖已有来源」成立。
  • 用 exec 的两处都是 DO UPDATE:冲突时被更新的那一行照样由 RETURNING 带回来,exec 拿到主键,返回 Ok。RecordNotInserted 只在「DO NOTHING + 带 RETURNING 的执行方法」这个组合上出现,站点没有这个组合。

grant_membership 先 find_by_id 读出 expires_at,在 Rust 里算出新的到期时间,再 upsert;两个管理员同时续期,后写的覆盖先写的,少加一份天数。这是读改写,不是 sea-orm 的问题;只有管理员能调,影响有限。

col_expr:哪些计数是原子的

update_many().col_expr(..) 在站点里有两种用法。

一种是在 SQL 里算:post_reply 在事务里插回复,再 update_many:

topic::Entity::update_many()
  .col_expr(topic::Column::ReplyCount, Expr::col(topic::Column::ReplyCount).add(1))
  .col_expr(topic::Column::LastReplyAt, Expr::value(now))
  .col_expr(topic::Column::UpdatedAt, Expr::value(now))
  .filter(topic::Column::Id.eq(topic_id))
  .exec(&txn)

经代理(src/bin/txns.rs 的第一段):

--- post_reply(topic 1) ---
  wire> SELECT "topics"."id", "topics"."title", "topics"."tag", "topics"."content", "topics"."user_id", "topics"."reply_count", "topics"."last_reply_at", "topics"."ref_kind", "topics"."ref_path", "topics"."created_at", "topics"."updated_at" FROM "topics" WHERE "topics"."id" = $1 LIMIT $2
  wire> BEGIN
  wire< ReadyForQuery(T)
  wire> INSERT INTO "topic_replies" ("topic_id", "user_id", "content", "created_at") VALUES ($1, $2, $3, $4) RETURNING "id", "topic_id", "user_id", "content", "created_at"
  wire> UPDATE "topics" SET "reply_count" = "reply_count" + $1, "last_reply_at" = $2, "updated_at" = $3 WHERE "topics"."id" = $4
  wire> COMMIT
  wire< ReadyForQuery(I)
topics.reply_count = 3, actual replies = 3

"reply_count" = "reply_count" + $1 由服务端在行锁下读旧值、写新值,两个并发回复各加一,不会丢。支付的 claim_paid 是同一个思路的条件版:update_many 把 status 设成 paid,过滤条件是订单号相等且 status.is_in(["pending", "closed"]),用返回的 rows_affected 判断是不是自己认领到的,并发回调只有一个拿到 1。

另一种是在 Rust 里算好再写:admin_delete_reply 在事务里删掉一条回复,用 count() 重新数这个话题的回复,再把数出来的值 Set 回 reply_count,用 Entity::update 写进去。事务是 Postgres 默认的 READ COMMITTED,每条语句看见的是它开始时已提交的数据。

❗IMPORTANT
动脑筋:删回复的事务数完之后、写回之前,另一个请求提交了一条新回复,最后 reply_count 是多少?

src/bin/txns.rs 的第二段。开始时话题 1 有 3 条回复,reply_count 是 3。管理员事务删掉回复 1、数出剩下的条数;这时另一条连接上的 post_reply 完整跑完并提交;管理员事务再读话题、写回、提交。代码拷自 admin_delete_reply,只在中间插了一次 post_reply:

let txn = db.begin().await?;
topic_reply::Entity::delete_by_id(reply.id).exec(&txn).await?;
let new_count = topic_reply::Entity::find()
  .filter(topic_reply::Column::TopicId.eq(reply.topic_id))
  .count(&txn)
  .await? as i32;
post_reply(&plain, 1, 3).await?; // another request, on another connection
let target = topic::Entity::find_by_id(reply.topic_id).one(&txn).await?;
if let Some(t) = target {
  let mut am: topic::ActiveModel = t.into();
  am.reply_count = Set(new_count);
  topic::Entity::update(am).exec(&txn).await?;
}
txn.commit().await?;

结束时 reply_count 和实际的回复条数各是多少?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14,sea-orm 1.1.20。

这三处写法里,post_reply 的事务把「插回复」和「计数加一」绑在一起,二者同生同灭;claim_paid 和随后的发货却不在一个事务里:process_event 先认领、再调发货回调,发货失败时订单已经是 paid,网关重试走「已支付」的快路径,不再发货。函数的文档注释写明了这一点(「发货失败需人工对账」),是有意的取舍。

事务:三个,都只用 &txn

站点有三处 begin():sync_user_to_db(新用户 + 身份 + 读回)、admin_delete_reply(上一节)和 post_reply。三处在 begin 和 commit 之间的每条语句都传 &txn;post_reply 的审核入队用 &db,但在 commit 之后。所以事务里用了 &db 的那种自锁和「看不到自己的写入」在站点里不存在。出错时都是 ? 提前返回,DatabaseTransaction 被 drop,回滚随连接归还一起发出(事务)。没有嵌套事务,也没有 begin_with_config。

登录的事务值得跑一下:两个请求同时拿同一个第三方账号第一次登录,都在事务外查过身份表、都没查到,于是都进入「建新用户」的分支。拷来的 sync_user_to_db 只在查完身份之后加了一个 Barrier,让两个请求都错过对方(src/bin/login_race.rs):

request a: Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "23505", message: "duplicate key value violates unique constraint \"uq_user_identities_provider_uid\"", detail: Some("Key (provider, provider_uid)=(github, new-42) already exists."), hint: None, position: None, where: None, schema: Some("so_site"), table: Some("user_identities"), column: None, data_type: None, constraint: Some("uq_user_identities_provider_uid"), file: Some("nbtinsert.c"), line: Some(666), routine: Some("_bt_check_unique") }))))
request b: Ok((4, "dave"))
users: [(1, "alice"), (2, "bob"), (3, "carol"), (4, "dave")]
identities for new-42: 1

后插身份的那个事务在唯一索引 uq_user_identities_provider_uid 上等前一个提交,然后报 23505;它已经插进去的 users 行跟着事务回滚,表里只有一个 dave,没有孤儿用户。错误是 DbErr::Query 而不是 Exec:Postgres 上 Insert::exec 带 RETURNING,走的是查询路径(错误)。这一路的错误经 AppError::Db 交给前端时只给「内部错误」(client_message),用户重试一次就会走「已有身份」的分支。

能伤到这三个事务的是取消:外层把请求的 future 在 BEGIN 来回途中丢掉,连接会带着打开的事务回到池里(sqlx 0.8.6 的 #4393)。sqlx 卷在本站的设置下复现过(本站怎么用 sqlx),sea-orm 一侧的机制见 cancelled-begin。

错误怎么到前端

ServerFnError::new(e.to_string()) 在 89a61b1 里出现 85 次,除了登录,server 函数里每个数据库调用后面几乎都是这一句,DbErr 的 Display 原样成了 ServerFnError 的消息;论坛的回复框直接显示它(发表失败: {})。于是约束名、列名、duplicate key value violates ... 这些都会出现在浏览器里。DbErr 各分支的文本和它会带出什么,见错误;app_core::error::AppError 已经有只返回「内部错误」的 client_message,这些 server 函数没用它。

连接池、迁移和 mock 注释

连接参数在 build_connect_options,sqlx 卷逐项核对过,这里只记两条结论。DB_CONNECT_TIMEOUT_SECS 不起作用:sea-orm 把 connect_timeout 和 acquire_timeout 都写进 sqlx 的 acquire_timeout,后者后写,总是赢,1.1.20 和 2.0.4 一样(DatabaseConnection 与 ConnectOptions,坑页 connect-timeout-ignored)。第 60 行的注释「sqlx 默认 INFO 太吵」说反了主语:INFO 是 sea-orm ConnectOptions::new 的默认,sqlx 自己的默认是 DEBUG,sqlx_logging_level(Debug) 只是把 sea-orm 抬高的级别降回去(本站怎么用 sqlx 的日志一节)。

Migrator 有 9 个迁移,main.rs 启动时 Migrator::up(&db, None)。把拷来的迁移 crate 对空 schema 跑一遍(src/bin/migrate_wire.rs,节选):

  wire> BEGIN
  wire< ReadyForQuery(T)
  wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
  ...
  wire> SELECT "version", "applied_at" FROM "seaql_migrations" ORDER BY "version" ASC
  ...
  wire> CREATE INDEX "idx_topic_replies_topic" ON "topic_replies" ("topic_id", "created_at")
  wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
  ...
  wire> CREATE TABLE IF NOT EXISTS "memberships" ( "user_id" integer NOT NULL NOT NULL PRIMARY KEY, "tier" varchar(16) NOT NULL NOT NULL DEFAULT 'pro', "expires_at" timestamp with time zone NOT NULL NOT NULL, "source" varchar(32) NOT NULL NOT NULL DEFAULT 'admin_grant', "updated_at" timestamp with time zone NOT NULL NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "fk_memberships_user" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE )
  wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
  wire> ALTER TABLE "users" ADD COLUMN "token_version" integer NOT NULL DEFAULT 0
  wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
  wire> ALTER TABLE "orders" RENAME COLUMN "course_slug" TO "product_ref"
  wire> ALTER TABLE "orders" ADD COLUMN "product_kind" varchar(16) NOT NULL DEFAULT 'course'
  wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
  wire> COMMIT
  wire< ReadyForQuery(I)
Migrator::up = Ok(())

整个输出(48 行)只有一个 BEGIN 和一个 COMMIT,中间是 9 次 INSERT INTO "seaql_migrations":1.1.20 把所有待执行的迁移放进同一个事务,没有锁、没有校验和(和 sqlx 迁移的对照、两个实例并发迁移的实验都在 本站怎么用 sqlx,sea-orm-migration 的机制见 sea-orm-migration)。建表语句里的 NOT NULL NOT NULL 来自迁移的写法:string_len(..) 这类 schema 辅助函数已经带了 not_null(),迁移又调了一次,Postgres 接受重复的约束,无害。2.0.4 改成每个迁移一个事务,对本站意味着什么,见升级到 2.0。

最后是 payment crate [dev-dependencies] 上的注释:「不用 sea-orm 的 mock feature——它会经 feature 统一全局禁用 DatabaseConnection: Clone」。在 1.1.20 下成立,范围是编译 payment 的测试时;2.0 起 DatabaseConnection 无条件 Clone,这条理由不再存在(Mock 与 Proxy)。payment 的测试用的是自己写的 MockStore,不依赖 sea-orm 的 mock。

动脑筋答案

--- admin_delete_reply(reply 1) while post_reply(topic 1) commits in between ---
admin txn counted 2
post_reply committed
admin txn reads reply_count = 4
  wire> UPDATE "topics" SET "reply_count" = $1 WHERE "topics"."id" = $2 RETURNING "id", "title", "tag", "content", "user_id", "reply_count", "last_reply_at", "ref_kind", "ref_path", "created_at", "updated_at"
  wire> COMMIT
  wire< ReadyForQuery(I)
topics.reply_count = 2, actual replies = 3

reply_count 是 2,实际有 3 条回复。

  • 管理员事务删掉一条之后数出 2:这条 SELECT COUNT(*) 在 post_reply 提交之前执行,看不见那条新回复。
  • post_reply 的 UPDATE ... "reply_count" + 1 没有被挡住:管理员事务此时只锁了被删的那行回复,还没碰 topics 的行。它提交后 reply_count 是 4:删除没有动计数,3 加 1。
  • 管理员事务接着 find_by_id 读到 4(READ COMMITTED 下新语句看得见刚提交的数据),但写回的是事务开始阶段数出来的 2。Entity::update 只 SET 被 Set 的那一列,WHERE 只有主键,于是 2 覆盖了 4,新回复的那一次加一被抹掉。直到下一次删回复重新计数之前,列表上显示的回复数比实际少一。

同样的交错,把重新计数换成和 post_reply 对称的减一(src/bin/txns.rs 的第三段):

topic::Entity::update_many()
  .col_expr(topic::Column::ReplyCount, Expr::col(topic::Column::ReplyCount).sub(1))
  .filter(topic::Column::Id.eq(reply.topic_id))
  .exec(&txn)
  .await?;
--- same interleaving, reply_count = reply_count - 1 instead of a recount ---
post_reply committed
  wire> UPDATE "topics" SET "reply_count" = "reply_count" - $1 WHERE "topics"."id" = $2
  wire> COMMIT
  wire< ReadyForQuery(I)
topics.reply_count = 2, actual replies = 2

(这一段先重新灌了种子数据,话题 1 回到 2 条回复。)加一和减一都由服务端在行锁下完成,谁先谁后结果都对。重新计数的好处是能把以前漂移的值纠正回来。想保留它,就在事务一开始锁住话题行(lock_exclusive() 生成 FOR UPDATE,见 Select 与过滤),并发回复的 UPDATE 会排在后面(第四段,post_reply 在另一个任务里跑,等 300 毫秒看它有没有跑完):

let txn = db.begin().await?;
let t = topic::Entity::find_by_id(reply.topic_id).lock_exclusive().one(&txn).await?.unwrap();
topic_reply::Entity::delete_by_id(reply.id).exec(&txn).await?;
let p2 = plain.clone();
let other = tokio::spawn(async move { post_reply(&p2, 1, 3).await });
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
println!("post_reply finished before admin commit: {}", other.is_finished());
// ... 数、写回、提交,和上面一样
other.await??;
--- recount, but lock the topic row first ---
post_reply finished before admin commit: false
topics.reply_count = 2, actual replies = 2

post_reply 的插入不受影响,它的 UPDATE 在话题行上等管理员事务提交,再在提交后的值上加一。

小结

  • 本站用 sea-orm 1.1.20,11 个展开写法实体;写了 10 个 Related,只有评论列表的 find_also_related 用到,其余关联是手写的 IN 批量查询。
  • 列表页没有 N+1:主查询一条,每种关联一条 IN;get_topic 四条,可并成三条。find() 取回全部列,话题列表白拿了每篇正文。
  • order_by_desc(LastReplyAt) 在 Postgres 上把没有回复的话题全排在前面(NULLS FIRST),索引也是这个顺序。
  • 评论作者的解码错误会被 find_also_related 吞成「已注销」;外键保证作者存在,用 IN 单独查就能让错误冒出来。
  • 三处 on_conflict 在冲突时都返回 Ok:DO NOTHING 那一支用的是不带 RETURNING 的 exec_without_returning,用 exec 的两处是 DO UPDATE,RETURNING 照常有行。
  • 三个事务里只用 &txn;并发首次登录被唯一索引挡下,事务回滚,不留孤儿用户。取消 begin 的问题继承自 sqlx。
  • col_expr 的加一和支付的条件更新是原子的;删回复时在 Rust 里重新计数,和并发回复交错会把计数写错。
  • server 函数用 ServerFnError::new(e.to_string()) 把 DbErr 的文本交给前端(85 处)。9 个迁移在一个事务里,DB_CONNECT_TIMEOUT_SECS 无效,payment 的 mock 注释只在 1.1.20 下成立。

💬 关联讨论

发起讨论
加载中...