2024年9月

2024年9月开始了,
救园
的最后一个月到了,园子的
20年纪念T恤
终于赶在天凉好个秋之前上架了。

用这件设计简约清新给人希望的T恤,纪念过去二十年充满艰难而满怀希望的时光。

园子的二十年,是困难重重的二十年,是走了很多弯路摔了很多跟头的二十年,但也是心中一直燃烧着希望的二十年——服务好开发者,一切随之而来;真心帮助开发者,一定会有未来。在当前生死关头,很多园友出手相救,给燃烧的希望火上浇油,让这燃得更旺的希望,成为接下来二十年飞翔的翅膀。

园子的二十年,是陪伴很多园友美好青春时光的二十年,是见证很多园友不惜宝贵青春时光挥洒技术热情无私分享的二十年,这种分享精神是园子的希望之光,我们要在接下来二十年让它绽放光芒。

博客园20年纪念T恤载着怀念与希望在这个决定园子命运的9月上架啦!

价格

  • 售价:¥79
  • VIP会员价:¥59(仅限3件)
  • PLUS会员价:¥39(仅限3件)
  • 回头客价:¥59(仅限3件)

购买方式

优惠与赠送

  • 会员领优惠券:
    https://vip.cnblogs.com/benefits/t-shirt-20th-anniversary/coupon
    (只能在淘宝店使用)
  • PLUS会员赠送:开通
    PLUS会员
    会赠送100元周边礼,在周边小店购买纪念T恤会自动免费
  • 终身会员赠送:
    终身会员
    额外赠送1件纪念T恤(一共赠送2件),成为终身会员后加
    企业微信
    领取
  • 回头客优惠:如果您买过任一款2024年博客园T恤或者polo衫,可享回头客价,下单后先不付款,联系客服改价

面料参数

  • 面料:100%精梳紧密赛络棉
  • 支数:20支
  • 克重:230克

尺码表

liwen01 2024.09.01

前言

最近十几年,通信技术发展迅猛,通信标准更新频繁,有的设备还在使用 802.11/b/g/n 协议,有的已支持到 WiFi6、WiFi7。

而国内有关无线 WiFi 的书籍或资料却很少,就算能找着的,大多也是比较老旧。本文试图使用最新的数据来介绍 WiFi 相关的一些基础知识。

关于 WiFi 技术的发展,下面几个问题看你了解多少:

  1. 家用路由器一般都兼容支持哪些 WiFi 协议标准?

  2. 802.11 b/g/n/ac/ax 具体是指什么?与 WiFi4/5/6/7 有什么区别?

  3. 为什么不同协议间的最大速率相差巨大?它们实现的原理是什么?

  4. 在 WiFi 发展中,是由哪些关键技术的发展使 WiFi 速率得到显著提升?

  5. 为什么实际速都远低于理论速率?

  6. 有线以太网与无线 WiFi 在 OSI 七层模型中有哪些差异?

  7. 802.11 a/b/g/n 等这些标准是位于 OSI 的哪一层?

  8. WiFi 在 2.4Ghz/5GHz  各有多少个可用信道,有没其它限制?

  9. WiFi 在 2.4Ghz/5GHz  各有多少个不重叠信道?

  10. 为什么我们很少见到使用 WiFi5 的设备?

  11. 在同一个空间,两台使用不同协议的路由器相互之间是否会有干扰或影响?

  12. 最新的 WiFi7 可以工作在哪些频段?

本文主要介绍 WiFi 信道,无线网 OSI 模型,以及802.11b/g/n 标准协议的一些关键技术。由于篇幅的限制,WiFi5、WiFi6、WiFi7 将在下一篇中介绍。

(一) IEEE 协会 与 802.11 标准

  • IEEE
    (Institute of Electrical and Electronics Engineers)电气与电子工程师协会,是一个国际性的专业学会组织,是全球最大的技术专业组织
  • IEEE 802
    是一个标准系列项目,包括以太网、局域网、城域网的多个技术标准。
  • IEEE 802.11
    是 IEEE 802 标准系列中的一个工作组,专注于无线局域网 (WLAN) 技术。
  • 802.11a/b/g/n...
    是由 IEEE 802.11 工作组下的任务组开发的标准

在早些年,我们看到比较多的 WiFi 分类是按 802.11b/g/n 字母来区分,但是随着 WiFi 协议的不断发展,WiFi 联盟对不同 WiFi 标准指定了新的名字,也就是 WiFi4、WiFi5、WiFi6、WiFi7 按数字代号表示;其主要目的是方便大家记忆和区分。

802.11 be 也就是 WiFi7,预计在今年(2024)正式发布,现在网上可以买到的 WiFi7 设备,应该是预认证设备,具备 WiFi7 的部分功能,但可能与正式发布的标准会存在一些差异

在介绍各 WiFi 协议标准之前,我们先了解一下 WiFi 信道相关的概念。

(二) WiFi信道

目前在安防IPC设备上,使用比较多的还是 802.11b/g/n 三个标准,但也有不少厂家开始切换到 802.11ax(WiFi6) 协议上来了。

实际上大部分产品是直接 从 802.11n(WiFi4) 直接切换到 802.11ax(WiFi6)。

为什么不使用 WiFi5,而是从 WiFi4 直接跨越到了 WiFi6 呢?

因为 WiFi5 只支持 5GHz 频段,对于以前使用 2.4GHz 的设备就没法兼容了。

(1) 2.4Ghz 频段信道

  • 802.11b使用的信道频宽是 22MHz,目前使用的其它标准都是 20Mhz信道带宽
  • 每个相邻信道的中心频率,相差5MHz(除了14信道)
  • 传统认知上,有 3 个不重叠的信道(1、6、11)

由于 802.11b (使用 DSSS 调制技术频宽22 MHz) 已经淡出 WLAN 网络,不考虑兼容性问题,通常情况下,可以认为1、5、9和13信道也是非重叠信道。

对于 12~14 信道,不同国家有不同的要求规范,实际产品设计需要根据国家码去适配。

(2) 5GHz 频段信号


  • 5 GHz 频段通常被划分为 4 个 UNII (Unlicensed National Information Infrastructure) 子频段。
  • DFS(Dynamic Frequency Selection,动态频率选择)
    信道是为了避免干扰重要的雷达系统而设计的,这些信道需要 WiFi 设备监测雷达信号,并在探测到时自动切换信道。
  • 20 MHz 信道
    :是最常用的信道带宽,适合在设备较多的环境中使用,以避免干扰
  • 40 MHz 信道
    :通过聚合两个相邻的 20 MHz 信道,提供更高的吞吐量,但更容易受到干扰
  • 80 MHz 和 160 MHz 信道
    :适用于对吞吐量要求极高的应用(如 4K 流媒体、高清视频会议),但在实际使用中较少,因为它们占用了更多的频谱资源
  • UNII-2 Extended(5470-5725 MHz) 的所有信道在中国都不能使用
  • 在中国,
    只有 UNII-3 的 5 个信道可以在所有场景使用
  • 在中国,可以使用的非重叠 5GHz 频段有 13 个

上面表格数据是来源于华为的一份开源文档,我们可以看到低频和中频是被限定在室内使用,但是我们查看很多其它的资料,发现与华为的数据对不上,比如下图,它们对我国在 UNII-1 和 UNII-2 的部分信道并没有做限定。

通过查询最新版本上的《中华人民共和国无线电频率划分规定》,我们可以看到,华为的数据是对的,在 2023 年我国有规定,UNII-1 和 UNII-2 的信道只能在室内使用。

所以,
对于中国 5GHz 可以直接使用的信道,2023年之前的资料会包括UNII-1 和 UNII-2 里面的信道,但是在2023年之后,UNII-1 和 UNII-2 会被标注为仅限室内使用。

(3) 6GHz 频段信道

在 WiFi6 和 WiFi7 中会使用到一些 6GHz 的信道,但是目前我国还没有开放 6GHz 信道的使用。

6GHz 频段范围从 5925MHz 扩展到 7125MHz,共计 1200MHz 频谱。它可以通过信道绑定成  3 个 320MHz 信道、7 个 160MHz 信道、14 个 80MHz 信道或者是 29 个40MHz 信道。如果不绑定直接使用,它提供了 59 个 20MHz 信道。

对比 2.4GHz 和 5GHz,6GHz 频段的频谱资源比前两者相加还要多。

随着 WiFi6、WiFi7 逐渐地普及,国内将来应该也会开放一部分 6GHz 的 WiFi 信道

(三) 无线网中的 OSI 模型

计算机课程中常用网络分层参考7层模型:
物理层、数据链路层、网络层、传输层、会话层、表示层、应用层

上面这个模型其实是非常概括性的,实际要复杂很多,从这个图上我们看不出以太网与无线网有什么差别。

以太网与无线网在 OSI 模型上主要的差异在于第一和第二层,也就是物理层和数据链路层

(1) 物理层(Physical Layer)

物理层主要负责在网络设备之间传输原始的比特流(0和1)。它涉及物理连接,如电缆、光纤和无线电波,以及数据传输的电气和机械特性。常见的物理层设备包括网卡、集线器和电缆。

