一个看似成功的数据库写入,却被系统判定为失败,前端反复重试导致数据错乱——这不是网络抖动,而是Supabase行级安全(RLS)策略与PostgREST错误码的微妙交互。某团队为此耗费整整两天排查,最终发现PGRST116错误竟有两种完全相反的含义。

该团队运营一套基于Supabase的学习管理系统(LMS),架构涵盖认证服务、全表RLS策略,以及通过FastAPI与PostgreSQL交互的后端。问题出在quiz_attempts表的更新逻辑上。

打开网易新闻 查看精彩图片

具体实现采用upsert操作:当学生开始测验时创建尝试记录,答题过程中持续更新。使用upsert而非纯update的原因在于,若因网络原因重试,相同的attempt_id应当修补现有行而非插入重复数据。代码中通过.select().single()链式调用返回更新后的行,以便前端展示学生最新状态。

打开网易新闻 查看精彩图片

RLS策略设置存在关键不对称:写入策略仅校验student_id是否匹配当前认证用户;而读取策略额外要求is_visible字段为true。这一设计差异成为漏洞的通道。

触发条件源于一个边缘场景:quiz_attempts表上的触发器会在特定时机将is_visible置为false(与管理员工具的并行写入存在时序竞争)。此时学生的upsert实际已提交——字段写入成功,行归属正确。但随后的.select().single()执行时,SELECT策略生效,因is_visible=false而拒绝返回该行。

PostgREST返回的PGRST116错误信息为:"Results contain 0 rows",附带"JSON object requested, multiple (or no) rows returned"的说明。团队的运行时包装层对任何.error自动抛出异常,上游代码将此解读为upsert失败。前端据此重试,重试再次触发相同条件,最终导致用户状态与数据库状态 diverge。

打开网易新闻 查看精彩图片

排查困难的核心在于PGRST116的双重语义:它既可能表示写入真正失败(约束冲突、必填字段缺失、RLS拒绝写入),也可能表示写入成功但返回行被RLS隐藏。两种情况下错误码相同,HTTP状态码同为406,唯一区别存在于数据库层面,而客户端无从感知。

修复方案包含三点调整。第一,针对变异操作后链式调用的.select().single(),不再对PGRST116自动抛错,而是视为模糊状态单独处理。第二,业务层增加幂等性校验,通过唯一约束或预检查避免重复写入。第三,调整RLS策略设计,确保读写条件的一致性,或至少在关键路径上消除不对称性。

这一案例揭示了PostgREST错误码在RLS复杂场景下的语义模糊问题。当"无行返回"不等于"无行写入"时,简单的错误包装逻辑可能引发级联故障。对于依赖Supabase RLS的生产系统,区分"写入失败"与"读取被拒"成为必要的防御性编程实践。