RADIUS不仅负责认证和授权,Accounting(计费)机制同样是它的重要组成部分。运营Wi-Fi热点、VPN服务或者校园网时,经常需要回答两个问题:一个账号最多允许几台设备同时在线?会话空闲多久之后应该被强制下线?这两个需求本质上都是会话管理问题。本文用Ruby从零实现一套RADIUS会话管理模块,覆盖会话状态表、并发数限制、超时控制以及分布式部署时的会话共享。

理解RADIUS会话的生命周期
RADIUS的会话由一串Accounting报文来描述。用户认证成功后,NAS(网络接入服务器,比如路由器或无线控制器)会先发送Accounting-Start报文,标记会话开始;会话期间可能周期性发送Interim-Update报文汇报流量;用户下线或被踢出时发送Accounting-Stop报文。每一对Start和Stop之间的时间窗口,就是一个活跃会话。
识别独立会话靠两个关键属性:Acct-Session-Id是NAS为每次会话生成的唯一标识,同一个用户重复登录会得到不同的Session-Id;User-Name标识账号本身。所以判断一个账号有几个会话在线,就是统计该User-Name下有多少个已Start但未Stop的Session-Id。另外,Acct-Status-Type字段取值为1、2、3时分别对应Start、Stop、Interim-Update,处理逻辑要按这个分支走。
还有一个容易忽略的细节:NAS重启或网络异常时,Stop报文可能永远发不出来。如果服务端只依赖Stop报文释放会话,会话表就会越积越多,出现“幽灵会话”,导致用户明明已经下线却被计数器卡住无法重新登录。所以超时清理机制不是可选项,而是必需品,后文会专门处理。
设计会话状态表与并发数限制
会话管理核心是一张哈希结构的状态表。每个会话记录包含用户名、Session-Id、NAS地址、开始时间、最近更新时间和 Framed-IP-Address。下面是基础的会话管理器实现:
class RadiusSessionManager
SESSION_LIMIT = 2 # 每个账号最多同时在线2个会话
Session = Struct.new(:user, :session_id, :nas_ip, :started_at, :last_update, :framed_ip)
def initialize
@sessions = {} # session_key => Session
@lock = Mutex.new
end
# 生成会话唯一键,NAS IP + Session-Id 才能全局唯一
def session_key(nas_ip, session_id)
"#{nas_ip}:#{session_id}"
end
# 处理Accounting报文入口
def handle_accounting(attrs)
status = attrs[:'Acct-Status-Type']
user = attrs[:'User-Name']
sid = attrs[:'Acct-Session-Id']
nas = attrs[:'NAS-IP-Address']
case status
when :start
start_session(user, sid, nas, attrs)
when :stop
stop_session(nas, sid)
when :interim
touch_session(nas, sid)
end
end
def start_session(user, sid, nas, attrs)
@lock.synchronize do
if active_count(user) >= SESSION_LIMIT
return { allowed: false, reason: "session limit exceeded" }
end
key = session_key(nas, sid)
@sessions[key] = Session.new(
user, sid, nas, Time.now, Time.now, attrs[:'Framed-IP-Address']
)
{ allowed: true }
end
end
def stop_session(nas, sid)
@lock.synchronize do
@sessions.delete(session_key(nas, sid))
end
end
def touch_session(nas, sid)
@lock.synchronize do
s = @sessions[session_key(nas, sid)]
s.last_update = Time.now if s
end
end
def active_count(user)
@sessions.values.count { |s| s.user == user }
end
end
上面的实现有几个值得注意的点。第一,会话键由NAS地址加Session-Id组成,因为不同NAS可能生成相同的Session-Id,只用Session-Id会冲突。第二,所有操作都放在Mutex里,Accounting报文往往由多个线程并发处理,不加锁会导致计数不准。第三,start_session在插入前先检查计数,超限时直接返回拒绝,上层可以据此向NAS下发断开指令(通过Change-of-Authorization报文,即CoA)。
不过只在Start时检查是不够的。某些NAS会在认证阶段就询问能否放行,更稳妥的做法是把并发检查同时挂在Access-Request处理链上:认证通过后先查会话表,达到上限就返回Access-Reject,或者在Access-Accept中不携带IP分配属性,让用户无法真正建立连接。两种方式各有取舍,前者实现简单,后者可以给用户返回明确的失败原因。
实现会话超时与僵尸会话清理
超时控制分两个层面。第一个层面是授权下发的超时属性:Session-Timeout限制会话的绝对时长,到达后NAS主动断开;Idle-Timeout限制空闲时长,用户一段时间没有流量就下线。在Access-Accept报文中加入这两个属性即可:
require 'radiustube' # 构造带超时属性的Access-Accept响应 def build_accept(packet, secret) reply = packet.reply(secret) reply.code = :access_accept # 会话最长4小时 reply['Session-Timeout'] = 4 * 3600 # 空闲30分钟断开 reply['Idle-Timeout'] = 30 * 60 reply end
第二个层面是服务端自身的僵尸会话清理。前面提到Stop报文可能丢失,服务端必须根据Acct-Delay-Time、最近Interim时间以及NAS的Acct-Interim-Interval(通常5到15分钟)推断会话是否已死。一个简单可靠的规则是:超过N个Interim周期没有收到任何更新,就判定会话失效。用定时任务实现如下:
class SessionReaper
STALE_THRESHOLD = 45 * 60 # 45分钟无更新视为僵尸会话
def initialize(manager)
@manager = manager
end
def start(interval: 60)
Thread.new do
loop do
sleep interval
reap_stale_sessions
end
end
end
def reap_stale_sessions
cutoff = Time.now - STALE_THRESHOLD
@manager.each_session do |key, session|
if session.last_update < cutoff
@manager.force_expire(key)
Rails.logger&.info("expire stale session #{key} user=#{session.user}")
end
end
end
end
清理僵尸会话时最好配合Disconnect报文(RFC 5176定义的RADIUS Disconnect-Request)主动通知NAS断开连接。这样即使判断有误,NAS也会回一个确认,服务端能校验会话真实状态。如果NAS回复“会话不存在”,说明本地判断正确;如果NAS没响应或拒绝,就要谨慎处理,避免误踢真实在线用户。
用Redis实现多节点共享会话表
单进程内存表在RADIUS服务器多实例部署时会立刻失效:用户Start报文打到节点A,Stop报文可能打到节点B,两边状态对不上。解决办法是把会话表搬到Redis,利用Redis的原子操作保证并发计数准确。
Redis侧的数据结构建议这样设计:每个会话用一个String键存储,值为JSON序列化的会话信息,过期时间设为僵尸判定阈值,让Redis自动清理;再为每个用户维护一个Set,成员是该用户的所有会话键,用于快速统计在线数。判断并发数时用Lua脚本保证原子性:
require 'redis'
require 'json'
class RedisSessionStore
LIMIT_SCRIPT = <<~LUA
local user_key = KEYS[1]
local limit = tonumber(ARGV[1])
if redis.call('SCARD', user_key) >= limit then
return 0
end
return 1
LUA
def initialize(redis = Redis.new)
@redis = redis
end
def try_start(user, nas_ip, session_id, attrs, limit: 2)
user_key = "rad:user:#{user}"
sess_key = "rad:sess:#{nas_ip}:#{session_id}"
# Lua脚本原子判断是否超限
return false unless @redis.eval(LIMIT_SCRIPT, keys: [user_key], argv: [limit]) == 1
@redis.set(sess_key, {
user: user, nas: nas_ip, started_at: Time.now.to_i,
framed_ip: attrs[:'Framed-IP-Address']
}.to_json, ex: 45 * 60)
@redis.sadd(user_key, sess_key)
@redis.expire(user_key, 45 * 60)
true
end
def stop(nas_ip, session_id)
sess_key = "rad:sess:#{nas_ip}:#{session_id}"
data = @redis.get(sess_key)
return unless data
@redis.srem("rad:user:#{JSON.parse(data)['user']}", sess_key)
@redis.del(sess_key)
end
def touch(nas_ip, session_id)
sess_key = "rad:sess:#{nas_ip}:#{session_id}"
data = @redis.get(sess_key)
if data
@redis.expire(sess_key, 45 * 60)
user = JSON.parse(data)['user']
@redis.expire("rad:user:#{user}", 45 * 60)
end
end
end
这套方案把并发判断放进Lua脚本,避免了“先SCARD再SADD”两步操作之间的竞态窗口——如果没有原子性保证,两个请求同时通过检查就会突破上限。会话键设置TTL后,即使Stop报文彻底丢失,Redis也会在45分钟后自动删除键。唯一的残留问题是用户Set中的成员不会随String键过期而移除,所以还需要一个低频的补偿任务扫描Set成员,剔除已经不存在的会话键,或者在touch和统计时惰性过滤。
最后提醒一下时区和时间源问题。多节点部署时各服务器时钟要严格NTP同步,否则基于时间戳的超时判断会出现偏差。生产环境还建议把每次Accounting事件写入日志或时序数据库,既是审计依据,也能在用户投诉“为什么被踢下线”时有据可查。经过这些设计,一套Ruby实现的RADIUS会话管理就能稳定支撑数万级别的并发在线用户了。
RADIUS会话管理Ruby网络编程同时在线数限制修改时间:2026-09-16 17:06:57