以太网
:以太网使用有线连接,如双绞线电缆或光纤来传输数据。物理层定义了传输的电信号、电压和脉冲等特性。

无线网(Wi-Fi)
:Wi-Fi通过无线电波在空气中传输数据。物理层涉及无线频率的选择、天线的配置,以及信号的调制和解调方式

(2) 数据链路层(
Data Link Layer
)

数据链路层负责在相邻节点之间建立可靠的通信链路。它将数据帧从一个节点发送到下一个节点,并处理帧的传输错误。数据链路层还包括 MAC (介质访问控制)子层和 LLC (逻辑链路控制)子层。常见的设备有交换机和网桥。

以太网
:在数据链路层,以太网通常使用以太网帧(Ethernet Frame)进行数据封装。MAC 地址用于标识网络设备,并控制对介质的访问(
CSMA/CD
,载波侦听多路访问/冲突检测机制)。

无线网(Wi-Fi):
无线网在数据链路层也使用帧进行数据封装,但 Wi-Fi 帧格式与以太网帧有所不同。Wi-Fi使用
CSMA/CA
(载波侦听多路访问/冲突避免机制)来管理介质访问,并增加了加密(如 WPA/WPA2 )和认证(如802.1X)的功能,以增强安全性。

(3) 无线网数据帧封装

对无线网的物理层和数据链路层再进一步划分,我们可以看到物理层有:PLCP 和 PMD 层,数据链路层有:MAC 层和 LLC层

这里我们简单介绍一下各层的一个基本功能,详细的 WiFi 数据帧分析我们将在后面章节来介绍。

  • LLC 子层
    :(
    Logical Link Control
    )逻辑链路控制子层,为上层网络协议提供统一的接口,管理逻辑链路的控制和数据传输。
  • MAC 子层
    :(
    Medium Access Control
    )媒体访问控制子层,管理设备对共享通信介质的访问和数据帧的传输。
  • PLCP 子层
    :(
    Physical Layer Convergence Procedure
    )物理层收敛过程子层,负责在 MAC 层和 PMD 子层之间转换数据帧格式。
  • PMD 子层
    :(
    Physical Medium Dependent
    ) 物理介质相关子层,直接处理物理信号的传输和接收。

我们常说的802.11 b/g/n等协议标准,实际上是位于物理层。

(4) 物理层扩频技术

扩频技术是无线局域网数据传输使用的技术,扩频技术最初是用于军事部门防止窃听或信号干扰。

WiFi(无线局域网) 使用扩频技术来提高通信的可靠性和抗干扰能力,扩频技术在 WiFi 中的应用主要通过以下几种方式实现:

(a) 直接序列扩频 (Direct Sequence Spread Spectrum, DSSS)

DSSS
通过将数据与一个伪随机噪声码 (PN码)进行异或运算,将数据分散到一个更宽的频谱上。这样做的好处是使得信号在频谱中的能量密度降低,从而提高了信号对噪声和干扰的抵抗力。

(b) 跳频扩频 (Frequency Hopping Spread Spectrum, FHSS)

FHSS
通过快速在多个频率之间跳转来避免干扰,这需要提前在发送和接收端约定好跳频的规律,实际在WiFi中使用得比较少。

(c) 正交频分复用 (Orthogonal
Frequency-Division Multiplexing
, OFDM)

OFDM 使用多个正交子载波,每个子载波传输数据的一部分,这样就大大降低了多径效应的影响,并提高了频谱效率。

上面的这三种扩频方式看不懂没关系,下面会有稍微比较详细的介绍。

(四) 802.11b

802.11b 是1999年发布的标准,
为什么它最大的理论数据只有11Mps?

这与802.11b 物理层使用的编码方式和调制方式有关系:

(a)
BPSK
与 QPSK调制方式

BPSK
: (Binary Phase Shift Keying)每个符号代表1个比特,即每次调制一个符号时只能传递1个比特。

QPSK
: (Quadrature Phase Shift Keying)每个符号代表2个比特,因为它可以区分四种相位,所以比BPSK效率更高。

(b) Barker 与 CCK 编码

Barker编码
: Barker 码是一个 11 比特序列 (例如10110111000),在无线传输方面存在优势,可以有效降低干扰,不过降低了效率。

每一个比特编码为一个 11 位 Barker 码,因此而产生的一个数据对象形成一个chip(碎片)。
实际传输的信息量是有效传输的 11 倍

CCK编码:
(Complementary Code Keying)补码键控,采用了复杂的数学转换函数,可以使用若干个 8-bit 序列在每个码字中编码 4 或 8 个位。

补码键控编码方式能有效防止噪声及多径干扰,缺点是补码键控为了对抗多径干扰,技术复杂,实现困难

(c) 802.11b 速率计算

关于802.11b 各速率的计算:

1Mbps (Barker + BPSK)

调制方式
: BPSK,每个符号1比特。

编码方式
: Barker 编码,每个符号被编码为11位。

结果
: 由于符号速率是1 MSym/s,BPSK调制1个符号1比特,所以最大理论速率是1 Mbps。

2Mbps (Barker + QPSK)

调制方式
: QPSK,每个符号2比特。

编码方式
: Barker编码。

结果
: 符号速率1 MSym/s,每个符号传输2比特,所以最大理论速率是2 Mbps。

5.5Mbps (4-bits CCK + QPSK)

调制方式
: QPSK,每个符号 2 比特。

编码方式
: 4-bits CCK编码,利用复杂的编码方式提高了每符号的比特传输效率。

结果
: 虽然每个符号代表 2 个比特,但CCK编码使得每个符号最终可以传递 4 个比特。因此最大理论速率是 5.5 Mbps。

11Mbps (8-bits CCK + QPSK)

调制方式:
QPSK,每个符号 2 比特。

编码方式
: 8-bits CCK 编码,每个符号可传递 8 个比特。

结果
:
在QPSK的基础上,通过CCK编码的优化使得每个符号可以传输8个比特,所以最大理论速率是11 Mbps

注意:
上面 Sym/s 是符号率/码元速率的单位,用于表示通信系统中每秒传输符号数量的单位

  • 在BPSK (Binary Phase Shift Keying)调制中,一个符号代表1个比特。
  • 在QPSK (Quadrature Phase Shift Keying)调制中,一个符号代表2个比特。

(五) 802.11g

802.11g 可以从 802.11b 中的最大速率 11Mbps 提升到 54Mbps, 核心是使用了OFDM 调制载波技术。

(1)正交频分复用技术(OFDM)

正交频分复用 (Orthogonal Frequency Division Multiplexing ,OFDM) 是一种数字多载波调制方案,它通过在同一单信道内使用多个子载波来扩展单子载波调制的概念。

OFDM 不是使用单个子载波传输高速率数据流,而是使用大量并行传输的紧密间隔的正交子载波。每个子载波均采用传统的数字调制方案。许多子载波的组合可以在等效带宽内实现与传统单载波调制方案类似的数据速率。

从上图我们可以看到,
当某个载波信号振幅最高的时候,也就是信号强度最强的时候,其它载波的振幅都刚好为0。

OFDM 基于频分复用 (FDM) 技术,在 FDM 中,不同的信息流被映射到单独的并行频道上,每个 FDM 信道均通过频率保护带与其他信道分开,以减少相邻信道之间的干扰。

OFDM 方案与传统 FDM 的不同之处在于以下相关方面:

  1. 多个载波 (称为子载波)承载信息流,
  2. 子载波彼此正交,并且为每个符号添加保护间隔,以最小化信道延迟扩展和符号间干扰

上图说明了 OFDM 信号的主要概念以及频域和时域之间的相互关系。

在频域中,多个相邻子载波各自独立地用复数数据进行调制。对频域子载波执行逆 FFT 变换以产生时域中的 OFDM 符号。

在时域中,在每个符号之间插入保护间隔,以防止由于无线电信道中的多径延迟扩展而在接收机处引起的符号间干扰。可以连接多个符号来创建最终的 OFDM 突发信号。

在接收器处,对 OFDM 符号执行 FFT 以恢复原始数据位。
这里的 FFT 就是高数中的傅里叶变换。

在802.11g 中,有48个子载波用来传输数据,4个子载波用来做相位参考

为什么802.11g速率可以达到 54Mbps 呢?

802.11g 除了使用了OFDM调制载波技术,它还使用了64-QAM 的编码方式。

(2) 64-QAM 编码方式

QAM (Quadrature Amplitude Modulation)正交幅度调制,在QAM (正交幅度调制)中,数据信号由相互正交的两个载波的幅度变化表示。模拟信号的相位调制和数字信号的PSK (相移键控)可以被认为是幅度不变、仅有相位变化的特殊的正交幅度调制。

64-QAM 中的每个符号都是一个包含 6 位的星座状态,每个符号是从 000 000 到 111 111 的 64 种不同状态中的一种可能组合。由于该调制方案使用二进制数据,因此可能的组合总数使用6 位为 2的6次方,即 64。

