你遇到过这种情况吗:用户注销账号后,业务方却反复叮嘱“别删数据,删了我们就查不到历史了”;可如果完全不删,任何有查询权限的人,都能随手翻出对方几年前的敏感记录。前一种要求是数据保留,后一种要求是数据安全,两者放在一起,几乎像是要你造一个“薛定谔的删除”。我去年处理类似需求时,最初想到的是加一个 deleted_at 字段,后来又改成视图,最后真正帮我解决问题的,是关系型数据库本身的行级安全(Row-Level Security,简称 RLS)。这篇文章就围绕一句话展开:如何用 RLS 让过去的数据,按照特定时间窗口,从不同角色的眼前逐渐“消失”。
1. 数据“被遗忘”不是删库,是重新定义可见性
“被遗忘”这个说法很容易让人误解。业务方说“让老数据消失”,很多开发的第一反应是物理删除,第二反应是软删除,但真正合适的方案往往是第三种——权限层不可见。
1.1 三种“删除”的语义差别
物理删除最简单,执行 DELETE 把行删掉,表空间回收。问题也最直接:历史没了,审计没了,如果后续要回溯某个时间段的业务细节,你只能面对一个空洞。
软删除是团队里最常见的做法,加一个 deleted_at 字段,默认查询条件带上AND deleted_at IS NULL。它解决了“恢复”问题,但带来两个新问题:一是所有查询入口都得记得这个条件,一是漏写一次条件,老数据就全出来了。更关键的是,软删除做不到“选择性遗忘”,它让所有能看到这张表的人都看不到被删行,无论你是普通运营还是审计员。
RLS 走的是完全不同的思路:数据原封不动留在表里,但数据库在返回结果之前,自动把不符合策略的行拦掉。对普通用户来说,三个月前的订单就像从来没存在过;对分析师来说,半年前的明细开始淡出视野;对审计来说,一切照旧。同样是“删除”需求,RLS 给了你一个“按角色、按时间、按条件进行不同遗忘”的能力。
三种方案的对比如下:
| 方案 | 数据是否保留 | 可恢复性 | 按角色差异化 | 运维复杂度 | 绕过风险 |
|---|---|---|---|---|---|
| 物理删除 | 不保留 | 不可恢复 | 无意义 | 低 | 无 |
| 软删除 | 保留 | 可恢复 | 不支持 | 中 | 高,漏条件就失效 |
| RLS 策略 | 保留 | 可恢复 | 支持 | 中高 | 低,策略由数据库强制 |
1.2 为什么这个需求总在联调阶段才冒出来
我复盘过几次项目,发现“让历史数据不可见”的需求几乎从不在需求文档的前三页出现。产品经理会说“用户注销后要删掉他的数据”,开发听成了“DELETE FROM users WHERE id = ?”,上线前做安全评审,法务或运维才跳出来说:不能真删,审计要留底,对方要申诉还得复核原始记录。
这时候大家才开始寻找“既保留又不可见”的方案,时间往往已经很紧。如果一开始就意识到,所谓“删除”本质上是一个可见性控制问题,就不会在项目末期陷入重写查询逻辑的被动局面。RLS 的价值正在于此:它把规则内聚在数据库层,业务代码不需要为“谁该看到什么”操心,只需要约定好角色和时间窗口。
2. RLS 到底做了什么:一条查询背后的策略改写
RLS 的机制可以用一句话概括:你执行一条 SELECT 或 UPDATE,数据库在真正扫描表之前,会根据策略自动给你追加一层过滤条件。这层过滤不是应用层拼 SQL 拼出来的,而是数据库强制执行的安全边界。
2.1 行级安全的最小原理
在 PostgreSQL 里,行级安全的载体是策略(Policy)。一张表开启行级安全后,所有访问都会经过策略判断,策略的USING表达式负责过滤已有行,WITH CHECK负责校验新插入或更新后的行。也就是说,USING管你能看到什么,WITH CHECK管你能写进来什么。
策略还分PERMISSIVE和RESTRICTIVE两种,默认是PERMISSIVE。这里有个容易混淆的点:多个PERMISSIVE策略之间是 OR 关系,也就是满足任意一个就能通过;RESTRICTIVE策略之间以及和其他策略之间是 AND 关系,必须全部满足。这个区别很关键,我第 4 节会专门讲踩坑经历。
另外,表所有者默认不受 RLS 限制,超级用户也不受限制。想让 owner 也被策略约束,必须对表执行FORCE ROW LEVEL SECURITY。这个设计本身是合理的,毕竟 DBA 总要能管理数据,但也导致很多人测试时发现“策略没生效”,其实是自己的账号绕过了策略。
2.2 第一次实现:完整可跑的最小演示
只看定义不够直观,我先把最简单的场景跑通。假设有一张订单表,销售角色只想看最近 30 天的订单,审计角色需要看全部。
CREATE TABLE orders ( id bigint PRIMARY KEY, owner_name text NOT NULL, amount numeric NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); INSERT INTO orders (id, owner_name, amount, created_at) SELECT n, 'user_' || (n % 5), random() * 1000, now() - (n || ' days')::interval FROM generate_series(1, 100) AS n;创建两个角色,并授予查询权限:
CREATE ROLE sales; CREATE ROLE auditor; GRANT SELECT ON orders TO sales, auditor;开启行级安全,并创建策略:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY orders_sales_policy ON orders FOR SELECT TO sales USING (created_at >= now() - interval '30 days'); CREATE POLICY orders_auditor_policy ON orders FOR SELECT TO auditor USING (true);然后用SET ROLE模拟不同身份查询:
SET ROLE sales; SELECT count(*) FROM orders; RESET ROLE; SET ROLE auditor; SELECT count(*) FROM orders; RESET ROLE;正常情况,sales 看到的行数大概是 30 左右,auditor 看到的是 100。如果连这些数字都看不到,先检查角色是否有USAGE权限访问当前 schema,以及是否误用超级用户登录测试,这两个问题我后面也会展开。
2.3 为什么这比“在查询里自己加 WHERE”更可靠
有人会说,这跟应用层查询加个WHERE created_at >= now() - interval '30 days'有什么区别?区别非常大。应用层过滤依赖每个调用方都遵守约定,但现实里数据出口远不止业务 API:报表工具直连、临时数据导出、SQL 命令行排查、给外部系统开只读账号。任何一个入口漏了条件,权限边界就破了。
RLS 把规则放在数据库内部,任何客户端、任何工具、任何拼 SQL 的方式,都无法绕开策略。它像门禁系统而不是门卫——门卫会打盹,门禁不会。对于“数据逐渐被遗忘”这类合规诉求,强制性和统一性比什么都重要。
3. 按角色和时间制造“遗忘”:一个可运行的完整示例
理解了最小机制,接下来做一个更贴近真实业务的方案:不同类型的角色,对同一张表的数据有不同的“遗忘速度”。
3.1 设定三档可见性
我以一张操作日志表为例。普通前端用户能看到最近 30 天的日志;业务分析师可以看到 180 天的;审计角色保留全部历史。
| 角色 | 可见时间窗口 | 语义 |
|---|---|---|
| app_user | 30 天以内 | 30 天前的事在普通用户眼里已经“被遗忘” |
| analyst | 180 天以内 | 半年前的数据逐渐淡出分析视野 |
| auditor | 全部 | 审计永远保留完整的历史痕迹 |
“逐渐”这个词就落在这里:随着时间推移,同一行数据对不同角色而言,会在不同时刻变得不可见。不是我手动去删某条记录,而是时间本身推动策略生效。
3.2 把规则写进策略函数
为了让规则集中管理,我先写一个策略函数,再把策略指向它。注意函数里引用的是传入的时间参数,而不是直接读取某一行数据:
CREATE OR REPLACE FUNCTION event_visible(p_event_time timestamptz) RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT CASE WHEN current_user = 'auditor' THEN true WHEN p_event_time < now() - interval '180 days' THEN false WHEN current_user = 'analyst' THEN true WHEN current_user = 'app_user' AND p_event_time < now() - interval '30 days' THEN false ELSE true END; $$;这个函数的判断顺序是:审计无条件可见;所有角色超过 180 天都不可见;分析师在 180 天内可见;普通用户只能看 30 天内。对普通用户来说,超过 30 天但不足 180 天的数据在后面的分支会被拦住,超过 180 天则在更早的分支被拦住,结果是一致的。
然后建表、插入模拟数据、挂策略:
CREATE TABLE event_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_time timestamptz NOT NULL DEFAULT now(), actor_id text NOT NULL, detail text NOT NULL ); INSERT INTO event_log (event_time, actor_id, detail) SELECT now() - (n || ' days')::interval, 'user_' || (n % 10), 'detail_' || n FROM generate_series(1, 400) AS n; ALTER TABLE event_log ENABLE ROW LEVEL SECURITY; CREATE POLICY event_log_time_policy ON event_log AS PERMISSIVE FOR SELECT TO app_user, analyst, auditor USING (event_visible(event_time)); GRANT SELECT ON event_log TO app_user, analyst, auditor; GRANT USAGE ON SCHEMA public TO app_user, analyst, auditor;注意最后两行授权,少了 schema 的使用权限,角色连表都碰不到,策略设得再对也白搭。
3.3 测试与隐藏收益
测试方法跟前面的最小演示一样,依次切换角色查询:
SET ROLE app_user; SELECT count(*) AS cnt, max(event_time) AS newest FROM event_log; RESET ROLE; SET ROLE analyst; SELECT count(*) FROM event_log; RESET ROLE; SET ROLE auditor; SELECT count(*) FROM event_log; RESET ROLE;我实际跑出来的结果是:app_user 看到约 30 行,analyst 看到约 180 行,auditor 看到 400 行。每一类角色拿到的数据视图都不同,但物理表只有一张。
这套设计的隐藏收益有三个:第一,数据没有移动,没有复制,不存在多个副本之间的同步问题;第二,业务代码完全不用感知规则,新接入的数据消费方天然继承策略;第三,时间窗口调整只是改函数或改参数,不用做数据迁移。将来某天业务方说“普通用户改成 60 天”,一行配置就能完成。
4. 我踩过的坑:策略叠加、函数索引与授权边界
RLS 并不是配置完就万事大吉。我在这条路上踩过几个不小的坑,每一个都花了几个小时排查,写出来帮你绕开。
4.1 两个 PERMISSIVE 策略不是“同时满足”而是“满足其一”
第一次做多角色策略时,我想当然地以为多个策略是“同时约束”,结果不是。两个PERMISSIVE策略之间是 OR 关系,只要命中其中一个,行就会被放行。
举个具体例子:我想限制普通用户“只能看到自己的订单”且“只能看到 30 天内的订单”,于是写了两条策略,一条USING (owner_name = current_user),一条USING (created_at >= now() - interval '30 days')。实际效果是:用户能看到自己所有的历史订单,因为第一条命中;也能看到 30 天内所有人的订单,因为第二条命中。这等于把权限放得更开了。
要同时满足两个条件,正确做法是把条件写进同一条策略的 USING 表达式,用 AND 连接;或者把其中一个做成RESTRICTIVE策略,强制取交集。排查的时候如果发现“数据变多了”,先检查是不是策略被 OR 合并了。
4.2 策略函数与大表性能
第二个坑是性能。RLS 的策略表达式会作用于表的每一行,这意味着函数越重,查询越慢。我第一次实现时图省事,把策略函数写成了 PL/pgSQL 函数,里面还有一段去查另一张配置表的逻辑。结果在 10 万行的表上,一条本来几十毫秒的查询跑了几秒钟。
根因是 PL/pgSQL 函数在不满足内联条件时,无法被查询规划器展开,只能一行一行调用。而纯 SQL 语言函数有机会被内联,让event_time上的索引参与过滤。所以策略函数我强烈建议优先写成LANGUAGE sql的简单表达式,不要在函数里做复杂的跨表查询。如果业务规则实在复杂,提前在表上冗余一个“是否可见”字段并建索引,让策略退化成简单的列比较,这是我在生产环境里最常用的手段。
4.3 超级用户和 owner 都“看不见”RLS
第三个坑相当隐蔽。我用超级用户登录测试,怎么查都能看到全部数据,一度以为是策略没生效。折腾了半天才反应过来:超级用户和表 owner 默认不受 RLS 约束。
想验证策略是否生效,最可靠的方式是创建普通角色,SET ROLE切换过去再查询。如果确实需要约束 owner,执行ALTER TABLE event_log FORCE ROW LEVEL SECURITY;让 owner 也必须走策略。这会在日常维护时增加一些不便,但安全要求严格时值得。另外一个关联坑是备份工具,比如pg_dump通常以超级用户运行,会绕过 RLS 导出全量数据。策略能防住应用用户,防不住有备份权限的运维,这个问题要在权限评估时单独考虑。
4.4 RLS 不解决列级脱敏
RLS 只管“哪些行能看到”,不管“哪些列能看到”。有些人误以为在策略里做点文章,就能把某个敏感字段自动替换成脱敏值,这是做不到的。
列级脱敏要靠别的机制,比如建一个脱敏视图,配合列的GRANT权限,或者用专门的数据脱敏中间件。我见过一个项目,把身份证号、手机号放在同一张宽表里,指望 RLS 把敏感字段藏起来,最后只能另建视图解决。所以一开始设计表结构时就要想清楚:行权限用 RLS,列级敏感信息用视图或单独的字段权限,二者是互补而不是替代。
5. 不建 RLS 会怎样:替代方案与它们的天花板
在引入 RLS 之前,我认真评估过几种常规方案。它们不是不能用,只是各有天花板,理解这些边界才能知道 RLS 到底解决了什么问题。
5.1 视图过滤
视图过滤是很多人第一时间想到的方案。建一个active_orders视图,里面对应WHERE condition,然后把基表权限收回,让应用只能查视图。分层清晰,实现简单,规模小的时候够用。
但视图方案有两个软肋。一是约束不强制,只要某个角色仍然持有基表的 SELECT 权限,就能绕过视图直接查底表,权限管理稍一松懈就破防。二是维护成本高,每个角色、每个可见性策略都要对应一套视图,策略一旦变化,视图跟着改,时间久了视图数量会失控。严格来说,这已经是在用应用层思路模拟 RLS,只适合静态规则。
5.2 应用层过滤
应用层过滤是“一人一条 WHERE”的路子。在 ORM 里写好默认过滤条件,或者在 DAO 层统一拼 SQL。小团队、单服务、数据出口少时,这套方案工作效率最高,排查问题也直观。
问题出在数据出口变多之后。BI 报表直连、离线数仓同步、客服后台单独拉数、给第三方开的数据接口,这些入口分散在不同系统里,很难统一执行同一套过滤逻辑。任何一处的权限判断出现偏差,老数据就从那个口子漏出去。应用层过滤本质上靠“每个人都不犯错”来维持安全,这在权限治理上是最危险的假设。
5.3 分区归档
分区归档针对“时间遗忘”很自然。把表按月份做分区,旧分区迁移到归档表,回收查询权限,过期的数据自然不可见。从数据库运维角度看,这个方案思路清晰且性能好,查询时还能借助分区裁剪。
但它处理不了“同一批数据对不同角色有不同遗忘时间”的需求。普通用户只能看 30 天、分析师能看 180 天、审计看全部,如果按分区回收权限,权限粒度是“这个分区谁能读”,根本表达不了 30 天和 180 天的差异。硬要做,就得拆出多张视图或多套权限体系,复杂度成倍上升,已经失去分区方案原本简洁的优势。
5.4 RLS 不擅长的场景
说了这么多 RLS 的优势,也要说清楚它的边界。低延迟高吞吐的写入场景,策略函数会给每一条访问增加判断开销,可能成为瓶颈。复杂跨表策略,比如根据另一张统计表的实时数据判断当前行是否可见,每次查询都会产生大量附加查询,性能很难压下来。团队里如果没有熟悉数据库权限体系的角色,策略出错时的排查成本也明显高于普通业务代码。
所以 RLS 适合的是“规则集中、角色多样、数据出口多、可见性需要动态变化”的场景。反过来说,如果只有单一应用在访问单一数据库,写个视图就是最优解。
6. 数据治理经验:脱敏、审计与灰度发布
最后聊几件 RLS 之外但必须一起做的事情。策略上了生产,只是开始。
6.1 给“遗忘”加一层保险
我建议在任何启用 RLS 的表旁边,单独建一张审计记录表,记录策略变更、时间窗口调整、甚至是可疑的越权查询尝试。理由很简单:RLS 让数据“看不见”以后,业务方反而更关心“它到底还在不在”。如果没有审计,将来回答不了“某个时间段谁能看到这笔数据”这类问题。
审计表本身的权限同样要收紧,最好只有审计角色和 DBA 能读,否则记录越权行为的日志本身就变成新的泄露点。我在实践中是把审计表放在独立 schema 下的,日常查询默认不开放。
6.2 时间窗口怎么调
“逐渐被遗忘”的窗口不是一成不变的。业务方可能今天说 30 天,过两个月说要变成 90 天。直接在生产库改策略函数风险很高,一旦写错,要么数据提前暴露,要么正常用户看不到该看的。
我的习惯是在测试环境复制一份数据和角色,用EXPLAIN ANALYZE验证新策略的执行计划,再切到生产。生产上先让一个非核心角色生效,观察几天查询反馈,确认没有异常后逐步放开。如果你有专门的参数配置表,也可以把时间窗口放到配置表里,策略函数每次读取。但要注意这是以牺牲部分性能为代价的,表数据量小可以考虑,大表不建议。
6.3 我的判断标准
经过这一轮实践,我现在判断一张表要不要上 RLS,标准很简单:能不能数出三个以上需要不同可见性规则的数据消费方?这些消费方是否都直连同一个数据库?如果两个回答都是肯定的,RLS 就是最省心的选择;如果只是“某张报表要用”,写个视图就够了。
这套机制上线后,最让我安心的一点是,业务方随时可以问“这个数据现在谁能看到”,而我只需要把策略函数打出来给他们看。数据还是在原地,但在不同角色的世界里,它已经在按照时间线慢慢退场。这种“被遗忘”,不是物理世界的删除,却是权限世界里最接近遗忘的一种状态。