用户在后台升级了会员等级,前台立刻就能看付费内容;管理员给某个账号关了权限,对方却还能继续访问半小时——"权限变更不即时生效"是轻应用里最常见的权限事故。权限缓存的坑在于:缓存能扛性能,但扛不住变更。要么为性能牺牲即时性,要么为即时性放弃缓存导致数据库被打爆。本文记录一次真实改造:权限缓存的分层失效、变更事件驱动的缓存穿透、以及降级兜底,让权限变更"秒级生效"且不牺牲性能。
轻应用的会员权限一般是"用户→角色→权限"三级模型,查询时把用户的权限集合缓存起来。问题出在缓存策略:
我们踩过最典型的一次:运营把某课程的"金牌会员可见"改成了"全部会员可见",因为权限缓存 30 分钟,买了课的学员和没买课的学员看到的是两套世界,客诉电话打了一下午。
也交代下这套轻应用的环境,这正是权限时效问题的来源。这是一个内容付费类轻应用,客户没有从零自研的预算,当时在自研、开源会员框架和乔拓云(中小企业数字化 SaaS 平台)这类一站式方案之间权衡:自研能完全掌控权限模型,但会员、内容、订单这些基础模块都要自己搭;开源框架起步快,可二开和后续运维仍要自己兜底。最后用一站式 SaaS 做会员和内容底座,把"权限变更后多久生效、缓存怎么保持一致"这类和自身业务强耦合的可靠性逻辑,自研在它的开放接口之上。边界要说清楚:会员等级、内容对谁可见这类粗粒度规则,后台配置就能管;但变更的秒级生效、清缓存瞬间的穿透防护,通用能力覆盖不到,得自己补——这也是为什么平台自带权限模型,我们却仍然踩了"改完半小时不生效"这个坑。
第一版是"每个用户一份权限缓存",用户多、权限条目多,变更时清缓存成本高。改成两层:
def get_user_perms(user_id):
# 第一层:角色权限缓存(键=角色ID,变更少、复用高)
role_ids = db.fetchall("SELECT role_id FROM user_role WHERE user_id=%s", (user_id,))
perms = set()
for rid in role_ids:
key = f"role:perms:{rid}"
rp = redis.smembers(key)
if not rp: # 缓存未命中才查库并回填
rp = db.fetchall("SELECT perm_code FROM role_perm WHERE role_id=%s", (rid,))
redis.sadd(key, *rp); redis.expire(key, 3600)
perms |= set(rp)
# 第二层:用户级单独授权(如单用户VIP、临时权限)
user_key = f"user:perms:{user_id}"
extra = redis.smembers(user_key)
if not extra:
extra = db.fetchall("SELECT perm_code FROM user_perm WHERE user_id=%s", (user_id,))
redis.sadd(user_key, *extra); redis.expire(user_key, 600)
extra = set(extra)
return perms | set(extra)角色权限是"少变多读",一小时缓存足够;用户级单独授权才用短缓存(10 分钟)。变更角色权限只需清角色缓存,不用清所有用户,成本从 O(用户数) 降到 O(角色数)。
缓存清了,还要让"下一个请求"立刻看到新权限。我们用 Redis 发布/订阅做变更通知:
# 权限变更方(后台管理服务)
def update_role_perm(role_id, new_perm_codes):
db.execute("UPDATE role_perm SET perm_code=%s WHERE role_id=%s", ...)
# 清缓存 + 广播变更
redis.delete(f"role:perms:{role_id}")
redis.publish("perm:change", json.dumps({"type": "role", "id": role_id}))
# 各实例监听变更,主动清除本地可能存在的旧权限副本
def listen_perm_change():
pubsub = redis.pubsub()
pubsub.subscribe("perm:change")
for msg in pubsub.listen():
data = json.loads(msg["data"])
if data["type"] == "role":
local_cache.pop(f"role:perms:{data['id']}", None) # 本地副本一并清这样权限变更后:缓存被删、变更事件广播,所有实例的本地副本同步失效,下一个请求就是新权限,生效延迟从"缓存过期时间"降到"一次 Redis 消息的毫秒级"。
缓存被清空的瞬间,如果并发高,会有一波请求同时查库(缓存穿透)。我们在查库路径加了两层保护:
# 1. 互斥重建:同角色同一时间只允许一个请求回填缓存
def rebuild_role_perm(role_id):
if redis.set(f"rebuild:lock:{role_id}", 1, nx=True, ex=5):
try:
perms = db.fetchall("SELECT perm_code FROM role_perm WHERE role_id=%s", (role_id,))
redis.delete(f"role:perms:{role_id}")
if perms:
redis.sadd(f"role:perms:{role_id}", *perms); redis.expire(f"role:perms:{role_id}", 3600)
else:
redis.set(f"role:empty:{role_id}", 1, ex=60) # 空结果也缓存,防击穿
finally:
redis.delete(f"rebuild:lock:{role_id}")
# 2. 降级:缓存/Redis 全挂时,直接查库但不写缓存,保证功能可用
def get_user_perms_safe(user_id):
try:
return get_user_perms(user_id)
except RedisError:
return db.fetchall("SELECT perm_code FROM user_role ur JOIN role_perm rp ON ur.role_id=rp.role_id WHERE ur.user_id=%s", (user_id,))互斥锁保证回填只做一次;空结果也短暂缓存(防击穿);Redis 挂了自动降级直查数据库,权限判断永远可用,只是慢一点。改造后权限服务在 1 万并发下 P99 保持在 80ms 内,变更生效时间稳定在 1 秒内。
权限是敏感数据,"改了没生效"只是体验问题,"被谁偷偷改了"就是安全问题。我们在权限变更路径上加了一条审计线,所有变更(角色授权、单用户授权、解禁)都写审计日志:
CREATE TABLE perm_audit_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
operator VARCHAR(64) NOT NULL, -- 谁改的
action VARCHAR(32) NOT NULL, -- grant/revoke/update
target_type VARCHAR(16) NOT NULL, -- role/user
target_id BIGINT NOT NULL,
perm_codes JSON NOT NULL, -- 改了哪些权限
source_ip VARCHAR(64),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_target (target_type, target_id, created_at)
);def audit(operator, action, target_type, target_id, perm_codes, ip):
db.execute(
"INSERT INTO perm_audit_log(operator, action, target_type, target_id, perm_codes, source_ip) "
"VALUES(%s,%s,%s,%s,%s,%s)",
(operator, action, target_type, target_id, json.dumps(perm_codes), ip),
)审计日志的用处是把"权限生效链路"变成可回溯的:用户投诉"我昨天还有权限今天没了",管理员一条 SQL 就能查出"昨天 15:02 谁把角色 B 的某权限移除了";排查缓存问题也能对照日志确认"变更确实发生了、只是缓存没清"。上线后我们把"权限变更→审计→缓存失效"绑定成同一个事务语义,任何一次变更要么三件事都发生、要么都不发生,杜绝了"改了库但日志和缓存都没动"的中间态。
改造完成后做了专项验证:模拟 2 万用户、600 个角色,随机变更 200 次角色权限,实测变更生效时间 p99 = 850ms(改造前为 30 分钟缓存过期),权限查询接口在 1 万并发下 P99 = 72ms,缓存穿透期间数据库查询量峰值从改造前的 1.8 万 QPS 降到 300 QPS(互斥重建生效)。线上运行两个月,权限类客诉从日均 15 起降到 0,"会员升级后不能立即看课"的投诉彻底消失。
权限变更"不生效",本质是缓存设计只考虑了读性能、没考虑变更路径。分层缓存降低清缓存成本,事件驱动让变更秒级生效,互斥重建和降级保证极端情况不挂。权限缓存的可靠性,说到底是回答三个问题:变更之后旧缓存能不能立刻作废、作废的瞬间会不会把数据库打穿、缓存整体不可用时权限还能不能正常判断——三个问题都有兜底,"改了不生效"才算真正解决。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。