相应的在WiFi中还有使用16-QAM和256-QAM的编码方式,16-QAM 传输4个位,64-QAM 传输6个位,256-QAM传输8个位。

在802.11g 中使用的是64-QAM,并且它的编码率是3/4

(3) 802.11g 速率计算

数据速率是符号速率、每个符号承载的比特数和信道编码率的乘积。

调制方式
: 64-QAM,每个符号代表6个比特。

编码率
: 3/4 (前向纠错编码中使用的编码率)。

符号速率
: 250 ksps。

每个OFDM符号在所有子载波上
传输的总时间为4微秒 (μs),其中包括3.2微秒的数据传输时间和0.8微秒的保护间隔 (Guard Interval)

因此,符号周期 (Symbol Period)为4微秒。

由于每个符号周期为4微秒,符号速率为:

  • 每个符号可以传输的比特数 = 6bit (因为64-QAM)。
  • 载波编码率是3/4,所以实际有效比特数 = 6bit * 3/4 = 4.5 bit。
  • 有48个数据子载波,所以每个OFDM符号可以传输的比特数 = 48 * 4.5bit = 216bit。
  • 符号速率是250 ksps,所以总数据速率 = 216 比特/符号 * 250 ksps = 54 Mbps。

由上面的计算可以知道,802.11g 最大支持的速率是54Mbps。

于此同时,802.11g可以向下兼容,在不同调制方式和编码率下,可以匹配到不同的速率上。

调制方式 编码率 数据速率
BPSK 1/2 6 Mbps
BPSK 3/4 9 Mbps
QPSK 1/2 12 Mbps
QPSK 3/4 18 Mbps
16-QAM 1/2 24 Mbps
16-QAM 3/4 36 Mbps
64-QAM 2/3 48 Mbps
64-QAM 3/4 54 Mbps

(六) 802.11n (WiFi4)

2009 年更新的 802.11n 也就是 WiFi4,可以同时支持 2.4G 和 5G 信道,2.4Ghz 的理论速度达到了 450 Mbps, 5GHz 的理论速度达到了 600Mbps。同时支持两个频段,并且速率得到了跨越式的增长,大大地提升了 WiFi 的使用体验。

就目前而言,很多设备还是使用的 802.11n 协议,特别是在安防 IPC 行业。

那么,从 2003 年的 802.11g 到 2009 年的 802.11n(WiFi4),又有哪些关键技术的实现让 WiFi4 的速率得到质的飞跃呢?

WiFi4 核心的技术是 OFDM、FEC、MIMO、40Mhz、Short Gi。

(1) 802.11n 的 OFDM

这里使用的 OFDM 正交频分复用技术与 802.11g 中使用的是相同的。不同的点是:

  • 802.11g 总共有 52 个子载波,802.11n 有 56 个子载波
  • 802.11g 有 48 个数据子载波,802.11n 有 52 个数据子载波

数据子载波数 x 每个符号传输比特数 x 载波编码率 x 符号速率 = 最大理论速率

52 * 6bit * 3/4 * 250 ksps = 58.5Mbps

数据子载波数量增加了 4 个,所以速率由 802.11g 的 54Mbps 提升到了 58.5Mbps。

(2) 802.11n 的 FEC

前向纠错编码 (Forward Error Correction,FEC) 技术在发送端将原始数据块进行编码,添加冗余信息形成编码数据块。接收端通过解析这些冗余信息来检测和纠正传输过程中出现的错误。

这种方法不需要反馈和重传,因此可以显著提高数据传输的效率,特别是在高噪声或信号衰减严重的无线环境中。


使用 FEC 前向纠错编码之后,载波编码率由 802.11g 的 3/4 提升到了 5/6 。

数据子载波数 x 每个符号传输比特数 x 载波编码率 x 符号速率 = 最大理论速率

52 * 6bit * 5/6 * 250 ksps = 65Mbps

使用 FEC 编码之后,速率提升到了 65Mbps

(3) 802.11n 的 Short Gi

Guard Interval
(GI) 是指在每个 OFDM (正交频分复用) 符号之间插入的一段保护时间,用来防止符号间的干扰 (
ISI,
Inter-Symbol Interference
)。这种干扰通常由多径传播引起,即信号在传播过程中经过多次反射、折射和散射,从而导致信号在不同的时间到达接收端。

在传统的 802.11 系统中,
GI 的标准长度为 800 纳秒 (ns)
,这个时间间隔足够长,以消除大部分的符号间干扰。然而,长时间的 GI 也意味着浪费了一部分可以用于数据传输的时间。

为了提高数据传输效率,802.11n 及后续标准引入了 Short GI 技术,将 GI 的长度从 800 ns 缩短到 400 ns。这一缩短的保护时间段带来了显著的性能提升。

由于保护时间缩短了400ns,所以每个符号周期为4微秒-0.4微秒 = 3.6微秒

符号率为:277.778ksps

数据子载波数 x 每个符号传输比特数 x 载波编码率 x 符号速率 = 最大理论速率

52 * 6bit * 5/6 * 277.778 ksps = 72.2222Mbps

使用 Short GI技术之后,速率提升到了 72.2222Mbps.

(4) 802.11n 信道捆绑

802.11n 允许使用 信道捆绑 技术,将两个相邻的 20 MHz 信道捆绑在一起,形成一个 40 MHz 的信道。这使得数据传输可以在更宽的频谱范围内进行。

通过增加信道宽度,可以承载更多的子载波 (subcarriers),从而提高数据的传输速率。

  • 一个标准信道是 20Mhz 频宽,包含 52 个子载波
  • 两个相邻信道捆绑起来就是 40Mhz 频宽,包含108 (52*2+4=108) 个子载波

为什么上面两个信道捆绑到一起后,子载波数还多了4个呢?

因为信道与信道之间有间隙,当两个信道绑定之后,两个信道中间的频段也可以被使用到。

在 2.4G 模式上最多可以有一个 40M 信道,在5G模式上 40M 信道数目因国家不同而不同,理论上最多有11个 40M 信道。

数据子载波数 x 每个符号传输比特数 x 载波编码率 x 符号速率 = 最大理论速率

108 * 6bit * 5/6 * 277.778 ksps = 150Mbps

2.4Ghz频段信道捆绑注意事项:
在 2.4 GHz 频段,由于可用的信道较少且信道间隔较窄,通常使用的信道捆绑配置包括:

  • 信道 1 和 5:这些信道可以捆绑在一起形成 40 MHz 宽的信道。
  • 信道 6 和 10:这些信道也可以捆绑在一起形成 40 MHz 宽的信道。
  • 信道 11 和 7:这些信道也可以捆绑在一起形成 40 MHz 宽的信道。

由于 2.4 GHz 频段的信道带宽较小,捆绑时的信道间隔可能会导致较高的信道重叠和干扰,因此在这个频段使用信道捆绑时需要特别注意干扰管理。

(5) 802.11n MIMO

MIMO(Multiple Input Multiple Output)概念

多输入多输出
:MIMO 技术利用多个发射天线和接收天线在无线通信中进行数据传输。通过同时传输多个数据流,MIMO 技术可以显著提高无线网络的吞吐量和覆盖范围。

空间复用
:MIMO 技术允许在相同的频谱资源上同时传输多个数据流,增加了频谱的利用效率。这种技术基于空间复用原理,即在同一频段内通过空间分离的数据流来实现更高的数据传输速率。

发射机的多个天线意味着有多个信号输入到无线信道中,接收机的多个天线是指有多个信号从无线信道输出,多天线接收机利用先进的空时编码处理能够分开并解码这些数据子流,从而实现最佳处理,并有效地抵抗空间选择性衰落。

802.11n 使用了 MIMO 技术之后,速率可以提升到 150Mbps*n(n为空间流个数),n 的最大值为4,

数据子载波数 x 每个符号传输比特数 x 载波编码率 x 符号速率 x MIMO = 最大理论速率

108 * 6bit * 5/6 * 277.778 ksps *4 = 600Mbps

所以 802.11n 的最大速率是 600Mbps

我们回到最开始的WiFi标准与WiFi世代图中,
我们可以看到 802.11n (WiFi4) 在2.4GHz 的最大速率是 450Mbps,而在 5Ghz 的最大速率是 600Mbps,这是为什么?

我在网上看的资料是,802.11n 在 2.4GHz 的时候最大是 3 条数据流,而在 5GHz 的时候最大是 4 条数据流。

802.11n 除了上面介绍的 OFDM、FEC、MIMO、40Mhz、Short Gi 这些关键技术之外,它还有帧聚合、Block Ack 块确认、更加高效的MAC层等技术使 WiFi 的整体性能得到了很大的提升。

结尾

上面介绍了 WiFi 信道,无线 WiFi 的 OSI 模型,以及 802.11b/g/n 标准协议的一些关键技术,下一篇将介绍 WiFi5、WiFi6、WiFi7 相关的一些内容,以及这些标准在使用时需要注意的事项。

上面内容,如有错误,欢迎评论区批评指出,不胜感激。

------------------End------------------
如需获取更多内容
请关注 liwen01 公众号

大家好,我是码农先森。

