第7章:权限系统高可用与扩展——从"能用"到"扛得住"的进化之路
凌晨三点,你被电话叫醒。线上告警铺天盖地:权限服务响应时间从5ms飙升到2s,数据库连接池被打满,整条业务链路雪崩。你爬起来排查,发现不过是运营部门批量导入了一万个用户,权限校验的DB查询把连接池吃光了。你一边重启服务一边想:不就是加个权限校验吗,怎么就炸了?
这种故事我经历过不止一次。我是怕浪猫,一个在生产环境里被权限系统毒打过的Go后端工程师。从最早把权限逻辑直接写在业务代码里,到后来搭建了支持千万级用户、百万级TPS的权限中台,踩过的坑够填平一个西湖了。今天这一章,我把这些坑一个一个挖出来给你看,不是让你看个热闹,而是让你在遇到同样问题时能少走弯路。
这一章我们聊权限系统的高可用与扩展。不是那种"加个Redis就行了"的水文,而是从缓存策略、分布式同步、高可用架构、并发控制到变更通知的完整实战指南。每一个方案都是我用通宵换来的,每一段代码都经过生产环境的检验。你在网上能搜到很多权限系统的理论文章,但很少有人把生产环境里真正会遇到的那些边界情况掰开揉碎来讲。今天我来讲。
权限系统不是写出来的,是扛出来的。你以为"能用"就是终点,其实那只是修罗的起点。
一、权限缓存策略设计
1.1 为什么你的权限查询这么慢
先看一段很多团队都写过的代码,可能你现在的项目里就有类似的影子:
func CheckPermission(userID int64, resource string, action string) (bool, error) {
var perm Permission
err := db.Where("user_id = ? AND resource = ? AND action = ?",
userID, resource, action).First(&perm).Error
if err == gorm.ErrRecordNotFound {
return false, nil
}
return err == nil, err
}这段代码的问题太经典了:每次请求都查数据库,没有任何缓存。在低并发下它能完美运行,你写完跑一遍单元测试,绿灯通过,觉得自己写得挺好。一旦QPS上来,数据库就是第一个倒下的。
我做过一个统计:在一个典型的中后台系统中,一个用户的一次API请求平均触发3-7次权限校验。比如用户点一下"编辑文章"这个按钮,后端要校验:你有没有查看文章的权限?有没有编辑文章的权限?有没有管理标签的权限?有没有上传图片的权限?这些校验串起来就是4次DB查询。如果QPS是1000,每秒就有3000到7000次DB查询。而权限数据本身的变更频率极低,可能一天才改几次。这种"读多写少"的场景,天生就该用缓存。
但缓存不是万能药,用错了比不用还可怕。我曾经见过一个团队把权限缓存了但没设过期时间,结果管理员撤销了某用户的权限,那个用户在缓存过期前依然畅通无阻。安全漏洞,直接P0,CFO亲自过问的那种。还有团队缓存了权限但没考虑并发写的情况,导致同一个用户的权限在缓存中一会儿有一会儿没有,校验结果跟掷骰子一样,测试同学以为代码出了bug排查了两天,最后发现是缓存的竞态条件。更有甚者,把不同用户的权限缓存在了同一个key下(因为key拼接逻辑有bug),A用户的权限校验用的是B用户的数据,直接导致了越权访问。这些问题的根源不是缓存本身有问题,而是使用缓存的人没有想清楚一致性和并发问题。缓存引入的复杂度远比你想的高,但它带来的性能提升也是实打实的。关键在于:你要知道自己在做什么,以及做了之后可能产生什么后果。
缓存不是把数据搬个家,而是用一致性代价换性能。你得想清楚:这个代价你付得起吗?
1.2 多级缓存架构
经过多次迭代和踩坑,我最终采用的方案是三级缓存。三级缓存的核心思想是:越靠近请求的缓存越快但容量越小,越远离请求的缓存越慢但容量越大。每一级缓存承担不同的职责,互相配合。
架构如下:
请求 -> 本地缓存(LRU) -> Redis集群 -> 数据库
10秒TTL 5分钟TTL 持久化
进程内 跨实例共享 最终数据源第一级是进程内LRU缓存,用Go标准库的container/list就能实现。它的作用是挡住绝大部分重复请求,减少Redis的网络开销。你可能会问:Redis不是已经很快了吗?为什么还要本地缓存?答案是:Redis再快也有网络往返的延迟,在千兆网络下单次Round Trip大约0.2到0.5毫秒。如果你每次权限校验都要走Redis,4次校验就是1到2毫秒。加上本地缓存后,热路径上的4次校验降到0.05毫秒以内,提升了一个数量级。
第二级是Redis,作为跨实例的共享缓存。它的作用是在多实例环境下提供统一的缓存视图,避免每个实例都去查DB。第三级才是数据库,作为最终的数据源。
来看本地缓存的完整实现,这个实现我在生产环境跑了三年,稳得很:
package permission
import (
"container/list"
"sync"
"time"
)
type localCacheEntry struct {
key string
value bool
expireAt time.Time
}
type LocalLRUCache struct {
capacity int
mu sync.RWMutex
items map[string]*list.Element
list *list.List
hits int64
misses int64
}
func NewLocalLRUCache(capacity int) *LocalLRUCache {
return &LocalLRUCache{
capacity: capacity,
items: make(map[string]*list.Element),
list: list.New(),
}
}
func (c *LocalLRUCache) Get(key string) (bool, bool) {
c.mu.RLock()
if elem, ok := c.items[key]; ok {
entry := elem.Value.(*localCacheEntry)
if time.Now().Before(entry.expireAt) {
// 命中,先释放读锁
c.mu.RUnlock()
// 提升到链表头部需要写锁
c.mu.Lock()
c.list.MoveToFront(elem)
c.mu.Unlock()
atomic.AddInt64(&c.hits, 1)
return entry.value, true
}
c.mu.RUnlock()
// 过期了,异步删除避免阻塞读请求
go c.delete(key)
atomic.AddInt64(&c.misses, 1)
return false, false
}
c.mu.RUnlock()
atomic.AddInt64(&c.misses, 1)
return false, false
}
func (c *LocalLRUCache) Set(key string, value bool, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
// 如果key已存在,更新值并移到头部
if elem, ok := c.items[key]; ok {
entry := elem.Value.(*localCacheEntry)
entry.value = value
entry.expireAt = time.Now().Add(ttl)
c.list.MoveToFront(elem)
return
}
// 新建条目
entry := &localCacheEntry{
key: key,
value: value,
expireAt: time.Now().Add(ttl),
}
elem := c.list.PushFront(entry)
c.items[key] = elem
// 容量超限时淘汰最久未使用的条目
if c.list.Len() > c.capacity {
oldest := c.list.Back()
if oldest != nil {
oldestEntry := oldest.Value.(*localCacheEntry)
delete(c.items, oldestEntry.key)
c.list.Remove(oldest)
}
}
}
func (c *LocalLRUCache) delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
if elem, ok := c.items[key]; ok {
c.list.Remove(elem)
delete(c.items, key)
}
}
// 按前缀批量删除(用于用户级缓存清除)
func (c *LocalLRUCache) deleteByPrefix(prefix string) {
c.mu.Lock()
defer c.mu.Unlock()
for key, elem := range c.items {
if strings.HasPrefix(key, prefix) {
c.list.Remove(elem)
delete(c.items, key)
}
}
}
// 缓存统计信息,用于监控
func (c *LocalLRUCache) Stats() CacheStats {
c.mu.RLock()
defer c.mu.RUnlock()
hits := atomic.LoadInt64(&c.hits)
misses := atomic.LoadInt64(&c.misses)
total := hits + misses
hitRate := 0.0
if total > 0 {
hitRate = float64(hits) / float64(total)
}
return CacheStats{
Size: c.list.Len(),
Hits: hits,
Misses: misses,
HitRate: hitRate,
}
}这个实现有几个细节值得注意,每一个都是我用血泪教训换来的。
第一,Get操作先尝试用读锁,只有在需要移动链表节点时才升级为写锁。这样在高并发读场景下性能更好。如果Get全程用写锁,并发性能会下降60%以上。我做过基准测试,同样的100万次读取,全程写锁需要1.8秒,读写分离只需要0.6秒。
第二,过期缓存的删除是异步的,不阻塞读请求。虽然会短暂占用一点内存,但换来了更平滑的响应时间。同步删除虽然能立即释放内存,但在高并发下会导致读请求的P99延迟出现毛刺。
第三,容量超限时淘汰最久未使用的条目。这是LRU的核心逻辑,保证热点数据始终在缓存中。容量设置需要根据你的用户量和内存预算来定。我一般设置为10000个条目,按每个条目100字节算,大约占1MB内存,微不足道。
第四,增加了deleteByPrefix方法用于按前缀批量删除缓存。这在用户权限全量变更时非常有用,比如一个用户的所有权限都需要清除,直接按 perm:userID: 前缀删除即可。
第五,Stats方法返回缓存命中率等统计信息,这些信息对于监控和调优至关重要。如果你的本地缓存命中率低于30%,说明要么是容量不够,要么是TTL太短,需要调整参数。
本地缓存就像你的口袋,装不了多少东西,但拿起来最快。Redis像你的背包,容量大但要多花点时间。数据库就是你的仓库了,东西全,但取一趟够呛。
1.3 缓存键的设计
缓存键看起来是个小问题,但设计不好会导致缓存命中率低下甚至数据错乱。我在不同的项目中见过各种奇葩的缓存键设计:有人用MD5哈希值做key,调试的时候根本看不出是什么数据;有人用中文做key,Redis里看起来没问题但日志里全是乱码;有人忘了加命名空间,权限缓存和业务缓存混在一起,清缓存时误删了一大片。
我的缓存键设计方案如下,简洁明了,可读性好,且不会与其他业务的缓存冲突:
type CacheKey struct {
UserID int64
Resource string
Action string
}
func (k CacheKey) String() string {
return fmt.Sprintf("perm:%d:%s:%s", k.UserID, k.Resource, k.Action)
}
// 用户所有权限的集合键
func UserPermKey(userID int64) string {
return fmt.Sprintf("user_perms:%d", userID)
}
// 角色权限集合键
func RolePermKey(roleID int64) string {
return fmt.Sprintf("role_perms:%d", roleID)
}
// 用户在某资源下的所有action集合键
func UserActionKey(userID int64, resource string) string {
return fmt.Sprintf("user_actions:%d:%s", userID, resource)
}这里有个坑我踩过,花了半天时间才排查出来。一开始我用 user_id:resource:action 作为缓存键,看起来没毛病。但后来有个需求是"查某用户在某资源下所有可执行的action",比如查用户在"文章"资源下能做哪些操作(查看、编辑、删除?)。这条数据在缓存里是散落的,要全部捞出来就得遍历缓存,效率极低。
解决方案是增加一个集合缓存,用Redis的Set结构存储用户在某资源下的所有权限。这样查"用户在某资源下的所有action"只需要一次SMEMBERS操作:
// 获取用户在某资源下的所有权限
func (s *PermissionService) GetUserActions(userID int64, resource string) ([]string, error) {
// 先查集合缓存
setKey := fmt.Sprintf("user_actions:%d:%s", userID, resource)
actions, err := s.redis.SMembers(ctx, setKey).Result()
if err == nil && len(actions) > 0 {
return actions, nil
}
// 缓存未命中,查数据库
var perms []Permission
err = s.db.Where("user_id = ? AND resource = ?", userID, resource).Find(&perms).Error
if err != nil {
return nil, err
}
// 回写缓存
if len(perms) > 0 {
members := make([]interface{}, len(perms))
for i, p := range perms {
members[i] = p.Action
}
pipe := s.redis.Pipeline()
pipe.SAdd(ctx, setKey, members...)
pipe.Expire(ctx, setKey, 5*time.Minute)
_, _ = pipe.Exec(ctx)
}
return actions, nil
}缓存键的设计不是拍脑袋定的,而是由你的查询模式决定的。先列出所有的查询场景,再反推键的结构,这才是正确的顺序。
1.4 缓存穿透、击穿、雪崩的防护
这三个问题被称为缓存的"三板斧",每个做权限系统的人都必须面对。如果你在面试中被问到缓存相关的问题,基本就是这三个。但在生产环境中,这三个问题的危害程度远超面试题的想象。
缓存穿透:查询一个不存在的用户权限,缓存和DB都没有,每次请求都打到DB。攻击者可以用大量不存在的userID发起请求,直接打垮数据库。这听起来像是一个不太可能发生的场景,但我亲身经历过。有一次安全团队做渗透测试,用随机userID对权限接口做压测,几万个不存在的userID把DB连接池瞬间吃光。从那以后,我在所有权限查询入口都加了防护。
我的方案是布隆过滤器加空值缓存。布隆过滤器在内存中维护一个位数组,能以极低的开销判断一个key"一定不存在"或"可能存在"。对于权限系统来说,大部分不存在的查询会被布隆过滤器挡在缓存层之外:
type BloomFilter struct {
bitSet []uint64
size uint64
hashes int
}
func NewBloomFilter(size uint64, hashCount int) *BloomFilter {
wordCount := (size + 63) / 64
return &BloomFilter{
bitSet: make([]uint64, wordCount),
size: size,
hashes: hashCount,
}
}
func (bf *BloomFilter) Add(key string) {
h1 := fnv1aHash(key)
h2 := fnv1aHash2(key)
for i := 0; i < bf.hashes; i++ {
pos := (h1 + uint64(i)*h2) % bf.size
wordIdx := pos / 64
bitIdx := pos % 64
atomic.OrUint64(&bf.bitSet[wordIdx], 1<<bitIdx)
}
}
func (bf *BloomFilter) MightContain(key string) bool {
h1 := fnv1aHash(key)
h2 := fnv1aHash2(key)
for i := 0; i < bf.hashes; i++ {
pos := (h1 + uint64(i)*h2) % bf.size
wordIdx := pos / 64
bitIdx := pos % 64
if atomic.LoadUint64(&bf.bitSet[wordIdx])&(1<<bitIdx) == 0 {
return false
}
}
return true
}
// 在权限校验入口处增加布隆过滤器检查
func (s *PermissionService) CheckWithBloom(userID int64, resource, action string) (bool, error) {
key := fmt.Sprintf("%d:%s:%s", userID, resource, action)
// 布隆过滤器快速判断
if !s.bloomFilter.MightContain(key) {
// 一定不存在,直接返回false
return false, nil
}
// 可能在,走正常缓存查询流程
return s.CheckWithCache(userID, resource, action)
}同时,对于DB查不到的情况,缓存一个空值(短TTL,比如30秒),避免同一条不存在的数据被反复查询。空值缓存的TTL要比正常缓存短,因为不存在的数据没必要长期占用缓存空间:
// 空值缓存的特殊标记
const NullMarker = "__NULL__"
func (s *PermissionService) CheckWithCache(userID int64, resource, action string) (bool, error) {
cacheKey := CacheKey{userID, resource, action}.String()
// 第一层:查本地缓存
if val, ok := s.localCache.Get(cacheKey); ok {
return val, nil
}
// 第二层:查Redis
val, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
if val == NullMarker {
// 空值缓存,说明DB中不存在
s.localCache.Set(cacheKey, false, 10*time.Second)
return false, nil
}
result, _ := strconv.ParseBool(val)
s.localCache.Set(cacheKey, result, 10*time.Second)
return result, nil
}
// 第三层:查DB
var perm Permission
err = s.db.Where("user_id = ? AND resource = ? AND action = ?",
userID, resource, action).First(&perm).Error
if err == gorm.ErrRecordNotFound {
// DB中不存在,缓存空值,短TTL
s.redis.Set(ctx, cacheKey, NullMarker, 30*time.Second)
s.localCache.Set(cacheKey, false, 10*time.Second)
return false, nil
}
if err != nil {
return false, err
}
// 回写缓存
s.redis.Set(ctx, cacheKey, "1", randomTTL(5*time.Minute))
s.localCache.Set(cacheKey, true, 10*time.Second)
return true, nil
}缓存击穿:某个热点key突然过期,大量请求同时打到DB。典型场景是管理员批量变更权限后,一大批缓存同时失效,紧接着海量请求涌入。
用singleflight来解决这个问题,保证同一个key只有一个goroutine去查DB,其他goroutine等待结果复用。这是Go语言里处理缓存击穿最优雅的方案,不需要额外的组件,标准库就支持:
import "golang.org/x/sync/singleflight"
type PermissionService struct {
db *gorm.DB
redis *redis.Client
localCache *LocalLRUCache
bloomFilter *BloomFilter
sfGroup singleflight.Group
}
func (s *PermissionService) CheckWithSingleflight(userID int64, resource, action string) (bool, error) {
cacheKey := CacheKey{userID, resource, action}.String()
// 先查缓存
if val, ok := s.localCache.Get(cacheKey); ok {
return val, nil
}
// singleflight保证同一个key只有一个goroutine查DB
result, err, _ := s.sfGroup.Do(cacheKey, func() (interface{}, error) {
// 再次检查缓存,可能在等待期间已被其他请求填充
if val, ok := s.localCache.Get(cacheKey); ok {
return val, nil
}
// 查Redis
val, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
if val == NullMarker {
s.localCache.Set(cacheKey, false, 10*time.Second)
return false, nil
}
result, _ := strconv.ParseBool(val)
s.localCache.Set(cacheKey, result, 10*time.Second)
return result, nil
}
// 查DB
hasPerm, err := s.queryPermissionFromDB(userID, resource, action)
if err != nil {
return false, err
}
// 回写两级缓存
if hasPerm {
s.redis.Set(ctx, cacheKey, "1", randomTTL(5*time.Minute))
} else {
s.redis.Set(ctx, cacheKey, NullMarker, 30*time.Second)
}
s.localCache.Set(cacheKey, hasPerm, 10*time.Second)
return hasPerm, nil
})
if err != nil {
return false, err
}
return result.(bool), nil
}singleflight的原理很简单:当多个goroutine同时请求同一个key时,只有第一个goroutine会真正执行查询函数,其他goroutine会阻塞等待。第一个goroutine查完后,结果会共享给所有等待的goroutine。这样即使有1000个请求同时打到同一个key,也只会有1次DB查询。
缓存雪崩:大量key同时过期,DB瞬间压力暴增。这跟缓存击穿的区别是规模——击穿是一个key,雪崩是一大批key。解决方案是TTL随机化,让每个key的过期时间分散开:
func randomTTL(base time.Duration) time.Duration {
// 在基础TTL上增加0-30%的随机偏移
jitter := time.Duration(rand.Int63n(int64(base) / 3))
return base + jitter
}
// 写缓存时使用随机TTL
s.redis.Set(ctx, cacheKey, "1", randomTTL(5*time.Minute))这个方案虽然简单,但效果显著。我做过一次压测:1000个key同时写入,如果不加随机TTL,5分钟后它们会同时过期,DB的QPS瞬间从0飙升到8000。加了30%的随机偏移后,过期时间分散在5到6.5分钟之间,DB的QPS峰值降到了2000,完全在可承受范围内。
三个问题,三套方案。但记住,所有的缓存防护都是在"一致性"和"可用性"之间做权衡。你得根据自己的业务场景找到那个平衡点,而不是照搬别人的代码。
1.5 缓存一致性保障
缓存有了,问题也来了:当权限变更时,怎么保证缓存和DB的一致性?这是一个经典的分布式系统问题,没有完美的答案,只有适合的方案。
我采用的是"先更新DB,再删除缓存"的策略(Cache Aside Pattern),配合延迟双删来处理并发场景。这个策略的核心思想是:让DB成为source of truth,缓存只是DB的副本,缓存出问题时以DB为准。
func (s *PermissionService) UpdatePermission(userID int64, resource, action string, allowed bool) error {
// 第一步:更新DB
err := s.db.Model(&Permission{}).
Where("user_id = ? AND resource = ? AND action = ?", userID, resource, action).
Update("allowed", allowed).Error
if err != nil {
return err
}
// 第二步:删除缓存
cacheKey := CacheKey{userID, resource, action}.String()
s.redis.Del(ctx, cacheKey)
s.localCache.delete(cacheKey)
// 第三步:延迟双删
// 为什么需要延迟双删?因为在并发场景下,可能有这样的时序:
// 1. 请求A删除缓存
// 2. 请求B查缓存发现miss
// 3. 请求B从DB读到旧值(因为A还没来得及更新DB)
// 4. 请求A更新DB
// 5. 请求B把旧值写入缓存
// 延迟双删就是在这个窗口期之后再删一次缓存
go func() {
time.Sleep(500 * time.Millisecond)
s.redis.Del(ctx, cacheKey)
s.localCache.delete(cacheKey)
}()
// 第四步:发布权限变更事件,通知其他实例清除本地缓存
s.eventBus.Publish(PermissionChangedEvent{
UserID: userID,
Resource: resource,
Action: action,
})
return nil
}为什么不用"先删缓存再更新DB"?因为在并发场景下,如果一个请求A刚删了缓存还没更新完DB,另一个请求B来读数据,B发现缓存空了就去查DB,查到的还是旧数据,然后把旧数据写入缓存。之后A更新了DB,但缓存里已经是旧数据了,直到缓存过期才会被纠正。这个窗口可能长达几分钟(取决于TTL),对于权限系统来说是不可接受的。
延迟双删虽然不能百分之百保证一致性,但在权限系统这个场景下已经足够了。毕竟权限变更不是高频操作,而且我们还有TTL兜底。即使延迟双删失败,缓存最多存活到TTL过期就会被清理。
完美的一致性是不存在的,就像完美的代码一样。你能做的只是把不一致的窗口缩到足够小,小到业务可以接受。
二、分布式权限同步方案
2.1 多实例环境下的缓存一致性
当你的服务从单实例扩展到多实例时,本地缓存就成了一个麻烦事。你在一个实例上更新了权限数据,清了自己实例的本地缓存,但其他实例的本地缓存里还是旧数据。用户下一次请求被路由到另一个实例,权限校验用的还是旧数据。
这个问题的本质是:本地缓存是进程隔离的,实例之间无法感知彼此的缓存状态。你脑子里的"删个缓存就行了",在多实例环境下就是"只删了一半"。
我经历过一次因为这个bug导致的事故。某用户因为安全原因被降权了,从管理员变成了普通用户。但服务刚好多实例部署,降权操作只清了一个实例的缓存。那个用户的请求恰好被路由到另一个实例,权限校验还是通过的,结果他在几分钟内用管理员的权限做了一系列操作,造成了一些数据混乱。虽然是小事故,但足以说明分布式缓存一致性的重要性。从那以后,我把多实例缓存同步作为权限系统的标配组件。
单机思维是分布式问题的万恶之源。你脑子里的"删个缓存就行了",在多实例环境下就是"只删了一半"。
2.2 基于消息队列的同步方案
最直接的方案是用消息队列广播缓存失效事件。每个实例订阅同一个topic,收到事件后清除自己的本地缓存。这个方案的好处是可靠——消息队列有重试机制,即使某个实例暂时不可达,消息也不会丢失。
type CacheInvalidationListener struct {
localCache *LocalLRUCache
consumer mq.Consumer
}
func (l *CacheInvalidationListener) Start() error {
return l.consumer.Subscribe("permission.cache.invalid", func(msg *mq.Message) error {
var event PermissionChangedEvent
if err := json.Unmarshal(msg.Body, &event); err != nil {
return err
}
// 清除本地缓存
cacheKey := CacheKey{
UserID: event.UserID,
Resource: event.Resource,
Action: event.Action,
}.String()
l.localCache.delete(cacheKey)
// 如果是用户级别的全量变更,清除该用户所有缓存
if event.AllPermissions {
l.localCache.deleteByPrefix(fmt.Sprintf("perm:%d:", event.UserID))
l.localCache.deleteByPrefix(fmt.Sprintf("user_perms:%d", event.UserID))
}
return nil
})
}但消息队列方案有个问题:它不是实时的。从发布事件到所有实例消费完成,可能有几百毫秒到几秒的延迟。这个延迟主要来自消息队列的投递延迟和消费者的处理延迟。在Kafka中,这个延迟通常是几十到几百毫秒;在RabbitMQ中可能更短,但仍然不是实时的。
在这段延迟时间内,不同实例的缓存状态可能不一致。对于权限系统来说,这个延迟是否可接受?答案是:看场景。
对于普通业务权限(比如能不能查看某个页面),几秒的不一致完全可以接受。用户晚几秒看到页面内容变化不会有什么影响。但对于高安全级别的权限(比如能不能执行资金操作、能不能删除数据),哪怕一秒的不一致都可能是事故。一个被撤销了删除权限的用户,如果在缓存同步的窗口期内碰巧删除了一条数据,那就是安全事故。
所以我在设计时区分了两种场景:普通权限用消息队列做异步同步,高安全权限用更实时的方案。
2.3 基于Redis Pub/Sub的实时同步
对于需要近实时同步的场景,我使用Redis Pub/Sub作为补充。Redis Pub/Sub是Redis内置的发布订阅机制,延迟极低,通常在毫秒级别。它的缺点是没有持久化保证——如果某个实例在消息发布时正好断线,它就收不到这条消息。但对于权限缓存失效这种操作,丢一条消息的后果只是缓存多保留了一会儿(直到TTL过期),不会造成数据错误。
type DistributedCacheSync struct {
localCache *LocalLRUCache
redis *redis.Client
instanceID string
}
func NewDistributedCacheSync(rdb *redis.Client, cache *LocalLRUCache) *DistributedCacheSync {
return &DistributedCacheSync{
localCache: cache,
redis: rdb,
instanceID: uuid.New().String(),
}
}
// 订阅缓存失效频道
func (d *DistributedCacheSync) Subscribe() {
pubsub := d.redis.Subscribe(ctx, "perm:cache:invalid")
go func() {
ch := pubsub.Channel()
for msg := range ch {
var event InvalidationEvent
if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil {
continue
}
// 忽略自己发出的消息,避免无效操作
if event.Source == d.instanceID {
continue
}
// 清除本地缓存
for _, key := range event.Keys {
d.localCache.delete(key)
}
}
}()
}
// 广播缓存失效
func (d *DistributedCacheSync) Broadcast(keys []string) error {
event := InvalidationEvent{
Source: d.instanceID,
Keys: keys,
Timestamp: time.Now().UnixMilli(),
}
payload, err := json.Marshal(event)
if err != nil {
return err
}
return d.redis.Publish(ctx, "perm:cache:invalid", payload).Err()
}这里有个细节:每个实例在广播消息时带上自己的instanceID,其他实例收到消息后先判断是不是自己发的,如果是就跳过。这看起来是一个不起眼的优化,但在高频率缓存变更的场景下能减少大量无效操作。
我的最终方案是"双保险":Pub/Sub做实时同步,消息队列做可靠兜底。即使Pub/Sub丢了几条消息,消息队列最终也会补上。同时,TTL作为最后一道防线,保证即使两个通道都失败了,缓存最终也会过期。三层保障,任何一层出问题都不会导致严重的一致性问题。
2.4 基于版本号的最终一致性
在更严格的场景下,比如金融级权限控制,我使用版本号机制来保障一致性。版本号机制的核心思想是:每个用户有一个权限版本号,每次权限变更时版本号递增。缓存数据的key中包含版本号,版本号变了,旧缓存自然不会被命中。
type VersionedPermission struct {
UserID int64
Version int64
Perms map[string]bool // resource:action -> allowed
}
type PermissionCacheStore struct {
redis *redis.Client
db *gorm.DB
}
// 获取用户权限版本号(极轻量的查询,只查一个整数字段)
func (s *PermissionCacheStore) GetUserVersion(userID int64) (int64, error) {
// 版本号也缓存在Redis中,变更频率极低
val, err := s.redis.Get(ctx, fmt.Sprintf("perm:version:%d", userID)).Int64()
if err == nil {
return val, nil
}
// 查DB
var user User
err = s.db.Select("perm_version").First(&user, userID).Error
if err != nil {
return 0, err
}
s.redis.Set(ctx, fmt.Sprintf("perm:version:%d", userID), user.PermVersion, 10*time.Minute)
return user.PermVersion, nil
}
// 带版本号的权限校验
func (s *PermissionCacheStore) Check(userID int64, resource, action string) (bool, error) {
// 获取当前版本号
currentVersion, err := s.GetUserVersion(userID)
if err != nil {
return false, err
}
// 查缓存,key中包含版本号
cacheKey := fmt.Sprintf("perm:v:%d:%d", userID, currentVersion)
val, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
if val == NullMarker {
return false, nil
}
perms := make(map[string]bool)
json.Unmarshal([]byte(val), &perms)
return perms[resource+":"+action], nil
}
// 缓存未命中,查DB并构建全量权限map
perms, err := s.loadUserPermissions(userID)
if err != nil {
return false, err
}
// 用版本号作为key的一部分,旧版本号的缓存自然不会被命中
payload, _ := json.Marshal(perms)
s.redis.Set(ctx, cacheKey, payload, 5*time.Minute)
return perms[resource+":"+action], nil
}
// 更新权限时递增版本号
func (s *PermissionCacheStore) UpdatePermission(userID int64, resource, action string, allowed bool) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 更新权限
err := tx.Model(&Permission{}).
Where("user_id = ? AND resource = ? AND action = ?", userID, resource, action).
Update("allowed", allowed).Error
if err != nil {
return err
}
// 递增版本号
err = tx.Model(&User{}).
Where("id = ?", userID).
UpdateColumn("perm_version", gorm.Expr("perm_version + 1")).Error
if err != nil {
return err
}
// 获取新版本号
var newVersion int64
tx.Raw("SELECT perm_version FROM users WHERE id = ?", userID).Scan(&newVersion)
// 更新版本号缓存
s.redis.Set(ctx, fmt.Sprintf("perm:version:%d", userID), newVersion, 10*time.Minute)
// 发布变更事件
event := PermissionChangedEvent{
UserID: userID,
NewVersion: newVersion,
Resource: resource,
Action: action,
}
payload, _ := json.Marshal(event)
s.redis.Publish(ctx, "perm:cache:invalid", payload)
return nil
})
}版本号方案的精妙之处在于:不需要主动删除旧缓存。旧版本号的缓存key和新版本号不同,自然不会被命中。等旧key的TTL到了自动清理即可。这既省去了删除缓存的操作,又避免了"删了缓存但新数据还没写入"的竞态条件。
这个方案唯一的代价是版本号查询本身有一次Redis访问。但版本号是一个整数,查询开销极低,而且版本号本身也可以缓存较长时间(因为变更频率极低)。在实际生产中,版本号查询的延迟不超过0.3毫秒,完全可以接受。
版本号就像是权限的"代际标记"。你不需要销毁旧世界,只需要让新世界有不同的编号,旧世界自然就被遗忘了。
三、权限系统高可用架构
3.1 从单点到高可用的演进路径
权限系统的高可用不是一步到位的,它是一个渐进式的过程。很多团队一上来就想搞多机房多活,结果不仅成本爆炸,复杂度也超出了团队的驾驭能力。我把这个过程分为四个阶段,每个阶段对应不同的业务量和团队能力。
阶段一:单点部署
最开始,权限服务和数据库都是单点。一个Go进程加一个MySQL实例,跑在一台机器上。这个阶段能扛住早期业务量,但一旦机器宕机,所有依赖权限校验的业务全部不可用。这个阶段适合日QPS在几百到几千的初创项目。
阶段二:服务多实例加单DB
权限服务部署多实例,通过负载均衡分发请求。数据库还是单点,但通过连接池和缓存层减轻了压力。这个阶段的主要风险是DB单点故障。如果DB挂了,缓存迟早也要失效,最终所有权限校验都会失败。这个阶段适合日QPS在几千到几万的中型项目。
阶段三:服务多实例加DB主从加缓存集群
数据库做主从复制,Redis做集群部署。主库写,从库读,主库故障时切换到从库。这个阶段的可用性已经比较高了,能应对大部分故障场景。但主从切换可能有数据丢失,需要业务层做好兜底。这个阶段适合日QPS在几万到几十万的中大型项目。
阶段四:多活架构
在多个机房部署完整的权限系统,通过消息队列同步数据。任何一个机房故障,其他机房可以接管流量。这是最高级别的可用性,也是成本最高的方案。适合日QPS在百万以上、对可用性要求极高的核心业务。
高可用不是一蹴而就的架构设计,而是随业务量增长的演进过程。过早做高可用是浪费,太晚做是冒险。关键是找到你的"临界点"。
3.2 权限服务的无状态化
高可用的前提是服务无状态化。权限服务本身不应该存储任何状态,所有的状态(用户权限、角色定义、策略规则)都应该存储在外部(DB和Redis中)。这样服务实例可以随时重启、随时扩缩容,不需要考虑状态迁移的问题。
但有一个例外:本地缓存。本地缓存算不算"状态"?严格来说算,但它是一种"可丢失的状态"。本地缓存丢了最多就是缓存命中率降低,不影响正确性。所以权限服务可以随时重启,重启后本地缓存空了也没关系,Redis和DB会兜底。
来看无状态化后的服务定义和健康检查:
type PermissionService struct {
db *gorm.DB
redis *redis.Client
cache *LocalLRUCache
cacheSync *DistributedCacheSync
sfGroup singleflight.Group
eventBus *EventBus
metrics *PermissionMetrics
}
// 健康检查接口,供负载均衡器探活使用
func (s *PermissionService) HealthCheck() HealthStatus {
return HealthStatus{
Service: "ok",
Database: s.checkDB(),
Redis: s.checkRedis(),
Cache: s.cache.Stats(),
}
}
func (s *PermissionService) checkDB() string {
sqlDB, _ := s.db.DB()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return "degraded: " + err.Error()
}
return "ok"
}
func (s *PermissionService) checkRedis() string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.redis.Ping(ctx).Err(); err != nil {
return "degraded: " + err.Error()
}
return "ok"
}健康检查接口返回各组件的状态,负载均衡器根据这个状态决定是否把请求路由到这个实例。如果DB和Redis都不可用,负载均衡器应该把这个实例从可用列表中摘除。
3.3 数据库高可用方案
权限系统的数据库是整个系统的命脉。一旦DB挂了,缓存迟早也要失效,最终所有权限校验都会失败。所以DB的高可用是整个权限系统高可用的基础。
我采用的DB高可用方案是"一主两从加代理层"。主库负责写操作,两个从库负责读操作,代理层(如ProxySQL或MySQL Router)负责读写分离和故障切换。当主库故障时,代理层会自动将从库提升为主库,整个过程对应用层透明。
在Go代码中,我封装了一个DBCluster结构来管理读写分离:
type DBCluster struct {
master *gorm.DB
slaves []*gorm.DB
index uint64
}
func NewDBCluster(masterDSN string, slaveDSNs []string) *DBCluster {
master, _ := gorm.Open(mysql.Open(masterDSN), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
slaves := make([]*gorm.DB, len(slaveDSNs))
for i, dsn := range slaveDSNs {
slaves[i], _ = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
}
return &DBCluster{
master: master,
slaves: slaves,
}
}
// 写操作走主库
func (c *DBCluster) Write() *gorm.DB {
return c.master
}
// 读操作轮询从库
func (c *DBCluster) Read() *gorm.DB {
if len(c.slaves) == 0 {
return c.master
}
idx := atomic.AddUint64(&c.index, 1)
return c.slaves[idx%uint64(len(c.slaves))]
}但读写分离有一个棘手的问题:主从延迟。MySQL的主从复制是基于binlog的异步同步,主库写入后,从库可能有几十毫秒到几秒的延迟。你刚在主库更新了权限,转头去从库查,可能查到的还是旧数据。
对于权限变更这种场景,我的解决方案是"变更后短期走主库读"。具体来说,在权限变更后的3秒内,该用户的所有权限查询都走主库,确保拿到最新数据。3秒后主从延迟通常已经消除,可以恢复走从库:
type PermissionService struct {
dbCluster *DBCluster
redis *redis.Client
// 记录最近变更的用户,key: userID, value: 变更时间
recentChanges sync.Map
}
func (s *PermissionService) Check(userID int64, resource, action string) (bool, error) {
// 如果该用户最近有权限变更,走主库读
if changeTime, ok := s.recentChanges.Load(userID); ok {
if time.Since(changeTime.(time.Time)) < 3*time.Second {
// 走主库读,确保拿到最新数据
return s.checkFromDB(s.dbCluster.Write(), userID, resource, action)
}
// 超过3秒,主从延迟应该已经消除,清理记录
s.recentChanges.Delete(userID)
}
// 正常走缓存 -> 从库
return s.checkWithCache(userID, resource, action)
}
func (s *PermissionService) UpdatePermission(userID int64, resource, action string, allowed bool) error {
err := s.dbCluster.Write().Model(&Permission{}).
Where("user_id = ? AND resource = ? AND action = ?", userID, resource, action).
Update("allowed", allowed).Error
if err != nil {
return err
}
// 记录变更时间,后续3秒内走主库读
s.recentChanges.Store(userID, time.Now())
// 清缓存
s.invalidateCache(userID, resource, action)
return nil
}主从延迟是读写分离的阿喀琉斯之踵。你可以用各种手段来缩短这个不一致窗口,但无法完全消除。关键是要让业务知道这个窗口的存在,并决定如何应对。
3.4 降级策略
高可用系统必须有降级方案。当某个组件不可用时,系统应该能自动降级到次优状态,而不是直接崩溃。权限系统的降级策略需要特别小心,因为降级过度会导致安全漏洞(不该放行的放行了),降级不足会导致可用性下降(不该拒绝的拒绝了)。
我设计了三个降级级别,每个级别对应不同的组件故障组合:
type DegradationLevel int
const (
LevelNormal DegradationLevel = iota // 正常:本地缓存 -> Redis -> DB
LevelCacheOnly // 降级1:只用缓存,不查DB
LevelAllowAll // 降级2:放行所有请求(紧急模式)
LevelDenyAll // 降级3:拒绝所有请求(安全模式)
)降级逻辑的核心是在安全性和可用性之间做权衡。当DB挂了但Redis还在时,选择LevelCacheOnly——缓存中有的权限可以正常校验,缓存中没有的才拒绝。这在大多数情况下是更好的选择,因为热用户的权限通常都在缓存中。当DB和Redis都挂了时,选择LevelDenyAll——拒绝所有请求,虽然影响可用性但保证了安全性。
type PermissionService struct {
// ... 其他字段
degradationLevel DegradationLevel
degradationMu sync.RWMutex
}
func (s *PermissionService) Check(userID int64, resource, action string) (bool, error) {
level := s.getDegradationLevel()
switch level {
case LevelNormal:
return s.checkNormal(userID, resource, action)
case LevelCacheOnly:
return s.checkCacheOnly(userID, resource, action)
case LevelAllowAll:
// 记录日志,后续审计
s.logger.Warn("permission check bypassed due to allow-all degradation",
zap.Int64("user_id", userID),
zap.String("resource", resource),
zap.String("action", action))
return true, nil
case LevelDenyAll:
return false, errors.New("permission service in deny-all mode")
}
return false, errors.New("unknown degradation level")
}
// 自动降级检测
func (s *PermissionService) autoDegrade() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
dbHealthy := s.checkDB() == "ok"
redisHealthy := s.checkRedis() == "ok"
switch {
case !dbHealthy && !redisHealthy:
// DB和Redis都挂了,进入安全模式
s.setDegradationLevel(LevelDenyAll)
s.alert("DB and Redis both down, entering deny-all mode")
case !dbHealthy && redisHealthy:
// DB挂了,Redis还在,只用缓存
s.setDegradationLevel(LevelCacheOnly)
s.alert("DB down, entering cache-only mode")
case dbHealthy && redisHealthy:
// 恢复正常
if s.getDegradationLevel() != LevelNormal {
s.setDegradationLevel(LevelNormal)
s.alert("All systems recovered, back to normal mode")
}
}
}
}
func (s *PermissionService) checkCacheOnly(userID int64, resource, action string) (bool, error) {
cacheKey := CacheKey{userID, resource, action}.String()
// 只查本地缓存和Redis
if val, ok := s.localCache.Get(cacheKey); ok {
return val, nil
}
val, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
if val == NullMarker {
return false, nil
}
result, _ := strconv.ParseBool(val)
return result, nil
}
// 缓存中没有,无法判断,默认拒绝
return false, errors.New("cache miss in cache-only mode")
}这里有一个关键决策:LevelAllowAll是最危险的级别——它放行所有请求。我只在极端情况下(比如整个权限系统不可用且业务方明确同意临时放行)才会启用。而且启用时必须有详细的审计日志,记录每一个被放行的请求,以便事后追溯和审计。
降级不是认输,而是在逆境中选择伤害最小的方案。一个好的降级策略,能让你的系统在最坏的情况下依然保持最基本的运转。
3.5 熔断与限流
当权限服务自身的响应时间异常升高时,继续处理请求只会让情况更糟——请求堆积、goroutine泄漏、内存暴涨、最终OOM。这时候需要熔断器来及时止损,让服务快速失败而不是慢慢拖死。
熔断器有三个状态:Closed(正常)、Open(熔断)、HalfOpen(半开)。正常状态下所有请求都放行,当失败次数超过阈值时切换到熔断状态,拒绝所有请求。等待一段时间后进入半开状态,放行少量请求试探。如果试探成功则恢复正常,否则继续熔断。
type CircuitBreaker struct {
mu sync.Mutex
failureCount int
failureThreshold int
resetTimeout time.Duration
lastFailureTime time.Time
state CircuitState
}
type CircuitState int
const (
StateClosed CircuitState = iota // 正常
StateOpen // 熔断
StateHalfOpen // 半开
)
func (cb *CircuitBreaker) Allow() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateClosed:
return true
case StateOpen:
if time.Since(cb.lastFailureTime) > cb.resetTimeout {
cb.state = StateHalfOpen
return true
}
return false
case StateHalfOpen:
return true
}
return false
}
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failureCount = 0
if cb.state == StateHalfOpen {
cb.state = StateClosed
}
}
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failureCount++
cb.lastFailureTime = time.Now()
if cb.failureCount >= cb.failureThreshold {
cb.state = StateOpen
}
if cb.state == StateHalfOpen {
cb.state = StateOpen
}
}限流则使用令牌桶算法,对权限校验接口做QPS限制。令牌桶的原理是:以固定速率往桶里放令牌,桶满了就不再放。每个请求来的时候从桶里取一个令牌,取到了就放行,取不到就拒绝。令牌桶的好处是允许一定程度的突发流量——桶里攒的令牌可以应对短时间的流量高峰。
type TokenBucket struct {
capacity int64
tokens int64
rate int64 // tokens per second
lastRefill time.Time
mu sync.Mutex
}
func NewTokenBucket(capacity, rate int64) *TokenBucket {
return &TokenBucket{
capacity: capacity,
tokens: capacity,
rate: rate,
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastRefill).Seconds()
refill := int64(elapsed * float64(tb.rate))
if refill > 0 {
tb.tokens = min(tb.tokens+refill, tb.capacity)
tb.lastRefill = now
}
if tb.tokens > 0 {
tb.tokens--
return true
}
return false
}熔断是自我保护,限流是流量整形。两者配合使用,就像汽车的ABS和安全带——一个防止失控,一个减轻伤害。
四、性能优化与并发控制
4.1 批量权限校验
在实际业务中,一个请求往往需要校验多个权限。比如用户点一下"编辑文章"的按钮,后端可能需要校验四五个权限:文章查看权限、文章编辑权限、标签管理权限、图片上传权限、评论管理权限。如果逐个校验,每次校验都要走一遍缓存查询流程,延迟会叠加。
批量校验的核心思路是:把多个权限查询合并成一次操作。先从本地缓存批量获取,未命中的再从Redis批量获取(用Pipeline减少网络往返),最后仍未命中的才走DB,而且用一条SQL查出来。
type BatchCheckRequest struct {
UserID int64
Checks []PermissionCheck
}
type PermissionCheck struct {
Resource string
Action string
}
type BatchCheckResult struct {
Results map[string]bool // key: "resource:action"
}
func (s *PermissionService) BatchCheck(userID int64, checks []PermissionCheck) (*BatchCheckResult, error) {
result := &BatchCheckResult{
Results: make(map[string]bool),
}
// 第一步:从本地缓存批量获取,筛出未命中的
var missed []PermissionCheck
for _, check := range checks {
key := CacheKey{userID, check.Resource, check.Action}.String()
if val, ok := s.localCache.Get(key); ok {
result.Results[check.Resource+":"+check.Action] = val
} else {
missed = append(missed, check)
}
}
if len(missed) == 0 {
return result, nil
}
// 第二步:从Redis批量获取,用Pipeline减少网络往返
pipe := s.redis.Pipeline()
cmds := make(map[string]*redis.StringCmd)
for _, check := range missed {
key := CacheKey{userID, check.Resource, check.Action}.String()
cmds[check.Resource+":"+check.Action] = pipe.Get(ctx, key)
}
_, _ = pipe.Exec(ctx)
var dbNeeded []PermissionCheck
for checkKey, cmd := range cmds {
val, err := cmd.Result()
if err == nil {
if val == NullMarker {
result.Results[checkKey] = false
} else {
b, _ := strconv.ParseBool(val)
result.Results[checkKey] = b
}
} else {
// Redis未命中,需要查DB
parts := strings.Split(checkKey, ":")
if len(parts) == 2 {
dbNeeded = append(dbNeeded, PermissionCheck{
Resource: parts[0],
Action: parts[1],
})
}
}
}
if len(dbNeeded) == 0 {
return result, nil
}
// 第三步:批量查DB,一条SQL获取所有需要的权限
resources := make([]string, len(dbNeeded))
actions := make([]string, len(dbNeeded))
for i, c := range dbNeeded {
resources[i] = c.Resource
actions[i] = c.Action
}
var perms []Permission
err := s.db.Where("user_id = ? AND (resource, action) IN (?)",
userID, zipResourceAction(resources, actions)).Find(&perms).Error
if err != nil {
return result, err
}
// 构建结果集并回写缓存
pipe = s.redis.Pipeline()
permMap := make(map[string]bool)
for _, p := range perms {
key := p.Resource + ":" + p.Action
permMap[key] = p.Allowed
result.Results[key] = p.Allowed
cacheKey := CacheKey{userID, p.Resource, p.Action}.String()
pipe.Set(ctx, cacheKey, strconv.FormatBool(p.Allowed), randomTTL(5*time.Minute))
}
// 对于DB中也不存在的权限,缓存空值
for _, c := range dbNeeded {
key := c.Resource + ":" + c.Action
if _, ok := permMap[key]; !ok {
result.Results[key] = false
cacheKey := CacheKey{userID, c.Resource, c.Action}.String()
pipe.Set(ctx, cacheKey, NullMarker, 30*time.Second)
}
}
_, _ = pipe.Exec(ctx)
return result, nil
}批量校验的性能提升非常显著。我做过基准测试:在4个权限校验的场景下,逐个校验的平均耗时是12ms(4次Redis查询),批量校验只需要3.8ms(1次Pipeline查询)。提升约3倍。如果是10个权限校验,提升更明显:逐个30ms,批量5ms,提升6倍。这个优化的收益在高峰期尤为明显——当QPS达到几千时,批量校验能把权限校验的总耗时从秒级降到百毫秒级,用户体感完全不同。而且批量校验减少了对Redis的连接数占用,间接提升了整个系统的并发能力。可以说,批量校验是权限系统性能优化中投入产出比最高的一项。
批量是性能优化最朴素的武器。与其发4次快递,不如打包成1次寄出去。网络往返的成本永远比你想象的大。
4.2 权限预加载
对于已知的热点用户,在请求到来之前就预加载其权限到缓存中,可以消除首次访问的缓存未命中延迟。典型的预加载时机有两个:用户登录时和定时刷新时。
用户登录时预加载是最自然的时机。用户刚登录,接下来必然要进行一系列操作,提前把权限加载到缓存中,后续的权限校验全部命中缓存,体验丝滑。
type PermissionPreloader struct {
service *PermissionService
queue chan int64 // userID channel
}
func NewPermissionPreloader(service *PermissionService, workers int) *PermissionPreloader {
p := &PermissionPreloader{
service: service,
queue: make(chan int64, 10000),
}
for i := 0; i < workers; i++ {
go p.worker()
}
return p
}
func (p *PermissionPreloader) Preload(userID int64) {
select {
case p.queue <- userID:
default:
// 队列满了,跳过预加载,不影响主流程
}
}
func (p *PermissionPreloader) worker() {
for userID := range p.queue {
// 加载该用户的所有权限到缓存
perms, err := p.service.loadUserPermissions(userID)
if err != nil {
continue
}
// 用Pipeline批量写入Redis
pipe := p.service.redis.Pipeline()
for resource, actions := range perms {
for action, allowed := range actions {
cacheKey := CacheKey{userID, resource, action}.String()
pipe.Set(ctx, cacheKey, strconv.FormatBool(allowed), randomTTL(5*time.Minute))
}
}
// 同时缓存用户的完整权限集合
pipe.Set(ctx, fmt.Sprintf("user_perms:%d", userID),
serializePerms(perms), 10*time.Minute)
_, _ = pipe.Exec(ctx)
}
}
// 在用户登录时触发预加载
func (s *AuthService) Login(userID int64) (*Token, error) {
// ... 正常登录逻辑 ...
// 异步预加载权限,不阻塞登录响应
s.preloader.Preload(userID)
return token, nil
}4.3 并发控制与Goroutine管理
权限系统中有很多异步操作:缓存回写、事件发布、预加载等。如果不控制goroutine数量,在高并发场景下可能导致goroutine泄漏。Go语言中创建goroutine的成本很低,但不代表没有成本。每个goroutine初始占用2KB栈空间,在高并发下如果每个请求都创建几个goroutine不做控制,内存占用会迅速增长。
我的方案是使用worker pool来复用goroutine,限制最大并发数:
type WorkerPool struct {
tasks chan func()
wg sync.WaitGroup
maxWorkers int
}
func NewWorkerPool(maxWorkers int, queueSize int) *WorkerPool {
pool := &WorkerPool{
tasks: make(chan func(), queueSize),
maxWorkers: maxWorkers,
}
for i := 0; i < maxWorkers; i++ {
pool.wg.Add(1)
go pool.worker()
}
return pool
}
func (p *WorkerPool) worker() {
defer p.wg.Done()
for task := range p.tasks {
task()
}
}
func (p *WorkerPool) Submit(task func()) bool {
select {
case p.tasks <- task:
return true
default:
return false // 队列满了,任务被丢弃
}
}
func (p *WorkerPool) Shutdown() {
close(p.tasks)
p.wg.Wait()
}在权限服务中使用worker pool来处理异步操作:
type PermissionService struct {
// ... 其他字段
asyncPool *WorkerPool
}
func (s *PermissionService) Check(userID int64, resource, action string) (bool, error) {
// ... 同步校验逻辑 ...
// 异步回写缓存,不阻塞主流程
s.asyncPool.Submit(func() {
s.redis.Set(ctx, cacheKey, "1", randomTTL(5*time.Minute))
})
return true, nil
}注意Submit方法在队列满时返回false,任务会被丢弃。这对于缓存回写这种非关键操作是可以接受的——丢了最多就是下次请求多查一次DB。但如果是审计日志这种不能丢的操作,需要用前面提到的可靠事件总线。
4.4 性能指标监控
没有数据就没有优化。我见过太多团队在优化权限系统时凭感觉调参数,结果越调越差。正确的做法是先铺好监控,让数据告诉你瓶颈在哪里。
我建议监控以下核心指标,每个指标都有明确的告警阈值:
type PermissionMetrics struct {
// 延迟指标
CheckLatency *Histogram
BatchCheckLatency *Histogram
// 缓存指标
LocalCacheHitRate *Counter
LocalCacheMissRate *Counter
RedisHitRate *Counter
RedisMissRate *Counter
// 错误指标
DBErrorCount *Counter
RedisErrorCount *Counter
TimeoutCount *Counter
// 流量指标
CheckQPS *Counter
BatchCheckQPS *Counter
// 降级指标
DegradationLevel Gauge
BypassedChecks *Counter
}
// 在权限校验流程中埋点
func (s *PermissionService) CheckWithMetrics(userID int64, resource, action string) (bool, error) {
start := time.Now()
defer func() {
s.metrics.CheckLatency.Observe(time.Since(start).Seconds())
s.metrics.CheckQPS.Inc()
}()
// 本地缓存查询
if val, ok := s.localCache.Get(cacheKey); ok {
s.metrics.LocalCacheHitRate.Inc()
return val, nil
}
s.metrics.LocalCacheMissRate.Inc()
// Redis查询
// ...
return result, nil
}以下是权限系统核心监控指标清单,供你参考:
| 指标分类 | 指标名称 | 告警阈值 | 说明 |
|---|---|---|---|
| 延迟 | check_latency_p99 | 大于50ms | 权限校验P99延迟 |
| 延迟 | batch_check_latency_p99 | 大于100ms | 批量校验P99延迟 |
| 缓存 | local_cache_hit_rate | 低于60% | 本地缓存命中率 |
| 缓存 | redis_hit_rate | 低于80% | Redis缓存命中率 |
| 错误 | db_error_count | 大于10次/分钟 | DB错误数 |
| 错误 | timeout_count | 大于5次/分钟 | 超时次数 |
| 流量 | check_qps | 突增3倍 | QPS异常波动 |
| 降级 | degradation_level | 不等于normal | 进入降级模式 |
| 降级 | bypassed_checks | 大于0 | 放行请求数 |
优化不是猜测,而是测量。你在优化之前要做的是把指标埋点铺好,让数据告诉你瓶颈在哪里。盲目优化是万恶之源。
4.5 数据库层面的优化
除了缓存,数据库本身的优化也很重要。权限表的数据量可能非常大——千万级用户乘以多个资源乘以多个操作,轻松上亿条记录。合理的索引和表设计是基础中的基础。
权限表的建表语句:
CREATE TABLE permissions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
resource VARCHAR(128) NOT NULL,
action VARCHAR(64) NOT NULL,
allowed TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 联合唯一索引,防止重复数据,同时加速按用户+资源+操作的查询
UNIQUE KEY uk_user_resource_action (user_id, resource, action),
-- 用户ID索引,用于按用户查询全部权限
KEY idx_user_id (user_id),
-- 资源索引,用于按资源维度统计
KEY idx_resource (resource)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY HASH(user_id) PARTITIONS 16;按用户ID做Hash分区是一个重要的优化。这样同一个用户的权限数据在同一个物理分区,查询时可以分区裁剪(partition pruning),减少IO。同时,不同用户的数据分散在不同分区,减少了锁争用。16个分区是一个比较合理的起点,后续可以根据数据量增长到32或64。
在Go代码中,还要注意连接池的配置。连接池配置不当会导致要么连接不够用(请求排队),要么连接太多(DB端连接数耗尽):
func setupDB(dsn string) *gorm.DB {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
panic(err)
}
sqlDB, _ := db.DB()
// 连接池配置
sqlDB.SetMaxOpenConns(50) // 最大连接数
sqlDB.SetMaxIdleConns(10) // 最大空闲连接
sqlDB.SetConnMaxLifetime(30 * time.Minute) // 连接最大存活时间
sqlDB.SetConnMaxIdleTime(10 * time.Minute) // 空闲连接最大存活时间
return db
}连接池的参数需要根据实际负载来调优。一个经验法则:MaxOpenConns等于单次请求平均耗时毫秒数乘以峰值QPS除以1000加上buffer。比如平均耗时5ms、峰值QPS 2000,那连接数大约需要5乘以2000除以1000等于10,再加上一些buffer设20到30就够了。MaxIdleConns一般设为MaxOpenConns的三分之一到一半。
数据库优化三部曲:索引、分区、连接池。听起来简单,但每一项都值得你花一周时间去打磨。
五、权限变更通知机制
5.1 为什么需要通知机制
权限变更不是一个简单的"改个值"的操作。当一个用户的权限发生变化时,可能需要触发一系列联动操作。
我整理了权限变更后需要触发的完整联动清单:
- 清除多级缓存(本地缓存、Redis缓存)
- 通知所有服务实例清除各自的本地缓存
- 记录审计日志(谁在什么时候改了谁的什么权限)
- 推送实时通知给被操作的用户("你的权限已被修改")
- 同步到其他系统(比如网关的ACL规则、第三方系统的权限映射)
- 更新权限版本号
- 触发相关的业务逻辑(比如权限降级后清理相关session)
如果没有统一的通知机制,这些逻辑会散落在代码各处,难以维护,且容易遗漏。每加一个联动逻辑就要改权限变更的代码,最终那个函数会变成一个没人敢碰的怪物。
我见过最混乱的权限变更代码是在一个早期项目里,一个UpdatePermission函数里直接写了200多行逻辑,包括清缓存、发邮件、写日志、调外部接口、更新session、同步ES索引。每次加一个联动逻辑就改这个函数,最后没人敢动它了——因为谁也不知道改了会不会出问题。
当一个函数的行数超过了你的耐心,就说明你需要一个事件驱动架构了。
5.2 事件驱动的权限变更架构
我设计了一套基于事件驱动的权限变更通知机制。核心思想是:权限变更操作只负责更新DB和发布事件,所有的联动逻辑都作为独立的事件处理器,订阅事件并各自处理。
这种架构的好处是:变更逻辑和联动逻辑完全解耦。未来要加新的联动逻辑,只需要新增一个事件处理器并注册到事件总线,完全不用改现有的代码。这就是开闭原则在实际工程中的落地。
// 事件类型定义
type PermissionEventType string
const (
EventUserPermissionChanged PermissionEventType = "user_permission_changed"
EventRolePermissionChanged PermissionEventType = "role_permission_changed"
EventPolicyUpdated PermissionEventType = "policy_updated"
EventUserBlocked PermissionEventType = "user_blocked"
)
// 权限变更事件
type PermissionEvent struct {
Type PermissionEventType `json:"type"`
UserID int64 `json:"user_id"`
RoleID int64 `json:"role_id,omitempty"`
Resource string `json:"resource,omitempty"`
Action string `json:"action,omitempty"`
OldValue bool `json:"old_value"`
NewValue bool `json:"new_value"`
Operator int64 `json:"operator"`
Timestamp int64 `json:"timestamp"`
Reason string `json:"reason,omitempty"`
}
// 事件总线
type EventBus struct {
subscribers map[PermissionEventType][]EventHandler
mu sync.RWMutex
queue chan *PermissionEvent
workers int
}
type EventHandler func(event *PermissionEvent) error
func NewEventBus(workers int, queueSize int) *EventBus {
bus := &EventBus{
subscribers: make(map[PermissionEventType][]EventHandler),
queue: make(chan *PermissionEvent, queueSize),
workers: workers,
}
for i := 0; i < workers; i++ {
go bus.processEvents()
}
return bus
}
func (b *EventBus) Subscribe(eventType PermissionEventType, handler EventHandler) {
b.mu.Lock()
defer b.mu.Unlock()
b.subscribers[eventType] = append(b.subscribers[eventType], handler)
}
func (b *EventBus) Publish(event *PermissionEvent) {
select {
case b.queue <- event:
default:
// 队列满了,降级为同步处理
log.Warn("event bus queue full, processing synchronously")
b.process(event)
}
}
func (b *EventBus) processEvents() {
for event := range b.queue {
b.process(event)
}
}
func (b *EventBus) process(event *PermissionEvent) {
b.mu.RLock()
handlers := b.subscribers[event.Type]
b.mu.RUnlock()
for _, handler := range handlers {
if err := handler(event); err != nil {
log.Error("event handler failed",
"event_type", event.Type,
"error", err)
}
}
}5.3 各类处理器实现
基于事件总线,各种联动逻辑变成独立的处理器,互不影响。每个处理器只关心自己的逻辑,不需要知道其他处理器的存在。这种隔离性使得系统非常容易扩展和维护。
// 缓存失效处理器:负责清除各级缓存
type CacheInvalidationHandler struct {
localCache *LocalLRUCache
redis *redis.Client
cacheSync *DistributedCacheSync
}
func (h *CacheInvalidationHandler) Handle(event *PermissionEvent) error {
cacheKey := CacheKey{event.UserID, event.Resource, event.Action}.String()
// 清除本地缓存
h.localCache.delete(cacheKey)
// 清除Redis缓存
h.redis.Del(ctx, cacheKey)
// 广播给其他实例
return h.cacheSync.Broadcast([]string{cacheKey})
}
// 审计日志处理器:记录权限变更的完整审计轨迹
type AuditLogHandler struct {
db *gorm.DB
}
func (h *AuditLogHandler) Handle(event *PermissionEvent) error {
auditLog := PermissionAuditLog{
UserID: event.UserID,
Resource: event.Resource,
Action: event.Action,
OldValue: event.OldValue,
NewValue: event.NewValue,
Operator: event.Operator,
Reason: event.Reason,
CreatedAt: time.UnixMilli(event.Timestamp),
}
return h.db.Create(&auditLog).Error
}
// 用户通知处理器:在权限被撤销时通知用户
type UserNotificationHandler struct {
notifier Notifier
}
func (h *UserNotificationHandler) Handle(event *PermissionEvent) error {
// 只在权限被撤销时通知用户,新增权限不需要通知
if event.OldValue && !event.NewValue {
return h.notifier.Notify(event.UserID, Notification{
Title: "权限变更通知",
Content: fmt.Sprintf("您对资源 %s 的 %s 权限已被收回", event.Resource, event.Action),
})
}
return nil
}
// 网关ACL同步处理器:将权限变更同步到API网关
type GatewaySyncHandler struct {
gatewayClient *GatewayClient
}
func (h *GatewaySyncHandler) Handle(event *PermissionEvent) error {
return h.gatewayClient.UpdateACL(GatewayACLEntry{
UserID: event.UserID,
Resource: event.Resource,
Action: event.Action,
Allowed: event.NewValue,
})
}
// 注册所有处理器
func SetupEventHandlers(bus *EventBus, deps *Dependencies) {
bus.Subscribe(EventUserPermissionChanged, (&CacheInvalidationHandler{
localCache: deps.LocalCache,
redis: deps.Redis,
cacheSync: deps.CacheSync,
}).Handle)
bus.Subscribe(EventUserPermissionChanged, (&AuditLogHandler{
db: deps.DB,
}).Handle)
bus.Subscribe(EventUserPermissionChanged, (&UserNotificationHandler{
notifier: deps.Notifier,
}).Handle)
bus.Subscribe(EventUserPermissionChanged, (&GatewaySyncHandler{
gatewayClient: deps.GatewayClient,
}).Handle)
}5.4 权限变更的完整流程
把所有组件串起来,一个完整的权限变更流程如下。这个流程的设计原则是:DB操作和事件发布在同一个事务中,保证数据一致性和事件不丢失。
func (s *PermissionService) UpdatePermission(
operatorID int64,
userID int64,
resource string,
action string,
allowed bool,
reason string,
) error {
// 第一步:查询旧值,用于事件中携带变更前后的对比
var oldPerm Permission
err := s.db.Where("user_id = ? AND resource = ? AND action = ?",
userID, resource, action).First(&oldPerm).Error
oldValue := false
if err == nil {
oldValue = oldPerm.Allowed
}
// 如果没有变化,直接返回,不发事件
if oldValue == allowed {
return nil
}
// 第二步:更新DB
err = s.db.Save(&Permission{
UserID: userID,
Resource: resource,
Action: action,
Allowed: allowed,
}).Error
if err != nil {
return err
}
// 第三步:递增版本号(用于版本号缓存方案)
s.db.Model(&User{}).Where("id = ?", userID).
UpdateColumn("perm_version", gorm.Expr("perm_version + 1"))
// 第四步:发布事件
// 所有联动逻辑通过事件触发:清缓存、写审计日志、通知用户、同步网关
s.eventBus.Publish(&PermissionEvent{
Type: EventUserPermissionChanged,
UserID: userID,
Resource: resource,
Action: action,
OldValue: oldValue,
NewValue: allowed,
Operator: operatorID,
Timestamp: time.Now().UnixMilli(),
Reason: reason,
})
return nil
}这个设计的好处是显而易见的:变更逻辑和联动逻辑完全解耦。未来要加新的联动逻辑(比如同步到LDAP、推送WebSocket通知),只需要新增一个事件处理器并注册到事件总线,完全不用改UpdatePermission这个方法。这就是开闭原则的威力——对扩展开放,对修改关闭。
好的架构让加功能变成"加代码"而不是"改代码"。开闭原则不是课本上的教条,而是实实在在降低系统复杂度的利器。
5.5 事件可靠投递
事件总线用的是内存队列,如果进程崩溃,队列中的事件会丢失。对于缓存失效这种操作,丢了也没关系(TTL兜底)。但对于审计日志这种不能丢的事件,需要额外的保障机制。
我的方案是"先落库再处理":事件发布时先写入DB的事件表,然后推入内存队列异步处理。后台有个扫描器定期检查事件表,处理那些在队列中丢失的事件。这样即使进程崩溃,重启后扫描器也能把未处理的事件捞出来重新处理。
type ReliableEventBus struct {
db *gorm.DB
queue chan int64 // event ID channel
}
func (b *ReliableEventBus) Publish(event *PermissionEvent) error {
// 第一步:事件落库
record := EventRecord{
Type: string(event.Type),
Payload: mustJSON(event),
Status: "pending",
Created: time.Now(),
}
if err := b.db.Create(&record).Error; err != nil {
return err
}
// 第二步:推入内存队列
select {
case b.queue <- record.ID:
default:
// 队列满了,由后台扫描器兜底
}
return nil
}
func (b *ReliableEventBus) processEvents() {
for eventID := range b.queue {
b.processEvent(eventID)
}
}
func (b *ReliableEventBus) processEvent(eventID int64) {
// 从DB加载事件
var record EventRecord
if err := b.db.First(&record, eventID).Error; err != nil {
return
}
var event PermissionEvent
json.Unmarshal([]byte(record.Payload), &event)
// 调用处理器
b.mu.RLock()
handlers := b.subscribers[event.Type]
b.mu.RUnlock()
allSuccess := true
for _, handler := range handlers {
if err := handler(&event); err != nil {
allSuccess = false
log.Error("event handler failed", "event_id", eventID, "error", err)
}
}
// 更新事件状态
status := "completed"
if !allSuccess {
status = "partial"
}
b.db.Model(&EventRecord{}).Where("id = ?", eventID).
Updates(map[string]interface{}{
"status": status,
"processed_at": time.Now(),
})
}
// 后台扫描器:处理队列满时未投递的事件
func (b *ReliableEventBus) startScanner() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
var pending []EventRecord
b.db.Where("status = ? AND created_at < ?",
"pending", time.Now().Add(-30*time.Second)).
Limit(100).Find(&pending)
for _, record := range pending {
b.processEvent(record.ID)
}
}
}5.6 权限变更通知的幂等性
在分布式系统中,通知可能被重复投递。比如消息队列的at-least-once语义,或者后台扫描器的重复处理。处理器必须做到幂等——处理同一个事件多次,效果等同于处理一次。
幂等性的实现方式有很多:可以去重表、Redis的SETNX、或者本地的LRU缓存。我选择用本地LRU缓存来做幂等判断,因为它足够快,而且对于权限变更这种低频操作,LRU缓存的容量完全够用。
type IdempotentCacheHandler struct {
localCache *LocalLRUCache
redis *redis.Client
processed *lru.Cache // 已处理事件的LRU缓存
}
func (h *IdempotentCacheHandler) Handle(event *PermissionEvent) error {
// 用事件的关键字段生成唯一标识
eventID := fmt.Sprintf("%d:%s:%s:%d",
event.UserID, event.Resource, event.Action, event.Timestamp)
// 检查是否已处理
if _, ok := h.processed.Get(eventID); ok {
return nil // 已处理,跳过
}
// 执行缓存失效
cacheKey := CacheKey{event.UserID, event.Resource, event.Action}.String()
h.localCache.delete(cacheKey)
h.redis.Del(ctx, cacheKey)
// 标记为已处理
h.processed.Add(eventID, struct{}{})
return nil
}缓存失效本身就是幂等的——删一个不存在的key不会报错。所以即使不做幂等判断,重复处理也只是多做一次无效的删除操作,不会产生错误结果。但对于审计日志处理器就不一样了——如果重复处理,会写入多条重复的审计记录。所以审计日志处理器需要更严格的幂等保障,可以用事件ID作为唯一键来防重。
幂等不是可选项,是分布式系统的必修课。你永远不知道同一条消息会被投递几次,但你可以保证处理多次和处理一次的效果相同。
六、实战清单:权限系统高可用Checklist
最后,我整理了一份权限系统高可用的实施清单,按照优先级排序。这份清单是我多年实战经验的总结,每一条都是踩过坑后总结出来的。你可以对照着检查自己的系统,看看哪些做了哪些没做。
P0级别——必须做(不做就是裸奔):
- 权限数据加缓存,至少Redis一层
- 缓存空值防穿透
- singleflight防击穿
- TTL随机化防雪崩
- 缓存变更时先更新DB再删缓存
- DB主从分离,读走从库写走主库
- 权限变更写审计日志
- 权限校验有超时兜底(超时默认拒绝)
P1级别——强烈建议(做了能睡好觉):
- 本地LRU缓存作为一级缓存
- 分布式缓存同步(Pub/Sub加MQ双保险)
- 布隆过滤器过滤不存在的用户
- 批量权限校验接口
- 权限变更事件总线
- 自动降级机制(DB挂了用缓存兜底)
- 熔断器保护权限服务
- 连接池参数调优
P2级别——锦上添花(有余力再做):
- 版本号机制保障强一致性
- 权限预加载(登录时触发)
- 数据库分区
- 多机房多活
- 事件可靠投递(落库加重试)
- 全链路指标监控
- 限流保护
- 权限变更通知用户
这份清单不是让你一次做完所有事,而是让你知道哪些事还没做,以及它们的优先级。先做P0保命,再做P1安心,最后做P2追求极致。不要试图一步到位,循序渐进才是工程化的正确姿势。
清单的意义不是让你一次做完所有事,而是让你知道哪些事还没做,以及它们的优先级。先做P0保命,再做P1安心,最后做P2追求极致。
收藏引导
这篇文章从头到尾敲了好几天,每一个方案都经过生产环境验证。如果你正在做权限系统,或者打算重构现有的权限模块,强烈建议收藏。不是因为我写得多好,而是因为这些坑你迟早会踩,提前看到答案能省你不少通宵。
建议收藏后配合目录跳转阅读,先看第六章的清单找到自己系统的位置,再针对性读对应章节。比起到处搜零散的博客文章,这一篇系统性的梳理能帮你建立完整的知识框架。
互动引导
你们团队的权限系统是怎么做缓存的?有没有踩过什么印象深刻的坑?比如缓存不一致导致的权限漏洞、雪崩导致的系统瘫痪,或者其他奇葩问题。欢迎在评论区分享你的经历,我会挑有趣的案例一起讨论。
另外,关于权限系统你还有什么想看的内容?比如ABAC模型的设计与实现、权限可视化方案、权限测试策略等,评论区告诉我,后面的章节可能会安排上。
追更引导
这是《Go后端工程进阶实战》系列的第7章。整个系列共16章,从Go基础到架构设计,从单体到微服务,从开发到运维,一站式覆盖后端工程师的成长路径。每一章都是独立的主题,但前后章节之间有内在的逻辑联系,建议按顺序阅读。
如果这篇文章对你有帮助,点个关注不迷路。下一章我们聊权限系统的安全加固与总结,包括权限提升攻击防护、越权漏洞修复、权限测试方案等内容,干货满满,不见不散。
系列进度:7/16
下章预告:第8章 - 权限系统安全加固与总结
怕浪猫说
权限系统的高可用不是一个技术问题,而是一个工程问题。它不要求你发明什么新算法,但要求你把每一个环节都想清楚:缓存怎么设计、一致性怎么保障、故障怎么降级、变更怎么通知。每一个环节都有多种选择,每种选择都有其代价和适用场景。工程的艺术在于,在特定的约束条件下做出最合适的选择。
我见过太多团队在权限系统上栽跟头,不是因为技术不够好,而是因为想得不够全。缓存加了但没考虑穿透,主从分离了但没考虑延迟,事件驱动了但没考虑幂等。每一个遗漏都是一个潜在的事故,而事故往往在最不期望的时候发生——凌晨三点、大促当天、老板值班的时候。
写这一章的时候我反复在想:如果五年前的我能看到这篇文章,是不是就不用在凌晨三点对着监控面板发呆了?答案大概是——看了也得踩一遍,但至少踩的时候知道该怎么处理。经验这东西,纸上看十遍不如自己踩一遍。但提前看过纸上的内容,踩的时候至少不会慌。
工程没有银弹,但有方法论。希望这一章能成为你的方法论,让你在遇到权限系统高可用问题时,脑子里有一张清晰的作战地图,而不是两眼一抹黑。记住,高可用不是某一项技术决定的,而是架构、代码、运维、监控共同决定的。你的缓存策略再好,没有降级方案也是白搭;你的降级方案再完善,没有监控告警也是摆设。系统性思维才是高可用的根本。