首页 / 资讯中心 / 文章详情

用Supabase重构知识交付体系:RLS权限与实时推送实践

用Supabase重构知识交付体系:RLS权限与实时推送实践 ★ FEATURED ARTICLE
先说个背景。我手里这套知识交付体系已经跑了两年多主要职责是把课程内容、配套文档、习题和系统通知分发给三类用户学员、内容创作者、管理员。功能上不算复杂但每次想调整一条访问规则都要拉上前后端一起改代码每次发布一篇新内容客户端总要等半分钟才刷出来。那段时间我一直在想问题到底出在架构上还是出在我们对知识交付这件事的理解上。后来一位同事把 Supabase 拉到我们面前我们花了两周做技术验证随后用了大概两个月把整套体系重写了一轮。这篇文章就是这次重构的完整记录为什么选它、动了哪些刀、踩了哪些坑以及重构之后我对全栈知识交付的理解发生了什么变化。整个重构过程中我感受最深的一句话是Supabase 表面上是一套后端服务本质上它逼着你重新思考数据归属权。权限不再是接口里顺手写的 if 判断而是数据自带的行为约束。这套思路放到知识交付场景里恰好能把内容访问控制、发布推送、角色管理这些原本割裂的模块全部统一到数据库层来解决。下面按我实际操作的顺序来拆。1. 旧知识交付体系的三个卡点权限散落、链路过长、联调内耗1.1 权限逻辑散落在每一个接口里老系统是典型的 Java Spring Boot MySQL Redis 组合前端用 React。权限判断全部写在服务端接口里管理员接口开头一个if(!isAdmin)创作者接口再来一个if(!isOwner)学员接口又判断enrollment.exists。一开始只有两三个角色这样写还能忍受。等加入试读用户课程助理内容审核员这些角色之后噩梦就来了每个接口都要叠加好几层角色判断而且不同接口的判断逻辑还不完全一致。最典型的一次产品提了一个试读用户只能看每门课的前两节的需求我统计了一下要改 14 个接口因为接口列表是写死的。问题本质不是代码写得差而是我们把数据该不该被看到这种业务规则硬生生散落在了应用层各个接口的流程里。规则一多必然不一致。1.2 内容下发链路太长发布像在走迷宫知识交付的核心动作是发布。老系统的发布流程是写 MySQL、更新 Redis 缓存、给消息队列丢一条通知、WebSocket 服务再向前端广播。每一步都可能出错而且每一步出错的表现都不一样。有段时间经常出现运营反馈后台显示已发布学员那边却看不到的问题排查链路从接口日志翻到缓存过期时间再翻到消息队列消费情况一查就是小半天。更深层的问题是这套链路里数据库只是存储真正的业务逻辑散在缓存层、队列层和推送服务里。每多一层就多一种不一致的可能。1.3 前后端联调成本随功能数量线性增长老系统的接口文档靠手写前端同学每次要查某个字段到底返回什么格式都得来问我。一个列表接口要支持排序、筛选、分页前端要传五六个参数后端要写对应的一大段逻辑。新功能从需求到上线平均两个星期其中一半时间花在接口联调和权限测试上。现在回头看这三个卡点指向同一个方向我们缺一个数据即接口的中间层。正当我们考虑要不要自己写一套数据访问服务时Supabase 出现了。2. 第一刀先动数据层用行级安全把权限写进每一行2.1 行级安全RLS到底解决了什么Supabase 和 Firebase 最大的区别是它底层跑着完整的 PostgreSQL而 Firebase 用的是封闭的 NoSQL 模型。这意味着你可以用数据库生态里已经沉淀了几十年的能力外键、触发器、视图、物化视图还有这个方案的核心——行级安全Row Level Security简称 RLS。RLS 的原理一句话就能说清每次查询时数据库根据当前用户的身份自动过滤掉不该看到的行。Supabase 的 Auth 模块会为登录用户签发 JWTPostgREST 把这个 JWT 解析成数据库会话变量RLS 策略基于这些变量决定放行还是拦截。也就是说前端可以直接通过 PostgREST 查数据库但查到的每一行都经过策略过滤。第一次听到这个设计时我的第一反应是这不就是把数据库裸奔给前端吗。后来想通了RLS 就是这个裸奔的守门员。规则写在数据库上写接口的人、查接口的人甚至未来新接的第三方系统都无法绕过这些规则。2.2 内容表上的核心策略公开读、创作者写、学员限读我们知识交付体系最核心的一张表是contents课程内容字段包括标题、正文、状态、创建人等。状态有四种草稿、审核中、已发布、已归档。针对这张表我们写了三组关键策略。-- 所有人都能读已发布内容这是知识交付的基础 create policy published_content_public_read on public.contents for select using (status published); -- 内容创作者只能操作自己的内容 create policy creator_manage_own_content on public.contents for all using (auth.uid() owner_id) with check (auth.uid() owner_id); -- 学员只能通过报名关系读取且不能读草稿 create policy enrolled_user_read_published on public.contents for select using ( auth.uid() in ( select user_id from enrollments where content_id contents.id ) and status published );这三条策略覆盖了 90% 的访问场景。管理者可以通过 Supabase 控制台或者 service_role 密钥管理一切但普通应用请求永远走 RLS。这里有个关键认知规则一旦下沉到数据库应用层的接口就变成了翻译器而不是决策器。我特意提一个容易踩的坑RLS 开启之后连 PostgreSQL 自带的 service_role 也会被部分影响只有超级权限才能绕过。开发时我们一度用 service_role 调试接口发现策略完全没生效还以为写错了后来才搞清楚是角色差异。2.3 试读、有效期和审核预览三条复杂的实战策略基础的读、写策略搞定之后真正的考验来自业务规则。第一个是试读用户只能看每门课前两节第二个是课程有有效期第三个是审核员可以预览但学员不行。试读的写法是用窗口函数判断内容在课程内的序号create policy trial_user_read_first_two_sections on public.contents for select using ( status published and row_number() over ( partition by course_id order by sort_order ) 2 );有效期用expires_at字段配合now()判断审核预览则靠额外的一张review_assignments表。这些策略组合起来之后原来的 14 个接口权限判断变成了数据库里十几条声明式策略。最直接的收益是产品再提加一种访问角色时我不再需要翻代码找所有接口而是加一条策略。提示RLS 策略一定要在本地 Docker 版本的 Supabase 里反复验证生产环境调试很多信息是被隐藏的。我们当时遇到策略写得过宽导致数据泄露风险的告警就是因为本地没测全。3. 推送链路的重塑从轮询和自建WebSocket到数据库事件驱动3.1 旧推送方案为什么费力不讨好老系统的知识下发依赖两条路列表页轮询 自建 WebSocket 推送。列表页每 30 秒请求一次最新内容列表高峰期一栋楼里几百个用户同时开页面接口压力全在列表查询上。自建 WebSocket 更麻烦要维护连接状态、心跳、断线重连、消息可靠投递还经常出现服务重启丢消息的问题。Supabase 的 Realtime 模块把这件事简化了它订阅 PostgreSQL 的 WAL预写日志表数据一旦变化就把变更事件广播给订阅了相关 channel 的客户端。发布于我们而言本质就是一次 UPDATE——把内容状态从审核中改成已发布。这一下发布动作本身天然就是一个推送信号。3.2 用触发器把状态变更变成业务事件为了让推送更语义化我们加了一张notify_events表并且用触发器在内容发布时自动插入一条事件记录。create function notify_on_published() returns trigger as $$ begin if new.status published and old.status published then insert into notify_events (content_id, event_type, occurred_at) values (new.id, content_published, now()); end if; return new; end; $$ language plpgsql; create trigger trg_notify_on_published after update on public.contents for each row execute function notify_on_published();客户端订阅notify_events这张表的变更收到事件后只做一件事按 content_id 去拉取最新内容。这里有个实践细节——广播的应该是信号而不是内容本身。Realtime 消息体大小有限制而且把整篇大文章塞进推送里客户端解析成本也高。正确做法是推送哪篇内容更新了客户端再走 PostgREST 读取。RLS 在这里还有一层妙用订阅者只能收到自己有权限读的事件。因为 Realtime 的 channel 鉴权基于 RLS 的 select 策略没权限的用户根本收不到对应事件不需要我们在应用层再过滤一遍。3.3 边缘函数接管的异步任务旧系统里还有一类需求天生适合边缘函数生成结课证书、内容摘要、定时统计。以前这些要么写成 Spring Boot 的后台任务要么丢进消息队列。现在用 Supabase Edge Functions基于 Deno 运行时处理直接部署在边缘节点上。以结课证书为例学员完成课程后前端调用 Edge Function函数里查询完成记录调用存储服务生成证书图片再把证书地址写回数据库。整个过程用户感知是毫秒级不需要我们再维护额外的任务队列服务。需要提醒的是Realtime 在生产环境要关注连接数和消息频率。免费层有并发连接限制我们当时上线运营活动时几百个用户同时刷新一度触发了连接数上限。后面在客户端做了 channel 复用和退避重连策略才缓解。4. 数据模型重构中的几个反直觉决策视图当接口状态当约束4.1 视图即对外契约表结构随便改这是这次重构里我自己觉得最值的一刀。以前我们直接暴露contents表给应用层后来需求一多表字段不断增加有了body_html又加了body_json有了sort_order又加了priority。每次加字段前端都会误用内部字段后端还得去解释。重构后我们立了一条规矩应用层只能访问视图不能直接碰基表。核心视图如下create view content_catalog_v as select id, title, summary, status, course_id, sort_order, published_at from contents where is_deleted false;这个视图就是给前端和 PostgREST 看的对外接口。底层表随便加字段、做拆分、改索引只要视图的输出稳定前端一行代码都不用动。数据库的关系模型跟面向服务的接口设计在这里合并了表是内部实现视图是公共契约。4.2 用数据库约束替代应用层枚举老系统里的角色和状态全靠 Java 枚举类管理部署新枚举要重启服务、发新包。重构后我们把这些改成数据库层面的约束角色用varchar加check约束值集合集中管理内容状态同样用check约束保证脏数据进不来权限规则用lookup表表达RLS 策略 join 这张表判断用户具备哪些权限。比如user_roles表的设计解决了 JWT 角色信息更新不及时的问题。JWT 里存的角色在签发后就固定了如果一个人刚被提升为课程助理要等 token 刷新才生效。改为 RLS 策略里 joinuser_roles表权限变更秒级生效这是我们踩过坑之后才改的。4.3 软删除和部分索引RLS 策略的底层支撑老系统的删除是物理删除重构后改成了软删除——因为 RLS 策略里要判断is_deleted false。这个改动让所有查询都多了一个过滤条件为了性能我们创建了部分索引create index idx_contents_active on contents (id, status) where is_deleted false;RLS 策略和索引的配合是个精细活。策略过滤的字段如果没有索引支撑复杂查询会全表扫描。特别是 enrollments 表这种高频 join 对象必须建立 (user_id, content_id) 的复合索引。我把重构前后的核心设计差异整理成一张表维度旧设计新设计权限控制应用接口 if 判断数据库 RLS 策略对外接口手写 REST 接口PostgREST 视图删除方式物理删除软删除 部分索引角色来源JWT 固定声明user_roles 表实时 join状态管理Java 枚举check 约束 触发器推送机制自建 WebSocketRealtime notify_events这张表基本就是整个重构的缩影一切都在往数据库更厚、应用更薄的方向走。5. 灰度迁移实录影子流量、双写、以及六个绕不开的坑5.1 四阶段的平滑迁移路径真正的重构不是推翻重来而是让老系统像换血一样逐步过渡。我们分了四个阶段阶段一影子读流量。新体系搭建完成后把老系统的一部分只读查询复制一份到新体系跑比对返回结果。这个阶段新体系不承接真实请求纯粹验证数据一致性和策略正确性。阶段二读写双写。内容发布时同时写老库和新库学员查询逐步切换到新体系。我们通过开关控制切流比例先放 10% 的学员流量观察一周再逐步放大。阶段三写流量切换。内容创作者和管理员的写操作切到新体系老系统变成只读状态保留数据以备回滚。阶段四老系统下线。观察两周无异常后关停老系统迁移完成。整个过程中supabaseCLI 帮了大忙。本地开发用 Docker 起一套 Supabase代码改动通过supabase db diff生成迁移 SQL再supabase migration up应用到云端。数据库变更纳入版本管理之后团队再没有出现过本地和线上表结构不一致的情况。5.2 六个必须在生产环境之前解决的坑迁移过程中我们踩了不少坑这里挑六个最典型的列出来问题现象根因解决方案Realtime 连接在 JWT 过期后仍保持token 有效期和 WebSocket 生命周期没对齐客户端定时校验 token过期主动断开重连PostgREST 列表接口默认只返回 1000 行默认 max-rows 限制所有列表查询显式指定分页参数权限变更要等 token 刷新才生效JWT 角色声明固定改为 join user_roles 表动态判断本地 Docker Supabase 和云端版本不一致CLI 版本漂移锁定统一版本号升级走变更评审更新通知表触发器反向触发内容表更新触发器递归调用在触发器函数里加 pg_trigger_depth() 保护复杂 RLS 策略查询变慢策略 join 表缺索引对 enrollment 等高频表建复合索引其中触发器递归那个问题最隐蔽。我们给notify_events表加了触发器之后某次更新内容时发现写入事件居然触发了内容表自己的更新导致死循环。查了半天才明白是触发器函数里间接更新了内容表用pg_trigger_depth()做了一层保护才压住。注意任何涉及 RLS 策略的变更都必须先在无权限匿名用户普通学员创作者管理员四类身份下分别测试。我们团队后来把这种测试固化成了发布清单每次改策略都要过一遍。6. 重构之后团队如何重新理解全栈知识交付6.1 角色的变化前端开始直面数据后端转向策略治理重构完成后最大的变化是前端同学现在直接用supabase-js查询视图后端同学的工作重心从写 CRUD 接口变成了设计视图契约、编写 RLS 策略、维护数据迁移脚本。团队里一开始有很多人抵触觉得这样让前端碰数据库太危险。但我们的规矩很明确视图是唯一入口基表的写操作只能通过 Edge Function 封装前端拿到的只是恰好够用的数据。后端业务代码量减少大约 70%这不是夸张。原来 14 个接口的权限判断变成了几十行 RLS 策略原来要写的列表分页排序逻辑PostgREST 原生支持原来要维护的消息推送服务现在一个触发器加一张事件表就完成了。6.2 知识交付体系的价值迁移从应用功能到数据生态这次重构让我想明白了一件事知识交付体系真正该交付的不是一堆接口而是任何人在任何时刻都能按自己的身份获得恰好的知识。这个目标靠应用层代码堆是堆不出来的因为应用层代码总有漏改、漏判的地方。而当规则被写进数据的每一行、每一次变更、每一条推送事件时知识的流动本身就变成了系统的默认行为。当然Supabase 也不是万能的。如果你的业务有大量复杂事务编排、海量离线报表、或者强一致性的跨服务分布式事务那还需要谨慎评估。我们当时也保留了少量 Edge Function 来封装复杂的写操作没有完全一刀切。6.3 最后分享一个延续性的实用技巧写在最后。重构完成后我们维护了一套内容发布自检的 Edge Function运营人员在后台点发布之前函数会自动执行三条检查——内容状态是否为已发布、RLS 策略是否允许目标学员读取、notify_events 是否正常插入。任何一条不通过就不允许发布。这个小工具上线之后发布成功但学员看不到的问题从每周好几起降到了零。如果你也在做类似的知识交付体系我建议你在重构完成后也补上这样一层自查机制它比任何监控都更贴近业务本身。
阅读完成 · 觉得有帮助?
咨询建站