我们在某宝或某多多上抢购商品时,如果只是下了订单但没有进行实际的支付,那在订单页面会有一个支付倒计时,要是过了这个时间点那么订单便会自动取消。在这样的业务场景中,一般情况下就会使用到延时队列。

通常在客户下单之后,就会将订单数据推送到延时队列中并且会对该消息设置一个延时时长,比如设置五分钟、十分钟、或十五分钟等,具体的时长应该还是要结合当前的业务进行衡量,然后消费端会在指定时间到达后就对该消息进行支付支付状态判断,如果已经支付则不予处理,要还是未支付,则会取消该订单,并且释放商品库存。

我们这次分享的内容,主要是基于 Redis 延时队列的实现方式,当然除了 Redis 还可以用其他的技术,比如 RabbitMQ、Kafka、RocketMQ 等专业的消息队列。但是我用 Redis 的原因是,它的应用场景比较广泛,我们平时接触也比较多,而且相对于专业的消息队列它没有过多复杂的配置,学起来容易上手,出了问题解决起来也快,学东西的路径都是由易到难嘛。

另外,如果你对上面提到的专业消息队列使用很熟练,也可以将 Redis 更换成它们,这里只是存储介质的不同,技术的实现逻辑上没有太大区别,重要的是设计思想,大家各取所需吧。

好了,我先介绍一下这次延时队列的实现逻辑。主要分为三个部分,一是:消息的发送,如果设置了延时时间则会将消息存储到 Redis 的延时队列中,反之会直接将消息推送到 Redis 的就绪队列中等待消费。二是:将到期的消息从 Redis 延时队列中取出,并且推送到 Redis 的就绪队列中等待消费。三是:消费端会从 Redis 的就绪队列中按顺序读取出消息,并且执行对应的业务处理逻辑,如果处理失败则会将该消息,再次推送到 Redis 的延时队列中进行下一次的重试。

这里说到的延时队列是利用 Redis 有序集合来实现的,它每间隔一秒钟就会被轮询一次,如果有到期的消息,则就会将该消息推送到 Redis 就绪队列,并且从该集合中移除过期的消息,至此就可以等待着消费端进行消费了。接下来我们就从实际的代码出发,来看一下如何实现基于 Redis 的延时队列。

话不多说,开整!我们先来看一下整体的项目目录结构,内容主要分为 PHP 和 Go 两部分。

[manongsen@root php_to_go]$ tree -L 2
.
├── go_delay
│   ├── app
│   │   ├── controller
│   │   │   └── notify.go
│   │   ├── config
│   │   │   └── config.go
│   │   ├── extend
│   │   │   └── queue.go
│   │   └── route.go
│   ├── go.mod
│   ├── go.sum
│   └── main.go
└── php_delay
│   ├── app
│   │   ├── controller
│   │   │   └── Notify.php
│   ├── composer.json
│   ├── composer.lock
│   ├── command
│   │   └── Consumer.php
│   ├── route
│   │   └── app.php
│   ├── extend
│   │   └── Queue.php
│   ├── think
│   ├── vendor
│   └── .env

ThinkPHP

使用 composer 创建基于 ThinkPHP 框架的 php_delay 项目。

