基于 LRU 的流表老化:工程实践

张开发
2026/4/3 16:14:49 15 分钟阅读
基于 LRU 的流表老化:工程实践
业务背景报文按五元组源/目的 IP、源/目的端口、VPN 等识别业务并为每条业务维护一个流对象。不同协议通常分表存放如 TCP、UDP、ICMP。idle 时间可按协议区分本文约定TCP、UDP 为 3 分钟ICMP 为 15 秒可按现网调整现有实现与痛点定时例如每5 秒做一次全表扫描遍历各流表将判定为老化的流送入老化队列。在数十万级流表规模下全量遍历的 CPU 与缓存开销明显扫描周期与表规模耦合扩展性一般。LRU 算法概要LRULeast Recently Used在容量受限时用访问顺序近似“冷热”每次读/写某个键即视为该键最近被使用若需腾出位置则淘汰最久未被访问的条目。经典实现为std::unordered_mapstd::list哈希表做 O(1) 定位双向链表维护全序命中时用splice调整位置均摊O(1)哈希冲突在平均意义下。作用在有容量上限或需要决定淘汰顺序时优先保留“近期更可能再被访问”的条目。LRU 解决的是淘汰策略不替代业务上的是否应失效例如协议状态机、绝对超时二者常组合使用。优点热点友好高频访问的键停留在链表“热端”不易被误淘汰。均摊 O(1)查找、调整次序、在已知迭代器下删除代价可控。实现直观顺序由链表显式表达不必维护全表排序或复杂堆结构。决策局部化淘汰时机可与“插入已满”或定时扫描结合避免无差别全表扫描。下面给出定长缓存的最小示例键值为int未命中时get返回-1便于与后续流表实现对照。#includelist#includeunordered_mapclassLruCache{public:explicitLruCache(intcapacity):cap_(capacity){}intget(intkey){autoitidx_.find(key);if(itidx_.end())return-1;lst_.splice(lst_.begin(),lst_,it-second);returnit-second-second;}voidput(intkey,intvalue){autoitidx_.find(key);if(it!idx_.end()){it-second-secondvalue;lst_.splice(lst_.begin(),lst_,it-second);return;}if(static_castint(lst_.size())cap_){idx_.erase(lst_.back().first);lst_.pop_back();}lst_.emplace_front(key,value);idx_[key]lst_.begin();}private:intcap_{};std::liststd::pairint,intlst_;std::unordered_mapint,std::liststd::pairint,int::iteratoridx_;};基于 LRU 的流老化实现单表示例UDP将「五元组 → 流」与 LRU 结合时典型做法是哈希表按 key 查找双向链表按最近访问时间排序。与上一节LruCache最近用在表头的演示不同下面按老化扫描习惯采用链表头front最久未被命中的流优先做超时判断。链表尾最近一次命中的流MRU 端。报文命中时更新lastHitSec并把结点splice到表尾。若全表 idle相同此处 UDP 为3 分钟从表头扫描时一旦表头未超时其后结点更“新”可提前结束与定时全表扫描相比显著减少无效遍历。#includecstdint#includefunctional#includelist#includeunordered_map#includeutilitystructFlowKey{std::uint32_tsrcIp{};std::uint32_tdstIp{};std::uint16_tsrcPort{};std::uint16_tdstPort{};std::uint32_tvpnId{};booloperator(constFlowKeyo)const{returnsrcIpo.srcIpdstIpo.dstIpsrcPorto.srcPortdstPorto.dstPortvpnIdo.vpnId;}};structFlowKeyHash{std::size_toperator()(constFlowKeyk)constnoexcept{std::size_t hk.srcIp;h^std::hashstd::uint32_t{}(k.dstIp)0x9e3779b9(h6)(h2);h^std::hashstd::uint32_t{}(k.srcPort)0x9e3779b9(h6)(h2);h^std::hashstd::uint32_t{}(k.dstPort)0x9e3779b9(h6)(h2);h^std::hashstd::uint32_t{}(k.vpnId)0x9e3779b9(h6)(h2);returnh;}};structFlowEntry{std::uint64_tlastHitSec{};};classUdpFlowTable{public:staticconstexprstd::uint64_tkUdpTtlSec3*60;voidonHit(constFlowKeykey,std::uint64_tnowSec){autoitidx_.find(key);if(it!idx_.end()){it-second-second.lastHitSecnowSec;lru_.splice(lru_.end(),lru_,it-second);return;}lru_.emplace_back(key,FlowEntry{nowSec});idx_.emplace(key,std::prev(lru_.end()));}voidagingScan(std::uint64_tnowSec){while(!lru_.empty()){constautoheadlru_.front();if(nowSec-head.second.lastHitSeckUdpTtlSec)break;idx_.erase(head.first);lru_.pop_front();}}std::size_tsize()const{returnlru_.size();}private:usingNodestd::pairFlowKey,FlowEntry;std::listNodelru_;std::unordered_mapFlowKey,std::listNode::iterator,FlowKeyHashidx_;};说明agingScan中表头一旦未超时即break依赖不变量「表头 全局最久未更新」若存在不经过onHit更新时间的路径需自行保证链表顺序不被破坏。跨线程场景下的 LRU 更新消息与散列问题背景多线程并发收发报文命中某条业务流后需向流管理线程发送LRU 结点更新消息。为抑制抖动对每条流设置了发送等待时间节流。流量突增时大量流在同一时刻满足发送条件更新消息在时间上扎堆易造成队列拥塞、丢包进而导致 LRU 链与真实访问顺序不一致。思路将“发送更新消息”的时刻在时间上摊开为等待时间设定一个区间[base, base range)具体单位与实现一致如毫秒。利用每条流唯一的lru_index流对象内保存的 LRU 结点索引对range取模把不同流映射到区间内不同偏移。实际等待时间取base (lru_index % range)若与现有节流字段叠加注意上限与溢出。这样同一突发窗口内各流的通知时刻在range上大致均匀分布降低瞬时洪峰不改变 LRU 的语义只改变何时把“命中”同步到管理线程。总结问题大规模流表下周期性全表扫描老化成本高多线程命中后集中上报 LRU 更新易造成消息突发与丢包。LRU 与流表用哈希 双向链表维护「五元组 → 流」与访问顺序表头最久未命中、命中移尾时在统一 idle下可从表头做超时判断并提前结束避免每次扫全表。跨线程在节流基础上对发送时刻做按lru_index的散列把更新打散到[base, base range)缓解拥塞、利于链表与真实流量一致lru_index可为你在流对象中自定义的 LRU 结点索引或等价标识。落地注意**idle是否超时**与 **LRU先后顺序**是两层逻辑

更多文章