当一台服务器上同时运行着在线交易、批量报表和日志采集任务时,CPU 和磁盘 IO 的竞争几乎是不可避免的。在线交易对延迟极其敏感,批量报表则希望吞吐越高越好,而日志采集如果无节制地写入,可能把磁盘带宽占满,拖慢前两者。配额和优先级就是用来解决这类资源争抢的两种基础手段。配额回答的是每个任务最多能用多少资源,优先级回答的是当资源不够时先满足谁。两者并不互斥,实际系统中往往需要组合使用,才能既保护核心业务又避免资源闲置。

资源争抢的根源与常见治理误区
资源争抢的根源在于共享资源的有限性和请求的随机性。CPU 核数、内存容量、网络带宽、磁盘 IOPS、数据库连接池等都属于稀缺资源,一旦多个消费者同时发起请求,瞬时需求超过供给,就会出现排队、超时、重试甚至雪崩。容器化环境里,同一宿主机上的多个容器即使配置了 CPU limit,仍然会争抢内核调度时间片;微服务架构中,一个慢接口可能耗尽线程池,让其他正常接口得不到处理。这些现象共同说明,资源争抢不能只靠增加硬件解决。
常见误区包括只做扩容不做限制,结果流量一上来又把新资源打满;只设置优先级不设配额,导致高优先级任务把资源全部占走,低优先级任务长期得不到执行;或者给所有任务平均分配,看似公平却浪费了高优先级任务的响应时间。还有团队认为加锁就能解决资源争抢,其实锁解决的是数据一致性问题,不是资源容量问题。配额和优先级才是面向容量的治理手段,它们控制的是谁能用、能用多少,而不是操作之间如何互斥。
配额机制:从硬限制到自适应令牌桶
配额的核心是给每个消费者设定资源使用上限。硬限制最简单,例如一个容器最多使用 2 核 CPU、4GB 内存,或者一个 API 调用者每分钟最多请求 100 次。硬限制的好处是隔离性强,一个调用方超量不会影响别人;缺点是资源利用率可能不高,因为配额未用完时无法借给其他消费者,整体吞吐会受到影响。为了在隔离和利用率之间取得平衡,实践中更多使用令牌桶这类平滑限流算法。
令牌桶会以固定速率向桶中放入令牌,请求到来时需要消耗令牌,桶满则不再增加。以 Go 语言为例,令牌桶的核心结构可以这样写:
package main
import (
"fmt"
"sync"
"time"
)
type TokenBucket struct {
mu sync.Mutex
capacity float64
tokens float64
refillRate float64
lastRefill time.Time
}
func NewTokenBucket(capacity, refillRate float64) *TokenBucket {
return &TokenBucket{
capacity: capacity,
tokens: capacity,
refillRate: refillRate,
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastRefill).Seconds()
tb.tokens += elapsed * tb.refillRate
if tb.tokens > tb.capacity {
tb.tokens = tb.capacity
}
tb.lastRefill = now
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
桶容量决定了允许的最大突发量,补充速率决定了长期平均速率。相比固定窗口计数器,令牌桶不会在窗口边界出现双倍请求的问题。但令牌桶只能限制速率,无法感知请求的优先级,如果低优先级请求先到,高优先级请求同样需要排队等令牌,这正是它需要与优先级机制配合的原因。为了让配额更灵活,还可以引入自适应策略,例如根据下游健康度调整补充速率,当错误率升高时减少发放令牌,当系统空闲时允许借用其他租户未使用的配额。
优先级调度:静态排序、抢占与饥饿预防
优先级调度解决的是资源不足时先处理谁的问题。实现上通常使用优先队列,按照优先级数值排序,数值越小越先出队。简单的优先队列适合任务调度场景,例如线程池中的任务按优先级取出执行。基于堆的优先队列插入和弹出时间复杂度都是 O(log n),适合高并发下的实时调度。下面是一段用 Go 的 container/heap 实现的优先队列:
package main
import (
"container/heap"
"fmt"
)
type Task struct {
Name string
Priority int
index int
}
type PriorityQueue []*Task
func (pq PriorityQueue) Len() int { return len(pq) }
func (pq PriorityQueue) Less(i, j int) bool {
return pq[i].Priority < pq[j].Priority
}
func (pq PriorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
pq[i].index = i
pq[j].index = j
}
func (pq *PriorityQueue) Push(x interface{}) {
n := len(*pq)
item := x.(*Task)
item.index = n
*pq = append(*pq, item)
}
func (pq *PriorityQueue) Pop() interface{} {
old := *pq
n := len(old)
item := old[n-1]
old[n-1] = nil
item.index = -1
*pq = old[0 : n-1]
return item
}
静态优先级容易实现,但如果高优先级任务持续到达,低优先级任务可能长时间得不到执行,这就是饥饿。解决方法包括老化机制,即等待时间越长,动态提升优先级;或者使用多级反馈队列,让任务在多个优先级层级之间流转,刚进入时放在高优先级队列,用完后降级,等待过久又升级。抢占式调度是另一种增强手段,当高优先级任务到达时,可以暂停当前低优先级任务,保存现场并切换到高优先级。但抢占会增加上下文切换开销,还可能引发优先级反转问题。优先级反转的典型场景是低优先级任务持有锁,高优先级任务等待该锁,中优先级任务抢占 CPU,导致高优先级任务被中优先级任务长期阻塞。解决方式有优先级继承,让低优先级任务临时获得高优先级以避免阻塞。
配额与优先级结合:构建可落地的资源治理策略
单独使用配额或单独使用优先级都有明显短板。配额保证了公平性和隔离性,但无法保证关键任务优先获得资源;优先级保证了关键任务快速响应,但如果没有配额限制,高优先级任务可能把资源全部占满,导致其他任务永远得不到执行,甚至拖垮系统。因此实际系统需要把两者结合,例如在配额内按优先级分配,配额之外允许抢占或借用,同时给每个租户设置硬上限防止全局饿死。
常见组合方式是加权公平队列,给每个队列设置权重作为配额比例,队列内部按优先级排序。这样可以兼顾租户之间的公平和同一租户内部的关键程度。还有多级反馈队列,高优先级队列时间片短,低优先级队列时间片长,任务在消耗完时间片后降级,等待过久则升级。这种设计在操作系统调度和 API 网关限流中都很常见。落地时,服务端可以在网关层做配额和优先级控制,例如基于用户等级划分配额,VIP 用户请求进入高优先级队列。客户端也应配合实现退避重试,避免在服务端返回限流错误后立即重试。监控方面需要关注配额使用率、等待队列长度、饥饿任务数量和优先级反转事件。配额和优先级不是一次性配置,需要根据业务量和资源容量持续调整,才能真正做到资源争抢的可控与有序。