## 当前目录
[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/php_delay

## 安装 ThinkPHP 框架
[manongsen@root php_delay]$ composer create-project topthink/think php_delay
[manongsen@root php_delay]$ cp .example.env .env

## 安装 Composer 依赖包
[manongsen@root php_delay]$ composer require predis/predis
## 创建一个消费者脚本
[manongsen@root php_delay]$ php think make:command Consumer
## 创建一个生产者脚本,用于测试
[manongsen@root php_delay]$ php think make:command Producer

这个就是延时队列实现的核心类,定义了就绪、延时、失败三个消息队列。
send()
方法用于发送消息,其中可以指定
$delay
参数设置延时时间单位是秒。
wait()
方法用于消费端监听消息,从下面的代码可以看出这里还利用多进程,父进程的作用是每间隔一秒钟,就从 Redis 有序集合中读取到期的消息,并将该消息推送到 Redis 就绪队列,子进程则阻塞监听就绪队列的消息,并且将接收到的消息回调到用户自定义的业务函数中。

<?php
declare (strict_types = 1);

class Queue
{
    // 就绪消息存放的队列
    const QUEUE_READY = 'redis:queue:ready'; 

    // 延迟消息存放的队列(实际的数据结构是有序集合)
    const QUEUE_DELAY = 'redis:queue:delay'; 

    // 失败消息存放的队列
    const QUEUE_FAILED = 'redis:queue:failed'; 

    protected $_client;
    protected $_options = [
        'retry_seconds' => 5, // 重试延时5秒
        'max_attempts'  => 5, // 最大重试次数
    ];

    public function __construct()
    {
        // 与 Redis 建立连接
        $this->_client = new \think\cache\driver\Redis(config('cache.stores.redis'));
        $this->_client->get("ping");
    }

    // 发送消息
    public function send($data, $delay = 0)
    {
        static $_id = 0;
        $id = \microtime(true) . '.' . (++$_id);
        $now = time();
        $package_str = \json_encode([
            'id'       => $id,    // 消息ID
            'time'     => $now,   // 当前时间
            'delay'    => $delay, // 延迟时长(秒)
            'attempts' => 0,      // 重试次数
            'data'     => $data   // 消息内容
        ]);

        // 如果不是延时消息,则直接将消息推送到就绪队列
        if ($delay == 0) {
            $this->_client->lpush(static::QUEUE_READY, $package_str);
        } else {
            // 否则将消息写入到有序集合中
            $this->_client->zadd(static::QUEUE_DELAY, $now + $delay, $package_str);
        }
    }

    // 从有序集合中取出数据推送到就绪队列中
    public function tryToPullDelayQueue()
    {
        while (true) {
            try {
                $now = time(); // 当前时间
                $options = ['LIMIT', 0, 128]; // 每次取 128 条数据
                $items = $this->_client->zrevrangebyscore(static::QUEUE_DELAY, $now, '-inf', $options);
                foreach ($items as $package_str) {
                    // 从有序集合中移除该数据
                    $result = $this->_client->zrem(static::QUEUE_DELAY, $package_str);
                    if ($result !== 1) {
                        continue;
                    }
                    // 将数据JSON反序列化解析
                    $package = \json_decode($package_str, true);
                    if (!$package) {
                        // 解析失败则推送到失败队列
                        $this->_client->lpush(static::QUEUE_FAILED, $package_str);
                        continue;
                    }
                    // 将数据推送到就绪队列
                    $this->_client->lpush(static::QUEUE_READY, $package_str);
                }
            } catch (\Throwable $e) {
                echo $e->getMessage() . PHP_EOL;
            }

            // 间隔1s之后再次轮询
            sleep(1);
        }
    }

    // 监听消息
    public function wait($success_callback, $failure_callback)
    {
        echo "开始监听消息..." . PHP_EOL;
        // 创建一个进程
        // 父进程用于轮询有序集合消息
        // 子进程监听就绪队列消息
        $pid = pcntl_fork();
        if ($pid < 0) {
            exit('fork error');
        } else if($pid > 0) {
            // 轮询有序集合消息并推送到就绪队列
            (new \Queue())->tryToPullDelayQueue();
            pcntl_wait($status);
            exit();
        }

        while (true) {
            try {            
                // 阻塞监听就绪队列消息
                $data = $this->_client->brpop(static::QUEUE_READY, 0);
                if ($data) {
                    $package_str = $data[1];
                    // 将数据JSON反序列化解析
                    $package = json_decode($package_str, true);
                    if (!$package) {
                        // 解析失败则推送到失败队列
                        $this->_client->lpush(static::QUEUE_FAILED, $package_str);
                    } else {
                        try {
                            // 将消息回调到我们在业务层面写的回调函数中
                            \call_user_func($success_callback, $package['data']);
                        } catch (\Throwable $e) {
                            $package['max_attempts'] = $this->_options['max_attempts'];
                            $package['error'] = $e->getMessage();
                            $package_modified = null;
                            // 如果出现异常并且我们设置了失败回调函数
                            if ($failure_callback) {
                                try {
                                    // 则会回调到我们在业务层面写的回调函数中
                                    $package_modified = \call_user_func($failure_callback, $e, $package);
                                } catch (\Throwable $ta) {
                                }
                            }
                            // 如果修改了消息内容,则重新构造消息
                            if (is_array($package_modified)) {
                                $package['data'] = $package_modified['data'] ?? $package['data'];
                                $package['attempts'] = $package_modified['attempts'] ?? $package['attempts'];
                                $package['max_attempts'] = $package_modified['max_attempts'] ?? $package['max_attempts'];
                                $package['error'] = $package_modified['error'] ?? $package['error'];
                            }
                            // 如果已经超过了最大重试次数,则将消息推送到失败队列
                            if (++$package['attempts'] > $package['max_attempts']) {
                                $this->fail($package);
                            } else {
                                // 否则进入有序集合中,等待下一轮的轮询
                                $this->retry($package);
                            }
                        }
                    }
                }
            } catch (\Throwable $e) {
                echo $e->getMessage() . PHP_EOL;
            }
        }
    }

    // 重新添加到有序集合
    protected function retry($package)
    {
        // 延时时间随着重试的次数成倍增加
        $delay = time() + $this->_options['retry_seconds'] * ($package['attempts']);
        $this->_client->zadd(static::QUEUE_DELAY, $delay, \json_encode($package, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    }

    // 推送到失败的队列
    protected function fail($package)
    {
        $this->_client->lpush(static::QUEUE_FAILED, \json_encode($package, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    }
}

这个是消费端脚本,主要是实现在接收到消息之后,进行具体的业务逻辑处理。

<?php
declare (strict_types = 1);

namespace app\command;

use think\facade\Cache;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;

class Consumer extends Command
{
    protected function configure()
    {
        // 指令配置
        $this->setName('app\command\consumer')
            ->setDescription('the app\command\consumer command');
    }

    protected function execute(Input $input, Output $output)
    {
        (new \Queue())->wait(function($data){
            // 这里是正常接收消息的逻辑
            var_dump($data);
        }, function($e, $package){
            // 这里是消息异常的处理逻辑
            return $package;
        });
    }
}

这个是通过 API 接口将消息,推送到延时队列中。

<?php

namespace app\controller;

use app\BaseController;

class Notify extends BaseController
{
    public function sendMsg()
    {
        // 接收 GET 参数
        $params = $this->request->param();
        if (empty($params["content"])) {
            return json(["code" => -1, "msg" => "内容不能为空"]);
        }
        $content = $params["content"];

        // 推送到延时队列 15 秒之后会执行
        (new \Queue())->send($content, 15);

        return json(["code" => 0, "msg" => "success"]);
    }
}

我们来实际测试一下,先执行
php think consumer
启动消费者,然后再执行
php think run
启动服务,最后使用 Postman 工具进行调用。

Gin

通过 go mod 初始化 go_delay 项目。

## 当前目录
[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/go_delay

## 初始化项目
[manongsen@root go_delay]$ go mod init go_delay

## 安装第三方依赖库
[manongsen@root go_delay]$ go get github.com/gin-gonic/gin
[manongsen@root go_delay]$ github.com/go-redis/redis

这里和上面 PHP 中的实现逻辑都差不多,有一点值得注意的是在 Go 中是利用协程来异步从 Redis 有序集合中轮询到期的消息,而 PHP 是利用的多进程。

package extend

import (
	"encoding/json"
	"fmt"
	"go_delay/app/config"
	"time"

	"github.com/go-redis/redis"
)

var comId int

const (
	// 就绪消息存放的队列
	QUEUE_READY = "redis:queue:ready"

	// 延迟消息存放的队列(实际的数据结构是有序集合)
	QUEUE_DELAY = "redis:queue:delay"

	// 失败消息存放的队列
	QUEUE_FAILED = "redis:queue:failed"
)

type PackageData struct {
	Id          string `json:"id"`           // 消息ID
	Time        int64  `json:"time"`         // 当前时间
	Delay       int    `json:"delay"`        // 延迟时长(秒)
	Attempts    int    `json:"attempts"`     // 重试次数
	MaxAttempts int    `json:"max_attempts"` // 最大重试次数
	Data        string `json:"data"`         // 消息内容
	Error       string `json:"error"`        // 错误信息
}

type Queue struct {
	RetrySeconds int
	MaxAttempts  int
}

func NewQueue() *Queue {
	return &Queue{
		RetrySeconds: 5, // 重试延时5秒
		MaxAttempts:  5, // 最大重试次数
	}
}

// 发送消息
func (q *Queue) Send(data string, delay int) {
	comId += 1
	now := time.Now().UnixMilli() / 1000
	msgId := fmt.Sprintf("%d.%d", now, comId)
	packageData := &PackageData{
		Id:       msgId,      // 消息ID
		Time:     int64(now), // 当前时间
		Delay:    delay,      // 延迟时长(秒)
		Attempts: 0,          // 重试次数
		Data:     data,       // 消息内容
	}
	packageStr, err := json.Marshal(packageData)
	if err != nil {
		fmt.Printf("json.Marshal fail, err: %v\n", err)
		return
	}

	// 如果不是延时消息,则直接将消息推送到就绪队列
	if delay == 0 {
		config.RedisConn.LPush(QUEUE_READY, packageStr)
	} else {
		// 否则将消息写入到有序集合中
		z := redis.Z{
			Score:  float64(int(now) + delay),
			Member: packageStr,
		}
		config.RedisConn.ZAdd(QUEUE_DELAY, z)
	}
}

// 从有序集合中取出数据推送到就绪队列中
func (q *Queue) tryToPullDelayQueue() {
	for {
		// 当前时间
		now := time.Now().UnixMilli() / 1000
		// 每次取 128 条数据
		z := redis.ZRangeBy{
			Max:    fmt.Sprintf("%d", now),
			Min:    "-inf",
			Offset: 0,
			Count:  128,
		}
		cmd := config.RedisConn.ZRevRangeByScore(QUEUE_DELAY, z)
		items, err := cmd.Result()
		if err != nil {
			fmt.Printf("ZRevRangeByScore cmd.Result fail, err: %v\n", err)
			continue
		}
		for _, item := range items {
			// 从有序集合中移除该数据
			intCmd := config.RedisConn.ZRem(QUEUE_DELAY, item)
			if intCmd.Err() != nil {
				continue
			}
			var packageData *PackageData
			// 将数据JSON反序列化解析
			err = json.Unmarshal([]byte(item), &packageData)
			if err != nil {
				// 解析失败则推送到失败队列
				fmt.Printf("json.Unmarshal fail, err: %v\n", err)
				config.RedisConn.LPush(QUEUE_FAILED, item)
				continue
			}
			// 将数据推送到就绪队列
			config.RedisConn.LPush(QUEUE_READY, item)
		}

		// 间隔1s之后再次轮询
		time.Sleep(time.Second)
	}
}

func (q *Queue) Wait(successCallback func(string) error, failureCallback func(error, *PackageData) *PackageData) {
	// 启动一个协程用于轮询有序集合消息并推送到就绪队列
	go q.tryToPullDelayQueue()

	for {
		// 阻塞监听就绪队列消息
		stringSliceCmd := config.RedisConn.BRPop(0, QUEUE_READY)
		if stringSliceCmd.Err() != nil {
			fmt.Printf("RedisConn.BRPop stringSliceCmd.Err fail, err: %v\n", stringSliceCmd.Err().Error())
			continue
		}
		data, err := stringSliceCmd.Result()
		if err != nil {
			fmt.Printf("RedisConn.BRPop stringSliceCmd.Result fail, err: %v\n", err)
			continue
		}
		// 将数据JSON反序列化解析
		var packageData *PackageData
		packageStr := data[1]
		err = json.Unmarshal([]byte(packageStr), &packageData)
		if err != nil {
			fmt.Printf("json.Unmarshal fail, err: %v\n", err)
			// 解析失败则推送到失败队列
			config.RedisConn.LPush(QUEUE_FAILED, packageStr)
			continue
		}

		// 将消息回调到我们在业务层面写的回调函数中
		err = successCallback(packageData.Data)
		if err != nil {
			fmt.Printf("successCallback fail, err: %v\n", err)

			// 如果出现异常并且我们设置了失败回调函数
			packageData.MaxAttempts = q.MaxAttempts
			packageData.Error = err.Error()
			if failureCallback != nil {
				// 则会回调到我们在业务层面写的回调函数中
				packageModified := failureCallback(err, packageData)
				// 重新构造消息
				packageData.Data = packageModified.Data
				packageData.Attempts = packageModified.Attempts
				packageData.MaxAttempts = packageModified.MaxAttempts
				packageData.Error = packageModified.Error
			}
			continue
		}

		// 如果已经超过了最大重试次数,则将消息推送到失败队列
		packageData.Attempts += 1
		if packageData.Attempts > packageData.MaxAttempts {
			q.fail(packageData)
		} else {
			// 否则进入有序集合中,等待下一轮的轮询
			q.retry(packageData)
		}
	}
}

// 重新添加到有序集合
func (q *Queue) retry(packageData *PackageData) {
	// 延时时间随着重试的次数成倍增加
	delay := time.Now().Second() + q.RetrySeconds*packageData.Attempts
	packageStr, err := json.Marshal(packageData)
	if err != nil {
		fmt.Printf("json.Marshal fail, err: %v\n", err)
		return
	}
	z := redis.Z{
		Score:  float64(delay),
		Member: packageStr,
	}
	config.RedisConn.ZAdd(QUEUE_DELAY, z)
}

// 推送到失败的队列
func (q *Queue) fail(packageData *PackageData) {
	packageStr, err := json.Marshal(packageData)
	if err != nil {
		fmt.Printf("json.Marshal fail, err: %v\n", err)
		return
	}
	config.RedisConn.LPush(QUEUE_FAILED, packageStr)
}

func InitQueue() {
	queue := NewQueue()
	queue.Wait(func(data string) error {
		// 正常接收到消息
		fmt.Printf("接收到消息: %s\n", data)
		return nil
	}, func(err error, packageData *PackageData) *PackageData {
		// 消息异常了在这里增加处理逻辑
		return packageData
	})
}

使用
go extend.InitQueue()
启动了一个消费者。从这里可以看出在 Go 中不需要单独启动一个消费者脚本进程,只需启动一个异步的协程即可监听消息,因此在 Go 中实现 Redis 延时队列相较于 PHP 要方便很多。

package main

import (
	"go_delay/app"
	"go_delay/app/config"
	"go_delay/app/extend"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	app.InitRoutes(r)
	config.InitRedis()
	go extend.InitQueue()
	r.Run(":8001")
}

这个是通过 API 接口将消息,推送到延时队列中。

package controller

import (
	"go_delay/app/extend"
	"net/http"

	"github.com/gin-gonic/gin"
)

func SendMsg(c *gin.Context) {
	// 接收 GET 参数
	content := c.Query("content")
	if len(content) == 0 {
		c.JSON(http.StatusOK, gin.H{
			"msg":  "内容不能为空",
			"code": -1,
		})
		return
	}

	// 推送到延时队列 15 秒之后会执行
	queue := extend.NewQueue()
	queue.Send(content, 15)

	// 直接返回
	c.JSON(http.StatusOK, gin.H{
		"code": 0,
		"msg":  "success",
	})
}

我们直接执行
go run main.go
启动服务,然后使用 Postman 工具进行调用。

结语

看到这里我相信大家已经对基于 Redis 延时队列的实现方式,有所了解了。从上面的例子中可以看出来,这次延时队列用到的核心数据结构是 Redis 的列表和有序集合。有序集合主要用于存放设置了延时时长的消息,而列表存放的是就绪的消息,即等着被消费者消费的消息。

从 PHP 和 Go 两者语言的区别来看,在 PHP 中需要单独启动消费者脚本,还有在轮询有序集合中到期的消息,也需要在额外的进程中进行,不然就会阻塞消息的消费逻辑。而在 Go 中只需要异步开启一个协程就可以等待消息的到来,轮询到期的消息也再另外开启一个协程便可以完成对应的操作,单从这一点就可以看出 Go 的优势比 PHP 的要大。

此外,在 Go 语言中还可以利用通道 Channel 来替代 Redis,同样也可以实现延时队列,不过 Channel 不能持久化到磁盘,一旦服务挂了消息就丢失了,所以还是老老实实用 Redis 的好。再好的技术知识,也需要亲自来实践才能吸收,所以建议大家手动实践一下,如果有想要获取完整案例代码的朋友,可以在公众号内回复「8392」即可,本次分享的内容就到这里结束了,希望对大家能有所帮助。

感谢大家阅读,个人观点仅供参考,欢迎在评论区发表不同观点。

欢迎关注、分享、点赞、收藏、在看,我是微信公众号「码农先森」作者。

介绍

最近上了一个新项目,考虑到一个问题,在高并发场景下,我们无法控制前端的请求频率和次数,这就可能导致服务器压力过大,响应速度变慢,甚至引发系统崩溃等严重问题。为了解决这些问题,我们需要在后端实现一些机制,如接口限流、防重复提交和接口防抖,而这些是保证接口安全、稳定提供服务,以及防止错误数据 和 脏数据产生的重要手段。

而AOP适合在在不改变业务代码的情况下,灵活地添加各种横切关注点,实现一些通用公共的业务场景,例如日志记录、事务管理、安全检查、性能监控、缓存管理、限流、防重复提交等功能。这样不仅提高了代码的可维护性,还使得业务逻辑更加清晰专注,关于AOP不理解的可以看
这篇文章

接口限流

接口限流是一种控制访问频率的技术,通过限制在一定时间内允许的最大请求数来保护系统免受过载。限流可以在应用的多个层面实现,比如在网关层、应用层甚至数据库层。常用的限流算法有
漏桶算法(Leaky Bucket)、令牌桶算法(Token Bucket)
等。限流不仅可以防止系统过载,还可以防止恶意用户的请求攻击。

限流框架大概有

  1. spring cloud gateway集成redis限流,但属于网关层限流
  2. 阿里Sentinel,功能强大、带监控平台
  3. srping cloud hystrix,属于接口层限流,提供线程池与信号量两种方式
  4. 其他:redission、redis手撸代码

本文主要是通过 Redission 的分布式计数来实现的
固定窗口
模式的限流,也可以通过 Redission 分布式限流方案(令牌桶)的的方式RRateLimiter。

在高并发场景下,合理地实施接口限流对于保障系统的稳定性和可用性至关重要。

  • 自定义接口限流注解类
    @AccessLimit
/**
 * 接口限流
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {

    /**
     * 限制时间窗口间隔长度,默认10秒
     */
    int times() default 10;

    /**
     * 时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 上述时间窗口内允许的最大请求数量,默认为5次
     */
    int maxCount() default 5;

    /**
     * redis key 的前缀
     */
    String preKey();

    /**
     * 提示语
     */
    String msg() default "服务请求达到最大限制,请求被拒绝!";
}

  • 利用
    AOP
    实现接口限流
/**
 * 通过AOP实现接口限流
 */
@Component
@Aspect
@Slf4j
@RequiredArgsConstructor
public class AccessLimitAspect {

    private static final String ACCESS_LIMIT_LOCK_KEY = "ACCESS_LIMIT_LOCK_KEY";

    private final RedissonClient redissonClient;

    @Around("@annotation(accessLimit)")
    public Object around(ProceedingJoinPoint point, AccessLimit accessLimit) throws Throwable {

        String prefix = accessLimit.preKey();
        String key = generateRedisKey(point, prefix);

        //限制窗口时间
        int time = accessLimit.times();
        //获取注解中的令牌数
        int maxCount = accessLimit.maxCount();
        //获取注解中的时间单位
        TimeUnit timeUnit = accessLimit.timeUnit();

        //分布式计数器
        RAtomicLong atomicLong = redissonClient.getAtomicLong(key);

        if (!atomicLong.isExists() || atomicLong.remainTimeToLive() <= 0) {
            atomicLong.expire(time, timeUnit);
        }

        long count = atomicLong.incrementAndGet();
        ;
        if (count > maxCount) {
            throw new LimitException(accessLimit.msg());
        }

        // 继续执行目标方法
        return point.proceed();
    }

    public String generateRedisKey(ProceedingJoinPoint point, String prefix) {
        //获取方法签名
        MethodSignature methodSignature = (MethodSignature) point.getSignature();
        //获取方法
        Method method = methodSignature.getMethod();
        //获取全类名
        String className = method.getDeclaringClass().getName();

        // 构建Redis中的key,加入类名、方法名以区分不同接口的限制
        return String.format("%s:%s:%s", ACCESS_LIMIT_LOCK_KEY, prefix, DigestUtil.md5Hex(String.format("%s-%s", className, method)));
    }
}
  • 调用示例实现
@GetMapping("/getUser")
@AccessLimit(times = 10, timeUnit = TimeUnit.SECONDS, maxCount = 5, preKey = "getUser", msg = "服务请求达到最大限制,请求被拒绝!")
public Result getUser() {
    return Result.success("成功访问");
}

防重复提交

在一些业务场景中,重复提交同一个请求可能会导致数据的不一致,甚至严重影响业务逻辑的正确性。例如,在提交订单的场景中,重复提交可能会导致用户被多次扣款。为了避免这种情况,可以使用防重复提交技术,这对于保护数据一致性、避免资源浪费非常重要

  • 自定义接口防重注解类
    @RepeatSubmit
/**
* 自定义接口防重注解类
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {
    /**
     * 定义了两种防止重复提交的方式,PARAM 表示基于方法参数来防止重复,TOKEN 则可能涉及生成和验证token的机制
     */
    enum Type { PARAM, TOKEN }
    /**
     * 设置默认的防重提交方式为基于方法参数。开发者可以不指定此参数,使用默认值。
     * @return Type
     */
    Type limitType() default Type.PARAM;
 
    /**
     * 允许设置加锁的过期时间,默认为5秒。这意味着在第一次请求之后的5秒内,相同的请求将被视为重复并被阻止
     */
    long lockTime() default 5;
    
    //提供了一个可选的服务ID参数,通过token时用作KEY计算
    String serviceId() default ""; 
    
    /**
     * 提示语
     */
    String msg() default "请求重复提交!";
}
  • 利用
    AOP
    实现接口防重处理
/**
 * 利用AOP实现接口防重处理
 */
@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class RepeatSubmitAspect {

    private final String REPEAT_SUBMIT_LOCK_KEY_PARAM = "REPEAT_SUBMIT_LOCK_KEY_PARAM";

    private final String REPEAT_SUBMIT_LOCK_KEY_TOKEN = "REPEAT_SUBMIT_LOCK_KEY_TOKEN";

    private final RedissonClient redissonClient;

    private final RedisRepository redisRepository;

    @Pointcut("@annotation(repeatSubmit)")
    public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {

    }

    /**
     * 环绕通知, 围绕着方法执行
     * 两种方式
     * 方式一:加锁 固定时间内不能重复提交
     * 方式二:先请求获取token,再删除token,删除成功则是第一次提交
     */
    @Around("pointCutNoRepeatSubmit(repeatSubmit)")
    public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        //用于记录成功或者失败
        boolean res = false;

        //获取防重提交类型
        String type = repeatSubmit.limitType().name();
        if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
            //方式一,参数形式防重提交
            //通过 redissonClient 获取分布式锁,基于IP地址、类名、方法名生成唯一key
            String ipAddr = IPUtil.getIpAddr(request);
            String preKey = repeatSubmit.preKey();
            String key = generateTokenRedisKey(joinPoint, ipAddr, preKey);

            //获取注解中的锁时间
            long lockTime = repeatSubmit.lockTime();
            //获取注解中的时间单位
            TimeUnit timeUnit = repeatSubmit.timeUnit();

            //使用 tryLock 尝试获取锁,如果无法获取(即锁已被其他请求持有),则认为是重复提交,直接返回null
            RLock lock = redissonClient.getLock(key);
            //锁自动过期时间为 lockTime 秒,确保即使程序异常也不会永久锁定资源,尝试加锁,最多等待0秒,上锁以后 lockTime 秒自动解锁 [lockTime默认为5s, 可以自定义]
            res = lock.tryLock(0, lockTime, timeUnit);

        } else {
            //方式二,令牌形式防重提交
            //从请求头中获取 request-token,如果不存在,则抛出异常
            String requestToken = request.getHeader("request-token");
            if (StringUtils.isBlank(requestToken)) {
                throw new LimitException("请求未包含令牌");
            }
            //使用 request-token 和 serviceId 构造Redis的key,尝试从Redis中删除这个键。如果删除成功,说明是首次提交;否则认为是重复提交
            String key = String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_TOKEN, repeatSubmit.serviceId(), requestToken);
            res = redisRepository.del(key);
        }

        if (!res) {
            log.error("请求重复提交");
            throw new LimitException(repeatSubmit.msg());
        }

        return joinPoint.proceed();
    }

    private String generateTokenRedisKey(ProceedingJoinPoint joinPoint, String ipAddr, String preKey) {
        //根据ip地址、用户id、类名方法名、生成唯一的key
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        String className = method.getDeclaringClass().getName();
        String userId = "seven";
        return String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_PARAM, preKey, DigestUtil.md5Hex(String.format("%s-%s-%s-%s", ipAddr, className, method, userId)));
    }
}
  • 调用示例
