简介:来自上海师范大学学报(自然科学版)2021年第1期的一篇学术论文,针对软件定义网络(SDN)中的流量工程(TE)问题,提出基于深度强化学习的DRL-Routing路由算法。论文面向网络工程研究者、学生及SDN实践者,从强化学习原理、总体架构设计到实验验证进行了系统阐述,展示了该算法较OSPF、最小负载LL等传统路由算法在吞吐量、时延、丢包率上的性能优势,并探讨了深度强化学习在SDN流量工程、网络优化、安全检测等领域的应用前景。资源包为单份PDF格式,共1个文件,大小1.36MB,内容包含中英文摘要、关键词、全文图表及参考文献,适合作为算法学习、论文写作或课题立项时的参考文献。已有651人学习下载。阅读后可获取完整的DRL-Routing算法框架、状态与奖励函数设计,以及仿真实验的对比思路,对借助深度学习解决网络路由优化问题的研究人员有直接参考价值。
1. 深度强化学习与SDN路由算法:把流量从“爱堵的路”上挪走
凌晨三点跑的压测又翻车了:流量从东区涌向四区,OSPF算出的最短路径被打到90%利用率,旁边几条空载链路却在看戏。做过数据中心网络优化的人基本都撞过这个场景——传统路由算法只认拓扑距离,不认实时负载,网络一忙就容易一头热。基于深度强化学习的SDN路由算法,思路改得非常直接:让SDN控制器里的智能体持续观察网络状态,用深度强化学习算法训练策略网络,在流量模式变化时动态调整转发路径,把热点链路上的压力迁到空闲链路上。
这个标题落到工程上,其实是三件事:把路由问题改造成强化学习问题,用神经网络对状态和动作做拟合,再借SDN的OpenFlow流表机制把“换一条路走”变成可执行的转发表。论文里常见的框架也绕不开这三步,区别只集中在状态怎么定义、动作空间怎么约束、训练怎么稳定下来。这篇文章就按一条能复现的路线写:从哪里入手、最小可跑代码怎么组织、参数怎么调、哪些坑会让训练一夜回到解放前。
2. 把路由构造成马尔可夫决策过程:状态、动作与奖励怎么定
传统路由算法本质上是周期性的最优化问题,每隔一段时间拿全网的拓扑和流量矩阵重算一遍路径。问题在于流量矩阵本身是时变的,采集周期短了控制器扛不住,周期长了热点链路早就被打穿。强化学习的视角切换了一个目标:不再追求“某一刻的全局最优路径”,而是在和网络环境持续交互的过程中,累积尽量高的长期奖励。这个视角和SDN控制器的天然结构非常匹配,控制器既拿得到网络状态,又拥有下发流表的执行能力。
但把路由问题塞进强化学习框架,第一个要过的坎是状态空间和动作空间的定义。很多初学者一上来就把“每条链路的带宽利用率离散化成10档”,然后写Q表格,这种方案在小规模拓扑里还能转,拓扑超过10条链路就彻底爆掉。第二个坎是动作空间,路由决策从本质上说是离散的“选路径”问题,如果动作直接定义为“某个流的下一跳”,这条路径的组合数会随拓扑规模指数增长,强化学习算法根本探索不完。常见做法是先离线计算K条备选路径,动作空间固定成“在这K条里选一条”。
2.1 为什么Q表格在路由场景必然走不通
Q表格的容量是状态数乘动作数。以一条只有20条链路的网络为例,每条链路利用率按百分之一为粒度观察,就是100的20次方级别的状态组合,这还没算流的数量和排队时延。用表格记录所有状态对应的Q值,在物理上就不成立。所以基于深度强化学习的SDN路由算法一定会选择深度网络做值函数逼近,把“状态到Q值”的映射压缩到一组参数里。
动作空间也需要提前“降维”。我见过最朴素也最稳的做法是:拿到拓扑之后,先用Dijkstra或Yen算法为每个源目的对算出K条备选路径,比如K取3到8条,组成固定路径池。DRL智能体不再决定“怎么一步一步走”,而是决定“在这个流的所有备选路径中选第几条”。这样的好处有两个:一是动作维度固定,神经网络输出层可以直接映射到K个动作;二是这些路径本身就满足基本连通性,模型不会学出一个五环路来。
这类论文里真正拉开差距的往往不是算法结构,而是路径池的质量。备选路径不能只看跳数,要把链路容量、历史拥塞程度也纳入排序依据。我第一次复现时直接用K最短路径做池子,结果两条路径都共享同一条瓶颈链路,模型再怎么选都在同一个堵点上打转,训练曲线也一直没起色。
2.2 状态、动作、奖励的三段式定义:一组可以直接改着用的模板
在路由场景里,状态至少应该描述“网络当前忙不忙”和“可选路径长什么样”。常见做法是把每条链路的带宽利用率、剩余容量、平均时延拼接成向量,再做归一化。下面这一段是构造状态特征的常用模板:
import numpy as np def build_state(link_util, link_capacity, path_pool, demand): """ link_util: {link_id: 当前bps} link_capacity: {link_id: 链路容量bps} path_pool: [[link_id, ...], ...] 备选路径 """ state = [] for link_id in sorted(link_capacity.keys()): util = link_util.get(link_id, 0.0) cap = link_capacity[link_id] usage = util / cap if cap > 0 else 0.0 state.append(usage) # 剩余带宽同样重要,避免模型把“打满但队列正常”和“打满且拥塞”混为一谈 state.append(max(0.0, 1.0 - usage)) # 路径平均跳数归一化,给模型一个“长度差异”的先验 max_hops = max(len(p) for p in path_pool) for p in path_pool: state.append(len(p) / max_hops) return np.asarray(state, dtype=np.float32)这段代码的逻辑是:把链路利用率压到0到1之间,让神经网络输入范围一致;剩余带宽别丢掉,它是网络还能吃下多少流量的直接证据。路径池里的跳数信息也要拼进去,否则模型无法区分“绕路但空”和“直线但堵”这两种选择。参数上有一个值得注意的细节:如果拓扑里有两条容量差距很大的链路,直接把绝对利用率拼进去会让模型偏向大容量链路,容量小的链路永远得不到流量。我一般会把链路容量也作为特征加入,或者先按容量分桶再归一化。
奖励设计是路由强化学习最玄学的部分。太复杂的奖励函数会让梯度信号互相打架,太简单又学不出均衡效果。我常用的一套是:
r_t = w1 * (u_prev - u_now) + w2 * (th_now - th_prev) - w3 * switch_penalty
其中u是当前最大链路利用率,th是全网吞吐量,switch_penalty在路径切换时取1、否则取0。这样设计的用意是:鼓励模型降低“最堵的那条链路的负担”,兼顾吞吐提升,同时为频繁切换路径的行为加惩罚,防止流表抖动把网络搞乱。权重我一般先设w1=1.0,w2=0.5,w3=0.2,后面根据训练曲线再调。注意不要在奖励里直接加绝对值很大的时延惩罚,不同交换机厂商和不同仿真环境里时延基线不一致,模型很容易被角落里的噪声主导。
2.3 DDQN为什么是这类论文的事实标准
原生DQN在路由场景里有一个很讨厌的毛病:它用同一个网络选择动作又评估动作,max操作会系统性地高估Q值,结果训练初期模型往往选择“看上去很值、实际很烂”的路径。路由场景奖励本身噪声就大,这种过估计会被放大。DDQN的修改非常小:当前网络选动作,目标网络算Q值,把选择器和评估器分开,过估计一下就压住了。这也是深度强化学习领域的论文里,路由方向普遍拿DDQN当起点的原因。
在此基础上,Dueling架构也值得优先尝试。它把Q值拆成状态价值和动作优势两部分,而路由的很多状态本身优劣明显——全网都很空的时候无论选哪条路径差距都不大,Dueling网络能更好地学到这种“状态本身就不错”的信息。我在复现时直接做“DDQN + Dueling + 优先经验回放”的组合,成本只多十几行代码,收敛稳定性却明显好于原生DQN。PPO当然也能做路由,它更适合动作空间连续的场景,比如把流量按比例拆分到多条路径;但最小可复现项目里要处理连续动作到流表的映射,还要处理策略分布的方差问题,工作量会大不少,一上来不建议直接选它。
下面这张表是我做选型时常用的判断依据:
| 算法 | 动作类型 | 过估计风险 | 实现成本 | 适用场景 |
|---|---|---|---|---|
| Q-Learning | 离散 | 不涉及但状态爆炸 | 低 | 10条链路以内的小拓扑 |
| DQN | 离散 | 明显 | 低 | 作为基线对照 |
| DDQN | 离散 | 明显缓解 | 低 | 路由优化的默认选择 |
| PPO | 连续/离散 | 不涉及 | 中高 | 做多路径流量拆分时再考虑 |
3. 在Mininet+Ryu复现一个能跑的最小DRL路由算法
很多论文的复现难点不在模型代码,而在模型和网络控制器之间的闭环。强化学习需要不断“决策→下发→观察反馈→再决策”,这个闭环只要有一个环节对不上,训练曲线就会变成心电图。我建议最小实现只保留三层:Mininet负责模拟网络环境,Ryu控制器负责采集状态和下发流表,DDQN智能体负责输出动作。下面按这个分层把代码拆开写。
3.1 搭一套四节点拓扑:用Python脚本而不是敲mn交互命令
最小实验拓扑不用大,四台交换机四台主机足够看出路由算法的差异。我直接写一个拓扑脚本:
from mininet.topo import Topo from mininet.link import TLink class DrlTopo(Topo): def build(self): h1 = self.addHost('h1') h2 = self.addHost('h2') s1 = self.addSwitch('s1') s2 = self.addSwitch('s2') s3 = self.addSwitch('s3') s4 = self.addSwitch('s4') # 带宽和时延差异要拉开,否则模型学不到“换路”的价值 self.addLink(s1, s4, bw=20, delay='3ms') self.addLink(s1, s2, bw=10, delay='5ms') self.addLink(s2, s4, bw=10, delay='5ms') self.addLink(s1, s3, bw=5, delay='10ms') self.addLink(s3, s4, bw=5, delay='10ms') self.addLink(h1, s1) self.addLink(h2, s4) topos = {'drltopo': DrlTopo}这段脚本里TCLink是关键。默认Mininet链路没有带宽限制,所有数据包都是秒传,根本造不出拥塞,强化学习就拿不到有区分度的奖励信号。用bw和delay把三条路径的“容量”拉开,比如直连路径20Mbps、经过s2的路径10Mbps、经过s3的路径5Mbps,这样动作选择产生的结果差异才足够大。启动命令是:
sudo mn --custom topo_drl.py --topo drltopo --controller=remote,ip=127.0.0.1,port=6633 --switch ovsk,protocols=OpenFlow13注意要指定protocols=OpenFlow13,Ryu默认走OpenFlow 1.3,两边版本不一致会一直报握手失败。拓扑起来后先做一次连通性测试,再开始写强化学习逻辑,连不上就训练,纯属浪费时间。
3.2 DDQN智能体:网络结构、经验池与训练函数
模型部分我习惯用PyTorch写,结构保持简单:两层128维的全连接,输出单元个数等于路径池大小。关键是要用Dueling结构,把状态价值和动作优势分开:
import torch import torch.nn as nn import numpy as np import random from collections import deque class DuelingDQN(nn.Module): def __init__(self, n_state, n_action): super().__init__() self.feature = nn.Sequential( nn.Linear(n_state, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU() ) self.value = nn.Linear(128, 1) self.advantage = nn.Linear(128, n_action) def forward(self, x): feat = self.feature(x) v = self.value(feat) a = self.advantage(feat) # 动作优势减去均值,保证可辨识性 return v + a - a.mean(dim=-1, keepdim=True) class DqnAgent: def __init__(self, n_state, n_action, lr=1e-4, gamma=0.99, tau=0.005): self.online = DuelingDQN(n_state, n_action) self.target = DuelingDQN(n_state, n_action) self.target.load_state_dict(self.online.state_dict()) self.optim = torch.optim.Adam(self.online.parameters(), lr=lr) self.buffer = deque(maxlen=200000) self.gamma = gamma self.tau = tau self.epsilon = 0.5 def act(self, state): if random.random() < self.epsilon: return random.randint(0, self.online.advantage.out_features - 1) with torch.no_grad(): q = self.online(torch.FloatTensor(state).unsqueeze(0)) return int(q.argmax(dim=-1).item()) def update(self, batch_size=64): if len(self.buffer) < batch_size: return batch = random.sample(self.buffer, batch_size) s, a, r, s2, d = zip(*batch) s = torch.FloatTensor(np.array(s)) s2 = torch.FloatTensor(np.array(s2)) r = torch.FloatTensor(r).unsqueeze(-1) d = torch.FloatTensor(d).unsqueeze(-1) with torch.no_grad(): # DDQN 的关键:online网络选动作,target网络给值 next_action = self.online(s2).argmax(dim=-1, keepdim=True) q_next = self.target(s2).gather(1, next_action) y = r + self.gamma * q_next * (1 - d) q_now = self.online(s).gather(1, torch.LongTensor(np.array(a)).unsqueeze(-1)) loss = nn.functional.mse_loss(q_now, y) self.optim.zero_grad() loss.backward() self.optim.step() # 软更新,把online参数慢慢挪到target上 for tp, op in zip(self.target.parameters(), self.online.parameters()): tp.data.copy_((1 - self.tau) * tp.data + self.tau * op.data)训练更新里最值得注意的就是那个next_action = self.online(s2),这是DDQN和原生DQN唯一的算法差异,但正是这一步抑制了Q值过估计。经验池容量200000在路由场景里不能太大,网络状态会随着流量模式不断漂移,太久远的历史样本反而干扰当前策略。epsilon初始设0.5是合理的,路由动作探索成本低、换一条路顶多丢几个包,没必要像游戏场景那样从1.0开始慢慢退火。tau=0.005属于软更新的常用值,目标网络动得太快会不稳定,动得太慢则学习过程会滞后。
3.3 训练循环与控制器的配合:动作怎么变成OpenFlow流表
模型算出一个动作后,要变成真正的转发表项,这一步卡住过很多人。Ryu控制器收到PacketIn事件后,需要根据当前状态调用智能体,再选出的路径转化为交换机上的多级流表:
@set_ev_cls(ofp_event.EventOFPPacketIn, MAIN_DISPATCHER) def packet_in_handler(self, ev): msg = ev.msg datapath = msg.datapath in_port = msg.match['in_port'] eth_dst = msg.match['eth_dst'] state = build_state(self.link_util, self.capacity, self.path_pool, self.demand) action = agent.act(state) path = self.path_pool[action] # 路径池里的一组链路ID # 为路径上每一跳安装流表,形成转发链 for i in range(len(path) - 1): match = ofp_match(datapath, in_port=path[i], eth_dst=eth_dst) actions = [ofp_action_output(datapath, path[i + 1])] install_flow(datapath, match, actions, idle_timeout=30) # 延迟到下一个决策周期再收集网络反馈 self.pending_updates.append((state, action, datapath))这段代码背后的逻辑是:路径池里的每个元素不是一跳,而是一整条链路的序列,所以安装流表时要沿着整条路径逐跳下发。idle_timeout=30这个参数很重要,它保证流表在30秒空闲后自动老化,不会让一条旧路径永久占据交换机,模型换路径时才有机会重新路由。如果没有这个老化机制,哪怕模型已经决定走新路径,旧流表依然会把后续数据包导到堵死的链路上。
真正的训练循环并不在模型代码里,而在控制器和网络环境的交互节奏中。我常用的节奏是:控制器每收到一个周期的网络统计,就构造状态、执行动作、下发流表,等下一个周期再收集时延和利用率变化作为奖励,然后存进经验池并触发一次update()。采集统计的周期建议设在3到5秒,太短统计噪声大,太长则一条经验要等很久才能积累够。
4. 把训练调到稳定收敛:学习率、奖励尺度与更新频率的配合
模型结构只是第一个台阶,真正让人熬夜的是训练收敛问题。路由场景有一个和游戏AI不一样的地方:网络状态本身会随着模型的行为而改变,这导致训练过程非常不稳定,经验回放里的旧样本可能已经和当前网络状态对不上。下面这组参数是我在多个仿真拓扑上反复试出来的起点,直接复制大概率能跑,再根据曲线微调。
4.1 一组能跑起来的基线参数
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 学习率 | 1e-4 ~ 3e-4 | 超过1e-3容易震荡 |
| 批大小 | 64 | 路由状态平稳,大一点更稳 |
| 经验池容量 | 100000 ~ 200000 | 不要按游戏场景设到1e6 |
| 折扣因子γ | 0.99 | 强调长期收益 |
| 软更新系数τ | 0.005 | 慢一点比快一点安全 |
| epsilon退火 | 0.5 → 0.05 | 路由探索成本低,不用从1.0开始 |
| 奖励权重w1/w2/w3 | 1.0 / 0.5 / 0.2 | 先固定,后面单独调 |
这个参数组合里,学习率和奖励权重是最容易互相打架的。你把奖励乘了10,但学习率没降,模型就会像喝醉了一样沿着梯度乱撞。我一般先把奖励权重固定,观察loss是否在下降,再动学习率。一次只改一个参数,改完至少跑200个决策周期再下结论,这是血泪经验。
还有一个容易忽略的细节是状态的历史信息。路由场景里,单看当前瞬间的链路利用率是不够的,因为流量本身就是毛刺型波动。我给状态加了一个长度为3的滑动窗口,把前两个周期的利用率和平滑后的趋势值拼进特征向量。这个改动比换任何高级算法都有效,模型能区分“数据突发”和“持续拥塞”,决策质量立刻上了一个台阶。
4.2 三个影响收敛的细节:奖励标定、更新频率、状态归一化
奖励标定最容易被忽视。如果把奖励写成真实的毫秒时延差,数值可能落在几十到几百的范围,TD误差也被放大到同样量级。常见的做法是对奖励做归一化,让它的绝对值落在[0, 1]区间。我用的公式是:
r_t = (current_util_mean - previous_util_mean) + 0.5 * normalized_through_gain - 0.2 * switch_penalty
这样每个分量的量级都是0到1之间,梯度稳定得多。奖励函数的另一个坑是“躺着不动也能拿分”。如果网络空载时利用率本来就是0,模型什么都不做也能得到不错的奖励,它就会学会躺平。解决办法是奖励里加一个“动作前后对比项”,只有因为换路而带来的改善才计分,而不是绝对指标。
更新频率上,我习惯每4个决策周期才调用一次update(),而不是每个step都更新。原因是路由动作的效果需要等一个统计周期才能反馈回来,如果每个step都更新,模型会在奖励还没变化时就反复调整参数,等于在噪声里做梯度下降。批量更新时也建议把batch_size提到64以上,路由场景的reward噪声属于中等偏大,小批量容易被单个异常样本带偏。
状态归一化是第三个关键点,而且是最容易在不知不觉中出错的地方。链路利用率按0~1归一化是最基础的,但有些实现会把时延的原始毫秒值拼进去,时延的特征范围可能是0.1到100,模型会把所有注意力都放到大数值的时延上,完全学不到利用率的语义。正确的做法是把所有特征都压缩到[0,1]或[-1,1]区间。如果时延必须保留,先取对数再归一化,效果远比直接拼原始值好。
5. 复现路上的避坑记录:我被这些问题卡到怀疑人生的几个瞬间
这一章不讲理论,直接写我在复现这类算法时实际撞过的坑。每一条都按现象、原因、解决的顺序写,你在自己的实验里遇到类似情况,可以直接对照排查。
5.1 现象:训练loss降到很低,但网络吞吐和时延毫无改善
刚开始复现时,我盯着loss一路降到0.01,心里还挺高兴,结果跑A/B测试,发现DRL路由和普通静态路由的时延几乎一样。检查之后发现,模型只是把贝尔曼误差压低了,但根本没有学到“不同状态对应不同最优动作”的边界。原因出在状态特征上:链路利用率变化幅度太小,模型看到的输入几乎不变,它自然就学会了“一直输出同一个动作”。解决方法是先跑一段纯监控,把状态向量的每一个维度打印出来,看标准差是否够大。如果所有特征的标准差都趋近于0,说明流量模型太温柔,先加大背景流量制造拥塞,再训练。否则模型只会死记硬背一条Q路径,本质上退化成固定路由。
5.2 现象:流表一下发,网络就开始丢包,时延反而飙升
这个问题在从单路径切换到多路径时特别常见。原因有两层:第一层是控制器在短时间内给交换机堆积了大量流表项,OpenFlow通道被PacketIn消息塞满,控制平面自己先成了瓶颈;第二层是路径切换会让TCP报文乱序,接收端持续触发重传,反而加剧拥塞。解决方法是给流表设置合理的idle_timeout,并且同一个五元组的流在一个决策窗口内只允许切换一次。我在奖励里加了switch_penalty之后,这个问题从机制上被抑制了,模型学会了“不是所有流量都要换路,只有堵的时候才换”。
5.3 现象:Mininet里跑得很好,换到物理交换机上模型完全变傻
这是最打击人的一次“翻车”。原因在于Mininet的链路时延模型是固定值,不随负载变化;而真实交换机的排队时延会在拥塞时急剧上升。模型在仿真里学到的状态到Q值的映射,在真实环境中完全对不上。解决方法是训练阶段就给流量注入扰动:按ON/OFF模式随机启停背景流,或者在重丢包条件下测试。还有一个更实用的习惯:在物理交换机上先跑一轮纯A/B对比,用同一份流量数据回放,验证状态采集模块的时延统计是否准确,然后再上强化学习策略。
5.4 现象:经验池越大,模型反而越笨
这个坑是我在把经验池从20万改成100万之后踩到的。路由场景的状态分布会随着流量矩阵漂移,100万条经验里可能有一大半是旧拓扑、旧流量模式下的历史数据。模型反复在这些过时样本上做梯度下降,自然学不到当前网络的特征。解决方法是把经验池容量压到最近10万到20万个决策周期,或者每隔一段时间按时间衰减权重采样,让新样本占主导。优先经验回放在这里也是一把双刃剑,它会让那些“TD误差大的老经验”反复被抽取,反而拖慢对新环境的适应。
5.5 现象:奖励曲线从负值开始一直震荡,怎么调都收敛不了
训练初期的奖励曲线带一点震荡是正常的,但如果从-5到5之间来回蹦,基本可以判断是奖励尺度和学习率不匹配。我先排查了奖励项的量级,发现时延差动辄几十毫秒,乘积之后奖励落在三位数,Q值被撑得非常大,反向传播的梯度也跟着巨大化。解决方法是把奖励重新标定到[-1, 1]之间,学习率降到1e-5,然后清空经验池重跑。清空经验池这一招看起来简单粗暴,但在路由这种非平稳环境里就是后悔药,旧经验只会让模型在错误的方向上越走越远。
6. 验证与进阶:训练出来的路由策略到底能不能扛住真实流量
模型收敛了不代表可以上线,验证环节比训练更值得花时间。我一般按下面三条线做评估。
第一是A/B对照。把DRL策略和OSPF、ECMP、固定最短路径三条基线放在同一张拓扑下,用相同的流量生成器跑30分钟,指标对比表包括平均端到端时延、最大链路利用率、丢包率和路径切换次数。核心要看的是P95时延,不是平均值,平均值容易被大量轻载流掩盖,P95时延才是用户真实体验。我习惯把每条链路的利用率曲线画出来,DRL策略如果确实在学习,曲线应该比OSPF更平缓,热点链路的峰值至少降10%。
第二是扰动测试。把训练时没见过的流量矩阵灌进去,比如把流量从“东西向”突变成“南北向”,再看模型是否需要重新训练。这一步能暴露状态设计的问题:如果模型只是记住了训练时的流量分布,它面对新流量矩阵就会完全失效。更严格的做法是训练时用多套流量矩阵交替,测试时再拿出一套完全没见过的。如果扰动后性能掉得厉害,说明状态特征还没抽象到位,优先在状态里加入流量的源目的统计维度。
第三是迁移验证。模型在仿真里可用后,把策略网络导出成ONNX,在Ryu控制器的北向接口上固定为推理模式,去掉epsilon探索。实际迁移时要注意流表下发方式:一条一条下发会让控制通道在流量突增时爆掉,用批量Patch接口把整条路径的流表一次性写入,效果会好很多。我自己吃过一次亏,把仿真里训练好的模型直接推上线,结果真实流量一来,链路时延抖动得跟正弦波似的。后来学乖了:先固定模型,关掉探索,在拓扑上叠加扰动流量做压力测试,确认P95时延可控后才真正切换。
这套验证流程跑通之后,你就能判断一篇论文里的深度强化学习路由算法在你的场景里值不值得投入。如果你的流量矩阵很长时间都不变,传统负载均衡已经够用;但流量频繁突发、链路经常被热点打穿,DRL路由确实能把那些闲置链路盘活。希望这些踩坑记录能让你少熬几个晚上的排障,也希望帮到你。
本文还有配套的精品资源,点击获取