RADIUS协议定义了Accounting-Request和Accounting-Response两种报文,计费服务器监听UDP 1813端口,接收NAS(网络接入服务器)发来的计费开始、计费更新、计费结束事件。每个请求包含若干属性,例如Acct-Session-Id、Acct-Status-Type、Acct-Input-Octets、Acct-Output-Octets、Acct-Session-Time等。这些记录需要可靠地写入数据库,供后续计费对账和用户查询使用。由于UDP无连接、无重传,计费服务端必须尽快处理并持久化,否则在高并发下很容易丢包。Ruby虽然以开发效率著称,但如果直接使用ActiveRecord逐条insert,在面对每秒上千条请求时就会出现明显的性能瓶颈。接下来我们围绕解析、入库和表结构三个环节展开优化。

从网络包到数据库记录,主要开销集中在三个地方:二进制解析的CPU消耗、数据库连接的建立与释放、SQL语句的执行与事务提交。理解了这些瓶颈,就可以有针对性地设计缓冲策略和批量写入方案。下面先看如何高效解析RADIUS报文。
RADIUS计费数据的解析与字段提取
RADIUS报文由头部和属性列表组成,头部20字节,包含Code、Identifier、Length、Authenticator。每个属性由1字节类型、1字节长度和后续值构成。Ruby中可以使用UDPSocket或Socket.udp_server_loop监听1813端口,读取原始字节后用unpack逐段切分。下面是一个简单的解析函数,它接收UDP数据并返回属性哈希:
def parse_radius_accounting(data)
code, identifier, length = data.unpack('CCn')
return {} if length < 20
authenticator = data.byteslice(4, 16)
payload = data.byteslice(20, length - 20)
attributes = {}
offset = 0
while offset < payload.bytesize
attr_type, attr_len = payload.byteslice(offset, 2).unpack('CC')
break if attr_len < 2
value = payload.byteslice(offset + 2, attr_len - 2)
attributes[attr_type] = value
offset += attr_len
end
attributes
end
这个函数逐属性读取,避免了正则表达式和多余的内存拷贝。实际项目中还需要把属性类型映射成可读字段,比如类型40对应Acct-Status-Type,类型44对应Acct-Session-Id,类型42对应Acct-Input-Octets。整数值可以使用unpack('N')按大端序转换,IP地址可以通过IPAddr.ntop还原。这些转换操作如果放在每条记录的解析路径上,会占用不少CPU时间,建议只转换当前计费流程需要的字段,其他原始值直接存储或稍后批量处理。
解析性能的另一个关键点是避免在循环中创建大量临时对象。上面代码中byteslice返回新字符串,如果每个包有几十个属性,就会产生几十个短生命周期对象。可以把原始数据缓存在一个缓冲区中,利用偏移量直接读取,需要持久化时再复制必要部分。对于每秒几千个请求的场景,这个优化可以减少GC压力,降低停顿频率。
把计费记录高效写入数据库的几种策略
部分团队最初实现计费存储时,会在收到每个Accounting-Request后直接执行一条INSERT语句。这个做法在小流量下没有问题,但随着请求量增长,单条插入的数据库往返时间、事务提交产生的磁盘刷写、以及连接池耗尽都会迅速成为瓶颈。以PostgreSQL为例,每条插入通常需要一次网络往返和一次WAL刷盘,每秒最多几百条。优化思路是减少SQL往返次数和事务数量。
最直接的方法是批量插入。可以把多条记录的字段值拼成一个INSERT INTO ... VALUES (...), (...), (...)语句,一次提交。数据库端只需要执行一次解析和一次事务提交,吞吐量通常能提高5到10倍。下面的Ruby代码展示了如何将解析后的记录数组拼接成批量SQL:
def build_batch_insert(records)
values = records.map do |r|
"('#{r[:session_id]}', #{r[:status_type]}, #{r[:input_octets]}, #{r[:output_octets]}, #{r[:session_time]})"
end.join(', ')
"INSERT INTO radacct (acctsessionid, acctstatustype, acctinputoctets, acctoutputoctets, acctsessiontime) VALUES #{values}"
end
除了批量插入,使用预处理语句和连接池也非常重要。Ruby的pg驱动支持prepare语句,Sequel和ActiveRecord都有连接池管理。把批量插入放在一个事务中,让数据库在提交前只刷一次WAL,能进一步减少磁盘IO。还可以考虑使用activerecord-import库,它封装了各种数据库的批量导入细节。但要小心内存占用:批量大小建议控制在500到2000条,太大容易导致单条SQL过长和锁表。
另一种思路是写入时延后再异步处理,这会在下一节展开。总体来说,数据库写入优化先从SQL执行次数、事务开销和连接复用三个方面入手,往往能获得最直接的收益。
异步缓冲与批量提交的实现
单纯靠业务代码逐条收集然后批量写入,要么需要客户端等待缓冲区填满,要么需要复杂的同步逻辑。更实用的方案是引入一个内存队列,主线程解析完RADIUS包后立即把记录放入队列并返回,后台线程负责按照时间间隔或队列长度阈值批量写入数据库。这相当于在数据库前面加了一层缓冲,能够平滑处理突发流量,避免数据库瞬间过载。
下面是一个基于RubyQueue和Thread的简单实现:
require 'thread'
class AccountingWriter
def initialize(db, batch_size: 1000, flush_interval: 1.0)
@db = db
@batch_size = batch_size
@flush_interval = flush_interval
@queue = Queue.new
@buffer = []
@mutex = Mutex.new
@last_flush = Time.now
@thread = Thread.new { run }
end
def enqueue(record)
@queue << record
end
private
def run
loop do
begin
record = @queue.pop(true)
@buffer << record
rescue ThreadError
# queue empty, will flush below
end
if @buffer.size >= @batch_size || (Time.now - @last_flush) >= @flush_interval && !@buffer.empty?
flush
else
sleep 0.05
end
end
end
def flush
records = @mutex.synchronize { @buffer.dup }
@buffer.clear
sql = build_batch_insert(records)
@db.exec(sql)
@last_flush = Time.now
end
end
这个实现把网络接收和数据库写入解耦,但要注意内存队列不是持久化存储,进程崩溃或重启会丢失缓冲区中的记录。对于计费数据,如果允许少量丢失,这个方案足够;如果不允许丢失,应该把队列换成可靠的中间件,比如Redis的LPUSH/BRPOP或使用Sidekiq。可靠性提升的同时也会引入额外的网络延迟和运维复杂度,需要根据业务等级权衡。
此外,后台线程的批量刷新间隔和批量大小要根据数据库的承载能力调整。如果刷新间隔太短,批量效果不明显;如果太长,查询延迟和内存占用会增加。建议初期设置1秒和1000条,通过监控数据库写入延迟和队列长度逐步调优。
表结构、索引与归档优化
即使上层写入逻辑已经优化,数据库表结构不合理也会拖累写入速度。常见问题是把所有历史记录堆在一张表里,索引越建越多,每次插入都需要维护多个B+树索引,时间久了写入性能会持续下降。针对RADIUS计费记录,最好的做法是设计一个精简的当前表,只保留查询必须的索引,同时按时间对数据进行分区或定期归档。
建表时字段类型尽量匹配实际数据。会话ID使用VARCHAR(32),计费状态和会话时长使用SMALLINT UNSIGNED或INT UNSIGNED,输入输出字节数使用BIGINT UNSIGNED。索引方面,通常只需要一个基于会话ID的唯一索引和一个基于计费结束时间的普通索引。过多的联合索引虽然能加速某些报表查询,但会显著降低插入速度。下面是一个简化的PostgreSQL建表示例:
CREATE TABLE radacct ( id BIGSERIAL PRIMARY KEY, acctsessionid VARCHAR(32) NOT NULL, acctstatustype SMALLINT NOT NULL, acctinputoctets BIGINT NOT NULL DEFAULT 0, acctoutputoctets BIGINT NOT NULL DEFAULT 0, acctsessiontime INTEGER NOT NULL DEFAULT 0, acctstoptime TIMESTAMP WITH TIME ZONE, UNIQUE (acctsessionid, acctstatustype) );
当表数据量达到千万级后,建议按月份做范围分区,或者定期将已结束的会话记录迁移到归档表。分区表不仅能在查询时裁剪分区,还能在删除历史数据时使用DROP PARTITION秒级完成,避免大表DELETE引发的锁和日志膨胀。如果使用MySQL,PARTITION BY RANGE COLUMNS(acctstoptime)可以按时间自动分片;对于PostgreSQL 10以上版本,原生声明式分区也很容易实现。分区后写入性能可以得到稳定保持。
最后提醒一句,优化写入不是一次性工作,需要持续观察数据库的慢查询、锁等待和磁盘IO指标。很多情况下,性能问题来自某个不合理的索引、一次意外的全表扫描,或者批量脚本在高峰期运行。把Ruby应用、数据库和执行计划放在一起分析,才能找到真正的瓶颈。