@PostMapping("/saveUser")
@RepeatSubmit(limitType = RepeatSubmit.Type.PARAM,lockTime = 5,timeUnit = TimeUnit.SECONDS,preKey = "saveUser",msg = "请求重复提交")
public Result saveUser() {
    return Result.success("成功保存");
}

接口防抖

接口防抖是一种优化用户操作体验的技术,主要用于减少短时间内高频率触发的操作。例如,当用户快速点击按钮时,我们可以通过防抖机制,只处理最后一次触发的操作,而忽略前面短时间内的多次操作。防抖技术常用于输入框文本变化事件、按钮点击事件等场景,以提高系统的性能和用户体验。

后端接口防抖处理主要是为了避免在短时间内接收到大量相同的请求,特别是由于前端操作(如快速点击按钮)、网络重试或异常情况导致的重复请求。后端接口防抖通常涉及记录最近的请求信息,并在特定时间窗口内拒绝处理相同或相似的请求。

  • 定义自定义注解
    @AntiShake
// 该注解只能用于方法
@Target(ElementType.METHOD) 
// 运行时保留,这样才能在AOP中被检测到
@Retention(RetentionPolicy.RUNTIME) 
public @interface AntiShake {
    // 默认防抖时间1秒,单位秒
    long value() default 1000L; 
}
  • 实现
    AOP
    切面处理防抖
