1. 并发
FastAPI 可以同时处理多个请求。并发请求可能通过连接池中的不同连接执行事务,即使我们没有在业务代码中手动创建线程,数据库仍然会同时面对多个读写操作。
假设两个请求同时提高任务优先级:
1请求 A 读取 priority = 12请求 B 读取 priority = 13请求 A 写入 priority = 24请求 B 写入 priority = 2
两个请求都报告成功,但最终只增加了一次,这叫作丢失更新。每个请求单独看都没有语法错误,问题来自它们交错执行。
为了让后面的示例不依赖固定 ID,先在当前 psql 会话中选择一条未归档任务:
1SELECT id AS task_id2FROM tasks3WHERE archived_at IS NULL4ORDER BY id DESC5LIMIT 16\gset
\gset 会把查询结果保存为 task_id 变量。真实 FastAPI 接口应该直接使用路径参数或请求数据中的任务 ID,并通过数据库驱动绑定参数。
如果操作可以写成数据库内的原子更新,优先让一条 SQL 完成:
1UPDATE tasks2SET3priority = LEAST(priority + 1, 3),4updated_at = now()5WHERE id = :task_id6AND priority < 37RETURNING id, priority;
PostgreSQL 会为被更新的行进行必要的并发控制。两个请求同时执行时,其中一个会先更新,另一个随后基于该行的新版本继续计算,不会都使用应用之前读到的旧值。这里增加 priority < 3,到达上限后语句会影响零行,不会只修改 updated_at 却仍被接口当成提升成功。
原子 SQL 通常是处理简单并发写入的第一选择。它减少了应用与数据库之间的往返,也缩小了竞态出现的空间。
2. 锁
如果业务必须先读取多列、执行判断,再决定怎样写入,可以使用行锁:
01BEGIN;0203SELECT id, status, priority04FROM tasks05WHERE id = :task_id06FOR UPDATE;0708UPDATE tasks09SET status = 'doing', updated_at = now()10WHERE id = :task_id11AND status = 'todo'12RETURNING id, status;1314COMMIT;
FOR UPDATE 只锁定实际查询到的行,锁通常一直持有到当前事务提交或回滚。另一个事务如果想更新、删除或取得冲突的行锁,默认会等待;如果查询没有找到任务,也就没有任何任务行被锁定。若锁是在保存点之后取得的,回滚到该保存点时也会提前释放这部分锁。
行锁不会把整张表禁止访问。普通 SELECT 在 PostgreSQL 的多版本并发控制下通常仍能读取一个已经提交的版本,不会被 FOR UPDATE 阻塞,具体看到哪个版本取决于隔离级别。
取得锁和后续更新必须使用同一连接、处于同一事务。如果 SELECT ... FOR UPDATE 后立即提交,再从另一条连接执行 UPDATE,前面的行锁已经释放,无法保护后续操作。
行锁应该在事务中尽快完成使用。锁定后执行慢速网络请求,会让其他事务长时间等待,最终造成接口延迟和连接池堆积。