@Aspect // 标记为切面类
@Component // 让Spring管理这个Bean
public class AntiShakeAspect {
 
    private ThreadLocal<Long> lastInvokeTime = new ThreadLocal<>();
 
    @Around("@annotation(antiShake)") // 拦截所有标记了@AntiShake的方法
    public Object aroundAdvice(ProceedingJoinPoint joinPoint, AntiShake antiShake) throws Throwable {
        long currentTime = System.currentTimeMillis();
        long lastTime = lastInvokeTime.get() != null ? lastInvokeTime.get() : 0;
        
        if (currentTime - lastTime < antiShake.value()) {
            // 如果距离上次调用时间小于指定的防抖时间,则直接返回,不执行方法
            return null; // 或者根据业务需要返回特定值
        }
        
        lastInvokeTime.set(currentTime);
        return joinPoint.proceed(); // 执行原方法
    }
}
  • 调用示例代码
@PostMapping("/clickButton")
@AntiShake(value = 1000)
public Result clickButton() {
    return Result.success("成功点击按钮");
}

关于作者

来自一线程序员Seven的探索与实践,持续学习迭代中~

本文已收录于我的个人博客:
https://www.seven97.top

公众号:seven97,欢迎关注~

大家在工作中肯定遇到过接口被人狂刷的经历,就算没有经历过,在接口开发的过程中,我们也需要对那些容易被刷的接口或者和会消耗公司金钱相关的接口增加防盗刷功能。例如,发送短信接口以及发送邮件等接口,我看了国内很多产品的短信登录接口,基本上都是做了防盗刷,如果不做的话,一夜之间,也许公司都赔完了┭┮﹏┭┮。

假设我们正在开发一个发送短信(仅国内)的接口,过程如下

  1. 接口定义为
    /sendSms
  2. 请求参数只有
    phone
  3. 在处理请求时,我们对请求参数phone进行了合法性校验
  4. 如果手机号合法,那么调用腾讯云等服务商的发送短信Api,向目标手机号发送短信
  5. 流程结束

上面便是一个最简单的向手机号发送短信验证码的接口,不考虑其他和业务相关的操作。我们现在来分析一下,该接口存在的问题(刷接口)。

  1. 只对请求参数中的手机号进行合法性校验(11位手机号),并没有对手机号是否为空号进行验证,会导致别人构造大量合法但是是空号的手机号
  2. 没有增加单个手机号,每天最大发送次数
  3. 没有控制每个手机号发送间隔,会导致同一时间,向相同手机号发送大量短信

既然我们知道了发送短信验证码接口存在的缺陷,那我们将这些问题一一解决了,是不是就可以避免接口被盗刷呢?答案是只能在一定程度上防止被盗刷,因为这些恶意请求中,手机号都是通过程序无限生成的,都能通过我们的正则校验,所以对手机号进行发送次数和发送间隔限制,对他们是没有任何效果的。另外,想要避免向空号手机号发送短信的话,还需要额外的引入第三方的空号检验Api,增加了新的资源消耗。

我们现在从发送短信验证码的接口转移到其他的接口来看看,寻找一种能够应用于所有的接口,并能实现限流,幂等,防盗刷功能的方案。

公众号: 后端随笔

个人博客:
https://knowledge.xcye.xyz/

解决接口请求参数容易被构造

我们其实不难发现,导致接口被盗刷的根本原因在于请求参数很容易通过算法构造构造出来,这些通过程序生成的参数,在我们的程序看来,都是合法的。

{
"phone": "11位手机号"
}

通过上面两个对比,我们不难发现,先对于只有一个参数phone的发送短信接口来说,想要构造出淘宝发送短信的参数,难度直接上升了很多个阶梯。

我们从解决接口请求参数容易被构造的角度出发,我目前能想到的只有对请求参数进行加密,使用非对称加密的方式。具体的思路为,客户端在发送请求之前,使用服务端提供的公钥对请求参数进行加密,让请求参数看上去不那么容易被构造出来。服务端获取到请求参数后,使用私钥进行解密,然后再进行后续的一些验证操作。

那么这样可以防止接口被盗刷么?答案是,只能防君子,不能防小人。特别是对于Web端来说,如果发起盗刷的这个人,同样是一个开发者,他直接F12就可以从js文件中找到公钥。对于App来说,获取源码的方式会更难一点,但是最终公钥应该还是能够被找到的。

如果我们解决公钥容易被获取的问题,是不是可以通过这种方式防止接口被盗刷呢?如果能够解决公钥容易被获取的问题,在一定程度上,确实是可以解决接口被盗刷的问题,但是现在又将问题转移到了获取公钥接口上,我们还是需要解决获取公钥接口被盗刷的问题。

而且如果获取到的公钥不能存在时效性,可以被多次使用,那么这些通过加密实现防盗刷的接口,在公钥被泄露的情况下,还是会存在被盗刷的问题。想要解决的话,可以让公钥只能使用一次,或者只能在很短时间内使用,再者只能被多少个请求使用。我最终的解决方案也是类似于这个,让令牌只能使用一次。

而且使用公钥进行加密,通常是防止在请求过程中发生的中间人攻击,是为了解决参数被修改以及泄露的问题。

Ticket机制

我最终并不是通过解决参数容易被构造来防止盗刷的,我是通过对请求进行是否是机器人判断,如果是非法请求,强制必须先通过图形验证码,只有合法的请求,服务端才会进行处理。

我基于Ticket机制,客户端在发送请求之前,必须先向服务端申请一个Ticket。服务端在处理申请Ticket请求时,对请求进行判断,判断包含了是否是恶意请求和是否需要进行限流。当这两步都通过后,服务端会生成一个被加密,存在时效性并且只能使用一次的Ticket,客户端发送真正请求时,需要携带这个Ticket。每个Ticket只能被使用一次,而且客户端每次都携带Ticket,还可以通过Ticket实现请求的幂等性。

这种方案并不和任何的接口耦合,Ticket是携带在请求头上,不会对请求参数造成污染。

申请Ticket

我最终是使用Ticket完成了限流,防盗刷,幂等性这三个功能,为了让这个功能更加的通用,不和任何的接口相耦合。在申请Ticket时,客户端需要传递两个参数,分别是serviceType和primaryKey。serviceType用于控制该接口的类型,而primaryKey会被用于限流。最终结合配置中心,做到了能够轻松的对任何类型的请求进行独立的限流,UserAgent黑名单与白名单,Ip限流等操作。

具体的执行过程为(以发送短信验证码为例):

  1. 客户端调用接口申请Ticket,传递的参数为
    {serviceType: sms, primaryKey: 用户手机号}

  2. 服务端对客户端请求进行验证


    1. UserAgent是否在黑名单中(恶意请求的UserAgent基本上都是同一个),UserAgent还可以有很多的玩法,比如类似于Ip一样,对UserAgent进行限流(会影响一部分正常用户)
    2. 从请求头中对用户身份进行初步识别。可以和客户端协商好,在一些请求头值上做文章,帮助服务端识别请求者身份
    3. 对IP进行识别。很多的恶意请求都来自于不同的Ip,有部分来自同一个网段,我们可以对Ip结合serviceType进行限制。
  3. 如果服务端识别请求是恶意请求,则在响应体中将captchaStatus设置为true,表示需要客户端进行图形验证码验证

  4. 下一步,服务端通过serviceType,从配置中获取限流规则。通过serviceType+primaryKey作为key,看是否能通过指定的限流。

  5. 通过限流后,服务端使用对称加密对{captchaStatus, primaryKey}进行加密,得到Ticket。这一步的目的是为了在最终验证Ticket时,从解密的数据中获取captchaStatus,避免captchaStatus是由客户端传递,从而解决请求绕过图形验证码验证问题,客户端根据captchaStatus判断该Ticket是否需要用户通过图形验证码,才能执行后续操作。

  6. 服务端将Ticket放入Redis,并且设置过期时间,然后将{ticket, captchaStatus}返回给客户端。

服务端返回的Ticket是加密后的密文,存在过期时间,保存在Redis中,并且只能被使用一次,无法被客户端构造出来。尽管加密算法被不小心泄露,服务端也无法从Redis中查询到这个"合法的Ticket",所以这个Ticket是足够安全的。

图形验证码

调用申请Ticket接口后,响应参数中包含两个参数:captchaStatus, ticket。captchaStatus表示该Ticket是否需要客户端通过图形验证码。

当captchaStatus为true时,客户端调用另一个接口加载图形验证码,在调用接口时,需要携带上一步获得的Ticket,服务端最终会将本次的图形验证码和Ticket进行绑定,最终实现在下一步中通过Ticket获取图形验证码的验证结果,具体步骤为:

  1. 客户端携带申请到的Ticket加载图形验证码数据
  2. 服务端从请求头中获取Ticket,从Db中查询该Ticket加载过几次图形验证码,如果超过最大加载次数,那么直接通知客户端重新申请新Ticket,并且删除和旧Ticket相关的数据。
  3. 验证通过后,生成图形验证码数据,得到该图形验证码的key,然后将key和ticket放入Db中存储起来,目的是为了保存图形验证码验证结果
  4. 客户端接收到图形验证码数据并加载

在防盗刷功能中,最有效的还得是验证码功能

服务端验证Ticket

当客户端完成上面两个后,客户端现在才开始调用真正的接口(发送短信)。在调用发送短信验证码时,客户端需要携带申请到的Ticket和图形验证码Key(如果captchaStatus为true)。

服务端接收到请求后,具体的处理步骤如下:

  1. 从请求中获取Ticket,并且对Ticket进行解密,从Redis中查询该Ticket是否存在

    尽管我们的防盗刷逻辑被人知晓,他们也不能随意的构造Ticket

  2. 从解密后的数据中获取captchaStatus字段的值,如果为true,则表示该Ticket需要执行图形验证码验证。服务端从DB中查询和该Ticket最后一次绑定的图形验证码Key的结果,如果没有进行验证或者结果为失败的话,直接结束流程

  3. 对Ticket进行幂等性验证,主要是通过判断该Ticket之前是否被使用过,如果上一个请求已经完成,那么直接从Redis中获取执行结果,并返回

  4. 当上面都没有问题后,现在才开始执行最终的业务逻辑,这里是执行发送短信验证码。因为这个功能并不和任何的接口耦合,如果我们需要更细的防盗刷,还可以在具体的接口里面做文章。

  5. 执行完毕后,需要把Ticket相关的数据都删除。

上面便是我实现接口防盗刷的具体过程,现在我们来验证一下,这个防盗刷是否真的能防(还是以发送短信验证码)?

  1. 构造大量合法但空号的手机号

    每次请求时,都需要先申请Ticket,primaryKey为手机号。因为这些恶意请求的UserAgent是相同的,如果我们预先接收到报警并且将UserAgent放入黑名单中,这些请求会直接被拦截。

    就算UserAgent每被拦截,还有Ip等其他的限流措施。如果都通过,我们还可以直接强制要求每一个请求都进行图形验证码验证,因为图形验证码的破解难度更高,基本上已经劝退很多人了,强制进行图形验证码验证,对于正常用户来说,也只会降低使用体验。

    对于手机号为空号来说,如果这个用户确实通过了上面这些措施,那么基本上可以保证他是一个真实用户,所以手机号是否为空号验证,我觉得是多此一举,除非发送短信的资源真的非常宝贵。

  2. Ticket被泄露,被伪造

    在公司没出内鬼的情况下,Ticket是不可用被伪造出来的,并且就算被伪造出来,这个Ticket也没有保存至Db。如果该Ticket的captchaStatus为false并且被泄露了,他们也只能在指定时间内使用该Ticket,并且只能使用一次。不可能会存在Ticket无限泄露的情况。

在上面的过程中,服务端验证请求是否是机器人,还可以在发送真正请求时进行验证,如果验证失败,客户端根据响应体执行对应的操作,然后携带Ticket重发请求。

上面的逻辑并没有对正常用户的验证结果进行缓存,这会导致,正常用户在调用这些接口时,每调用一次,都需要通过图形验证码。

其他措施

还有其他的措施,也可以增加接口被盗刷的情况。这些措施包括增加防盗刷逻辑被破解难度和防止接口被盗刷。

先说防止接口被盗刷,本质上是防止接口被泄露。对于App来说,某个人想要知道我们接口信息的话,必须对App进行反编译,我对App反编译不太了解,可以试试那些增大反编译的措施,就算不进行反编译,使用Fiddler工具也是可以看到请求信息的。对于Web端来说,用户只需要按F12就可以看到JavaScript代码,以及每个请求的参数,响应体等。我们可以禁用F12以及右键(降低用户体验),以及在生产环境中,添加当用户按F12后,自动进入无限Debug模式。这两个操作可以增加我们接口被暴露的风险,从而在一定程度上起到"防盗刷"目的。

对于增加防盗刷逻辑被破解难度来说,市场上有很多的App的限流等规则都被人攻破了,我个人觉得会被攻破,除了接口设计的原因外,还有一个是接口的响应体中提示了很明显的错误信息。比如我们访问某个增加了防刷功能的接口,该接口提示UserAgent无效,当前Ip已被限流,Ticket无效,未进行图形验证码验证等很明显的信息。这些信息其实已经间接提示了让请求变合法的步骤是什么,这虽然可以帮助开发人员进行调试,但也间接的帮助了那些发送恶意请求的人。所以为了增大防盗刷逻辑被破解的难度,我们不需要返回这些很明显的提示信息,可以无论什么原因,都返回"非法请求",对于公司开发人员来说,他们自己通过code从开发文档中查询每个code所代表的意思。

以上便是我对于防止接口被盗刷的一些见解,可能还有更优的方案,但是我目前确实只能想到这一种。另外,也可以使用已有的服务,比如腾讯云和阿里云等服务商的验证码。我使用的图形验证码是开源的,来自于
dromara
大佬开源的
Java 行文验证码
,使用起来非常的方便,并且支持滑块,旋转,滑动,文字点选,非常感谢大佬。此外,因为每次请求时申请到的Ticket都是加密的,在加密和解密的过程中,性能消耗也是一个可以优化的点,具体得看自己选择的算法是什么。