整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

纯CSS实现波浪效果

直以来,使用纯 CSS 实现波浪效果都是十分困难的。

因为实现波浪的曲线需要借助贝塞尔曲线。

而使用纯 CSS 的方式,实现贝塞尔曲线,额,暂时是没有很好的方法。

当然,借助其他力量(SVG、CANVAS),是可以很轻松的完成所谓的波浪效果的,先看看,非 CSS 方式实现的波浪效果。

使用 SVG 实现波浪效果

借助 SVG ,是很容易画出三次贝塞尔曲线的。

看看效果:

代码如下:

<svg width="200px" height="200px" version="1.1" xmlns="http://www.w3.org/2000/svg">
    <text class="liquidFillGaugeText" text-anchor="middle" font-size="42px" transform="translate(100,120)" style="fill: #000">50.0%</text>
    <!-- Wave -->
    <g id="wave">
        <path id="wave-2" fill="rgba(154, 205, 50, .8)" d="M 0 100 C 133.633 85.12 51.54 116.327 200 100 A 95 95 0 0 1 0 100 Z">
        <animate dur="5s" repeatCount="indefinite" attributeName="d" attributeType="XML" values="M0 100 C90 28, 92 179, 200 100 A95 95 0 0 1 0 100 Z;
                                    M0 100 C145 100, 41 100, 200 100 A95 95 0 0 1 0 100 Z;
                                    M0 100 C90 28, 92 179, 200 100 A95 95 0 0 1 0 100 Z"></animate>
        </path>
    </g>
    <circle cx="100" cy="100" r="80" stroke-width="10" stroke="white" fill="transparent"></circle>
    <circle cx="100" cy="100" r="90" stroke-width="20" stroke="yellowgreen" fill="none" class="percentage-pie-svg"></circle>
</svg>

画出三次贝塞尔曲线的核心在于这一段。感兴趣的可以自行去研究研究。

使用 canvas 实现波浪效果

使用 canvas 实现波浪效果的原理与 SVG 一样,都是利用路径绘制出三次贝塞尔曲线并赋予动画效果。

使用 canvas 的话,代码如下:

$(function() {
    let canvas = $("canvas");
    let ctx = canvas[0].getContext('2d');
    let radians = (Math.PI / 180) * 180;
    let startTime = Date.now();
    let time = 2000;
    let clockwise = 1;
    let cp1x, cp1y, cp2x, cp2y;
     
    // 初始状态
    // ctx.bezierCurveTo(90, 28, 92, 179, 200, 100);
    // 末尾状态
    // ctx.bezierCurveTo(145, 100, 41, 100, 200, 100);
     
    requestAnimationFrame(function waveDraw() { 
        let t = Math.min(1.0, (Date.now() - startTime) / time);
           
        if(clockwise) {
            cp1x = 90 + (55 * t);
            cp1y = 28 + (72 * t);
            cp2x = 92 - (51 * t);
            cp2y = 179 - (79 * t);
        } else {
            cp1x = 145 - (55 * t);
            cp1y = 100 - (72 * t);
            cp2x = 41 + (51 * t);
            cp2y = 100 + (79 * t);
        }
         
        ctx.clearRect(0, 0, 200, 200);
        ctx.beginPath();
        ctx.moveTo(0, 100);
        // 绘制三次贝塞尔曲线
        ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, 200, 100);
        // 绘制圆弧
        ctx.arc(100, 100, 100, 0, radians, 0);
        ctx.fillStyle = "rgba(154, 205, 50, .8)";
        ctx.fill();
        ctx.save(); 
         
        if( t == 1 ) {
            startTime = Date.now();
            clockwise = !clockwise;
        }
 
        requestAnimationFrame(waveDraw);
    });
})

主要是利用了动态绘制 ctx.bezierCurveTo() 三次贝塞尔曲线实现波浪的运动效果,感兴趣的可以自行研究。

纯 CSS 实现波浪效果

好,接下来才是本文的重点!使用纯 CSS 的方式,实现波浪的效果。

你 TM 在逗我?刚刚不是还说使用 CSS 无能为力吗?

是,我们没有办法直接绘制出三次贝塞尔曲线,但是我们可以利用一些讨巧的方法,模拟达到波浪运动时的效果,姑且把下面这种方法看作一种奇技淫巧。

原理

原理十分简单,我们都知道,一个正方形,给它添加 border-radius: 50%,将会得到一个圆形。

border-radius:用来设置边框圆角,当使用一个半径时确定一个圆形。

好的,如果 border-radius 没到 50%,但是接近 50% ,我们会得到一个这样的图形:

注意边角,整个图形给人的感觉是有点圆,却不是很圆。额,这不是废话吗

好的,那整这么个图形又有什么用?还能变出波浪来不成?

没错!就是这么神奇。:) 我们让上面这个图形滚动起来(rotate) ,看看效果:

可能很多人看到这里还没懂旋转起来的意图,仔细盯着一边看,是会有类似波浪的起伏效果的。

而我们的目的,就是要借助这个动态变换的起伏动画,模拟制造出类似波浪的效果。

实现

当然,这里看到是全景实现图,所以感觉并不明显,OK,让我们用一个个例子看看具体实现起来能达到什么样的效果。

我们利用上面原理可以做到的一种波浪运动背景效果图:

CodePen Demo -- Pure CSS Wave[1]

后面漂浮的波浪效果,其实就是利用了上面的 border-radius: 45% 的椭圆形,只是放大了很多倍,视野之外的图形都 overflow: hidden,只留下了一条边的视野,并且增加了一些相应的 transform 变换。

注意,这里背景是蓝色静止的,运动是白色的椭圆形。

代码也很简单,SCSS 代码如下:

body {
    position: relative;
    align-items: center;
    min-height: 100vh;
    background-color: rgb(118, 218, 255);
    overflow: hidden;
 
    &:before, &:after {
        content: "";
        position: absolute;
        left: 50%;
        min-width: 300vw;
        min-height: 300vw;
        background-color: #fff;
        animation-name: rotate;
        animation-iteration-count: infinite;
        animation-timing-function: linear;
    }
 
    &:before {
        bottom: 15vh;
        border-radius: 45%;
        animation-duration: 10s;
    }
 
    &:after {
        bottom: 12vh;
        opacity: .5;
        border-radius: 47%;
        animation-duration: 10s;
    }
}
 
@keyframes rotate {
    0% {
        transform: translate(-50%, 0) rotateZ(0deg);
    }
    50% {
        transform: translate(-50%, -2%) rotateZ(180deg);
    }
    100% {
        transform: translate(-50%, 0%) rotateZ(360deg);
    }
}

为了方便写 DEMO,用到的长度单位是 VW 与 VH,不太了解这两个单位的可以戳这里:vh、vw、vmin、vmax 知多少[2]

可能有部分同学,还存在疑问,OK,那我们把上面的效果缩小 10 倍,将视野之外的动画也补齐,那么其实生成波浪的原理是这样的:

图中的虚线框就是我们实际的视野范围。

值得探讨的点

值得注意的是,要看到,这里我们生成波浪,并不是利用旋转的椭圆本身,而是利用它去切割背景,产生波浪的效果。那为什么不直接使用旋转的椭圆本身模拟波浪效果呢?因为中间高,两边低的效果不符合物理学原理,看上去十分别扭;

可以点进去看看下面这个例子:

CodePen Demo -- pure css wave[3]

使用纯 CSS 实现波浪进度图

好,既然掌握了这种方法,下面我们就使用纯 CSS 实现上面最开始使用 SVG 或者 CANVAS 才能实现的波浪进度图。

HTML 结构如下:

<div class="container">
    <div class="wave"></div>
</div>
.wave {
    position: relative;
    width: 200px;
    height: 200px;
    background-color: rgb(118, 218, 255);
    border-radius: 50%;
  
    &::before,
    &::after{
        content: "";
        position: absolute;
        width: 400px;
        height: 400px;
        top: 0;
        left: 50%;
        background-color: rgba(255, 255, 255, .4);
        border-radius: 45%;
        transform: translate(-50%, -70%) rotate(0);
        animation: rotate 6s linear infinite;
        z-index: 10;
    }
     
    &::after {
        border-radius: 47%;
        background-color: rgba(255, 255, 255, .9);
        transform: translate(-50%, -70%) rotate(0);
        animation: rotate 10s linear -5s infinite;
        z-index: 20;
    }
}
 
@keyframes rotate {
    50% {
        transform: translate(-50%, -73%) rotate(180deg);
    } 100% {
        transform: translate(-50%, -70%) rotate(360deg);
    }
}

效果图:

CodePen Demo -- Pure Css Wave Loading[4]

虽然效果差了一点点,但是相较于要使用学习成本更高的 SVG 或者 CANVAS,这种纯 CSS 方法无疑可使用的场景更多,学习成本更低!

纯 CSS 的充电效果

还能实现类似这样的充电效果:

一些小技巧

单纯的让一个 border-radius 接近 50 的椭圆形旋转,动画效果可能不是那么好,我们可以适当的添加一些其他变换因素,让动画效果看上去更真实:

  • 在动画过程中,动态的改变 border-radius 的值;
  • 在动画过程中,利用 transform 对旋转椭圆进行轻微的位移、变形;
  • 上面也演示到了,多个椭圆同时转动,赋予不同时长的动画,并且添加轻微的透明度,让整个效果更加逼真。
  • 绍一些你可能没用过的SVG小技巧。

    在平时开发中,很多时候都会用到SVG。大部分情况我们都不必关注SVG里面到底是什么,直接当成图片资源引入就行,比如常见的图标资源

    我们可以通过多种方式使用这个特殊的图片

    <img src="a.svg">
    
    .icon{
      background: url("a.svg")
    }
    

    甚至直接放到HTML

    <div>
      <svg>
      	...
      </svg>
    </div>
    

    这些都没什么问题,但有时候,我们需要的是可以自适应尺寸的,比如像这样的渐变边框,尺寸会随着文本内容的变化而变化,而不是固定尺寸,如下

    或者是这样的虚线渐变边框

    这样的该如何用 SVG 动态实现呢,一起看看吧

    一、SVG导出的局限性

    SVG通常不是手写的(能手写任意路径的都是大神),几乎都是设计师借助软件绘制生成的,比如设计都很喜欢的Figma(对前端非常友好,可以尝试一下)

    比如前面提到的渐变边框,在Figma中就是这样

    对于设计师来说,渐变边框很容易,只需要选择边框类型就行了

    对于 CSS 来说,这还算一个比较麻烦的事,通常我们需要额外嵌套一层渐变背景,通过遮盖或者mask裁切的方式实现,有兴趣的可以尝试一下,这里暂不展开。

    那么,这个设计可以直接通过导出SVG实现吗?

    先试试,Figma中可以直接将这个边框复制成SVG格式

    下面是这段复制出来的SVG代码(大概还是能看得懂一些的...)

    <svg width="41" height="25" viewBox="0 0 41 25" fill="none" xmlns="http://www.w3.org/2000/svg">
      <rect x="1" y="1" width="39" height="23" rx="4" stroke="url(#paint0_linear_1_2)" stroke-linecap="round"/>
      <defs>
      <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
      	<stop stop-color="#FFD75A"/>
      	<stop offset="1" stop-color="#ED424B"/>
      </linearGradient>
      </defs>
    </svg>
    

    我们尝试让这段SVG尺寸跟随button的大小,就行这样

    <style>
      svg{
        position: absolute;
        inset: 0;
      }
    </style>
    <button>
      CSS
      <svg>...</svg>
    </button>
    

    在内容不定的情况下,就变成了这样

    很显然不行,因为生成的SVG宽高是固定的,没法跟随文本内容自适应尺寸

    既然 SVG很擅长渐变边框,而 CSS很擅长自适应,那么,有没有取长补短的办法呢?

    当然也是有的!不过需要“改造”一下,接着往下看

    二、SVG 自适应尺寸

    首先我们把上面的那段SVG拿过来

    <svg width="41" height="25" viewBox="0 0 41 25" fill="none" xmlns="http://www.w3.org/2000/svg">
      <rect x="1" y="1" width="39" height="23" rx="4" stroke="url(#paint0_linear_1_2)" stroke-linecap="round"/>
      <defs>
      <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
      	<stop stop-color="#FFD75A"/>
      	<stop offset="1" stop-color="#ED424B"/>
      </linearGradient>
      </defs>
    </svg>
    

    有没有发现这里很多数值都固定的?如果想实现自适应,我们就需要将这些值改成百分比形式,注意看这个rect,有个xy坐标,我们现在宽高都是100%了,所以这里的坐标也要改成0,不然就撑出去了

    <svg width="100%" height="100%" viewBox="0 0 100% 100%" fill="none" xmlns="http://www.w3.org/2000/svg">
      <rect x="0" y="0" width="100%" height="100%" rx="4" stroke="url(#paint0_linear_1_2)" stroke-linecap="round"/>
      <defs>
      <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
      	<stop stop-color="#FFD75A"/>
      	<stop offset="1" stop-color="#ED424B"/>
      </linearGradient>
      </defs>
    </svg>
    

    为了验证这个 SVG的自适应,我们将这个SVG放在一个div

    <div style="width: 100px;height: 80px;">
      <svg>...</svg>
    </div>
    
    <div style="width: 200px;height: 180px;">
      <svg>...</svg>
    </div>
    

    效果如下

    是不是已经自适应了?

    不过还是有点问题,仔细观察,圆角处有些不自然,感觉被裁剪了一样

    造成这种现象的原因有两个:

    1. SVG描边是居中描边,并且不可修改
    2. SVG默认是超出隐藏的,也就是自带overflow:hidden

    我们把边框改大一点就可以很明显的观察到描边是居中的

    由于是居中的,所以在不做修改的情况下,我们看到的其实只有原边框的一半,利用这个原理我们其实可以实现常说的0.5px边框,有兴趣的可以参考我之前这篇文章:使用svg描边来实现移动端1px

    在这里,我再介绍一种新的方式,那就是利用 CSS calc !

    没错,在 SVG中也可以使用CSS函数,比如我们这里边框是4px,那么坐标xy就应该是2,然后宽高应该是calc(100% - 4px),所以可以很自然的改成这样

    <div style="width: 100px;height: 80px;">
      <svg width="100%" height="100%">
        <rect x="2" y="2" width="100%" height="100%" style="width: calc(100% - 4px);height: calc(100% - 4px);"  rx="4" stroke="url(#paint0_linear_1_2)" stroke-width="4" stroke-linecap="round"/>
         <defs>
        <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
          <stop stop-color="#FFD75A"/>
          <stop offset="1" stop-color="#ED424B"/>
        </linearGradient>
        </defs>
      </svg>
    </div>
    

    非常完美了,不会有任何裁剪!(大家也可以复制上面这段代码放在 HTML 中验证)

    这样就“轻易”实现了SVG的尺寸自适应

    这里小结一下

    1. SVG的尺寸改为`百分比
    2. 由于是居中描边,所以要修正一下坐标和大小

    除此之外,还能直接加上style样式,就像这样

    <svg width="100%" height="100%" viewBox="0 0 100% 100%" fill="none" xmlns="http://www.w3.org/2000/svg">
      <style>
        rect{
          width: calc(100% - 4px);
          height: calc(100% - 4px);
        }
      </style>
      <rect x="2" y="2" width="100%" height="100%" rx="4" stroke="url(#paint0_linear_1_2)" stroke-width="4" stroke-linecap="round"/>
      <defs>
        <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
          <stop stop-color="#FFD75A"/>
          <stop offset="1" stop-color="#ED424B"/>
        </linearGradient>
      </defs>
    </svg>
    

    虽然看着多,但后面作用更大,可以添加更多的 CSS 样式

    三、SVG 在 HTML 中的应用

    其实前面的这段 SVG 可以直接放到 HTML 中用了,比如

    <button>
      <svg width="100%" height="100%" fill="none" xmlns="http://www.w3.org/2000/svg">
        <rect x="2" y="2" width="100%" height="100%" style="width: calc(100% - 4px);height: calc(100% - 4px);" rx="16" stroke-width="2" stroke="url(#paint0_linear_3269_5233)"/>
        <defs>
          <linearGradient id="paint0_linear_3269_5233" x1="0" y1="0" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
            <stop stop-color="#FFD75A"/>
            <stop offset="1" stop-color="#ED424B"/>
          </linearGradient>
        </defs>
      </svg>
      CSS
    </button>
    

    我们需要将这个 SVG撑满整个button,所以可以直接绝对定位

    button{
      position: relative;
    }
    button>svg{
      position: absolute;
      inset: 0;
    }
    

    这样就得到了一个自适应尺寸的、带有渐变边框的按钮,效果如下

    你也可以访问在线链接:buton with SVG (juejin.cn)[1]

    四、SVG 在 CSS 中的应用

    不知道你有没有这样的感觉,把一大段 SVG放在 HTML不是特别优雅,总觉得太臃肿了。

    如果你有这种感觉,不妨将这段 SVG转换成内联CSS代码。

    在这里可以借助张鑫旭老师的这个工具:SVG在线压缩合并工具[2]

    我们将这段SVG粘贴过去,可以得到这样的一段内联SVG

    data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='1' width='100%25' height='100%25' style='width:calc(100%25 - 2px);height:calc(100%25 - 2px)' rx='16' stroke-width='2' stroke='url(%23paint0_linear_3269_5233)'/%3E%3Cdefs%3E%3ClinearGradient id='paint0_linear_3269_5233' y2='100%25' gradientUnits='userSpaceOnUse'%3E%3Cstop stop-color='%23FFD75A'/%3E%3Cstop offset='1' stop-color='%23ED424B'/%3E%3C/linearGradient%3E%3C/defs%3E%3C/svg%3E
    

    有了这段内联SVG,我们可以直接用在background背景上

    button{
      background: url("data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='1' width='100%25' height='100%25' style='width:calc(100%25 - 2px);height:calc(100%25 - 2px)' rx='16' stroke-width='2' stroke='url(%23paint0_linear_3269_5233)'/%3E%3Cdefs%3E%3ClinearGradient id='paint0_linear_3269_5233' y2='100%25' gradientUnits='userSpaceOnUse'%3E%3Cstop stop-color='%23FFD75A'/%3E%3Cstop offset='1' stop-color='%23ED424B'/%3E%3C/linearGradient%3E%3C/defs%3E%3C/svg%3E")
    }
    

    HTML只需要干净的button元素就够了

    <button>CSS</button>
    <button>CSS & SVG</button>
    

    神奇的是,即便是转为内联了,SVG仍然保持着自适应特性,这样也能实现同样的效果,是不是好多了?

    你也可以访问在线链接:button with SVG background (juejin.cn)[3]

    五、SVG 的独特魅力

    如果说上面的效果 CSS 还能勉强模拟一下,那如果是这样的虚线呢?

    对于 SVG 就非常容易了,只需要设置stroke-dasharray属性就行,并且可以随意更改虚线的间隔

    <svg width="100%" height="100%" fill="none" xmlns="http://www.w3.org/2000/svg">
      <rect x="2" y="2" width="100%" height="100%" style="width: calc(100% - 4px);height: calc(100% - 4px);" rx="16" stroke-width="2" stroke="url(#paint0_linear_3269_5233)"  stroke-dasharray="8 4"/>
      <defs>
        <linearGradient id="paint0_linear_3269_5233" x1="0" y1="0" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
          <stop stop-color="#FFD75A"/>
          <stop offset="1" stop-color="#ED424B"/>
        </linearGradient>
      </defs>
    </svg>  
    

    还有这种虚线边缘是圆角的情况,CSS就更加无能为力了

    SVG只需要设置stroke-linecap就行

    <svg width="100%" height="100%" fill="none" xmlns="http://www.w3.org/2000/svg">
      <rect x="2" y="2" width="100%" height="100%" style="width: calc(100% - 4px);height: calc(100% - 4px);" stroke-width="2" rx="16" stroke-linecap="round"  stroke="url(#paint0_linear_3269_5233)"  stroke-dasharray="8 6"/>
      <defs>
        <linearGradient id="paint0_linear_3269_5233" x1="0" y1="0" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
          <stop stop-color="#FFD75A"/>
          <stop offset="1" stop-color="#ED424B"/>
        </linearGradient>
      </defs>
    </svg> 
    

    更进一步,SVG还能实现虚线滚动动画,CSS 应该是实现不了了

    看似复杂,其实只需要改变stroke-dashoffset属性就行了,我们可以直接在SVG中插入CSS动画

    <svg width="100%" height="100%" fill="none" xmlns="http://www.w3.org/2000/svg">
      <style>
        .rect{
          width: calc(100% - 4px);
          height: calc(100% - 4px);
          animation: move .3s infinite linear;
        }
        @keyframes move {
          0% { stroke-dashoffset: 0; }
          100% { stroke-dashoffset: 14; }
        }
      </style>
      <rect class="rect" x="2" y="2" width="100%" height="100%" stroke-width="2" rx="16" stroke-linecap="round"  stroke="url(#paint0_linear_3269_5233)"  stroke-dasharray="8 6"/>
      <defs>
        <linearGradient id="paint0_linear_3269_5233" x1="0" y1="0" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
          <stop stop-color="#FFD75A"/>
          <stop offset="1" stop-color="#ED424B"/>
        </linearGradient>
      </defs>
    </svg>  
    

    所有情况都可以将 SVG转为内联CSS直接用在背景上,极大的保证了HTML的简洁性

    你也可以访问在线链接:dot border with animation (juejin.cn)[4]

    六、总结一下

    以上就是本文的全部内容了,主要介绍了如何利用 SVGCSS各种的优势来实现更加灵活的布局,下面总结一下

    1. 设计软件导出的SVG都是固定尺寸的,不能自适应尺寸
    2. SVG很擅长渐变边框,而CSS很擅长自适应尺寸,得想办法取长补短
    3. SVG部分属性支持百分比类型,可以实现尺寸自适应
    4. SVG描边是居中描边,并且不可修改,所以需要调整圆角矩形的坐标的大小
    5. SVG中也支持 CSS部分特性,比如calc计算函数
    6. SVG还支持内嵌style标签,直接插入CSS代码
    7. 可以将SVG转为内联CSS代码,在支持SVG特性的同时极大的保证了HTML的整洁
    8. 借助SVG可以很轻松的实现渐变虚线边框
    9. SVG中还支持CSS动画,可以实现虚线滚动动画

    你可能已经发现SVG并不是非常孤立的一门语言,现在还能和 CSSHTML联动起来,充分发挥各自的优势,这样才能事半功倍 。

    [1]buton with SVG (juejin.cn): https://code.juejin.cn/pen/7341373491785236532

    [2]SVG在线压缩合并工具: https://www.zhangxinxu.com/sp/svgo/

    [3]button with SVG background (juejin.cn): https://code.juejin.cn/pen/7341378448348643379

    [4]dot border with animation (juejin.cn): https://code.juejin.cn/pen/7341382517888876582

    作者:XboxYan

    来源-微信公众号:前端侦探

    出处:https://mp.weixin.qq.com/s/VH2U-jqm3cXI0yQFrR3adQ

    流是一种控制访问速率的策略,用于限制系统、服务或API接口的请求频率或数量。它的目的是为了保护系统免受过多请求的影响,防止系统因过载而崩溃或变得不可用。限流是一种重要的性能优化和资源保护机制。


    限流的好处有以下几个:

    • 保护系统稳定性:如果系统接受太多请求,超出了其处理能力,可能导致系统崩溃或响应时间急剧增加,从而影响用户体验。限流可以帮助控制请求速率,确保系统稳定运行。
    • 保护系统可用性:有些资源可能是有限的,如数据库连接、网络带宽、内存等。通过限制对这些资源的访问,可以防止它们被耗尽,从而保护系统的可用性。
    • 防止恶意攻击:限流可以减少恶意攻击和滥用系统资源的风险。例如,防止 DDoS(分布式拒绝服务)攻击或恶意爬虫访问网站。
    • 公平分配资源:对于多个客户或用户,限流可以确保资源公平分配。每个客户都有限制的访问机会,而不会被某个客户垄断。
    • 避免雪崩效应:当系统中的一个组件或服务发生故障时,可能会导致大量请求涌入其他正常的组件或服务,进一步加剧系统负载,限流可以防止这种雪崩效应。

    限流分类

    限流的实现方案有很多种,磊哥这里稍微理了一下,限流的分类如下所示:

    1. 合法性验证限流:比如验证码、IP 黑名单等,这些手段可以有效的防止恶意攻击和爬虫采集。
    2. 容器限流:比如 Tomcat、Nginx 等限流手段,其中 Tomcat 可以设置最大线程数(maxThreads),当并发超过最大线程数会排队等待执行;而 Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数。
    3. 服务端限流:比如我们在服务器端通过限流算法实现限流,此项也是我们本文介绍的重点。

    合法性验证限流为最常规的业务代码,就是普通的验证码和 IP 黑名单系统,本文就不做过多的叙述了,我们重点来看下后两种限流的实现方案:容器限流和服务端限流。

    一、容器限流

    1.1 Tomcat 限流

    Tomcat 8.5 版本的最大线程数在 conf/server.xml 配置中,如下所示:

    <Connector port="8080" protocol="HTTP/1.1"
              connectionTimeout="20000"
              maxThreads="150"
              redirectPort="8443" />

    其中 maxThreads 就是 Tomcat 的最大线程数,当请求的并发大于此值(maxThreads)时,请求就会排队执行,这样就完成了限流的目的。

    小贴士:maxThreads 的值可以适当的调大一些,此值默认为 150(Tomcat 版本 8.5.42),但这个值也不是越大越好,要看具体的硬件配置,需要注意的是每开启一个线程需要耗用 1MB 的 JVM 内存空间用于作为线程栈之用,并且线程越多 GC 的负担也越重。最后需要注意一下,操作系统对于进程中的线程数有一定的限制,Windows 每个进程中的线程数不允许超过 2000,Linux 每个进程中的线程数不允许超过 1000。

    1.2 Nginx 限流

    Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数。

    控制速率

    我们需要使用 limit_req_zone 用来限制单位时间内的请求数,即速率限制,示例配置如下:

    limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
    server { 
        location / { 
            limit_req zone=mylimit;
        }
    }

    以上配置表示,限制每个 IP 访问的速度为 2r/s,因为 Nginx 的限流统计是基于毫秒的,我们设置的速度是 2r/s,转换一下就是 500ms 内单个 IP 只允许通过 1 个请求,从 501ms 开始才允许通过第 2 个请求。

    我们使用单 IP 在 10ms 内发并发送了 6 个请求的执行结果如下:


    从以上结果可以看出他的执行符合我们的预期,只有 1 个执行成功了,其他的 5 个被拒绝了(第 2 个在 501ms 才会被正常执行)。

    速率限制升级版
    上面的速率控制虽然很精准但是应用于真实环境未免太苛刻了,真实情况下我们应该控制一个 IP 单位总时间内的总访问次数,而不是像上面那么精确但毫秒,我们可以使用 burst 关键字开启此设置,示例配置如下:

    limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
    server { 
        location / { 
            limit_req zone=mylimit burst=4;
        }
    }

    burst=4 表示每个 IP 最多允许4个突发请求,如果单个 IP 在 10ms 内发送 6 次请求的结果如下:


    从以上结果可以看出,有 1 个请求被立即处理了,4 个请求被放到 burst 队列里排队执行了,另外 1 个请求被拒绝了。

    控制并发数

    利用 limit_conn_zone 和 limit_conn 两个指令即可控制并发数,示例配置如下:

    limit_conn_zone $binary_remote_addr zone=perip:10m;
    limit_conn_zone $server_name zone=perserver:10m;
    server {
        ...
        limit_conn perip 10;
        limit_conn perserver 100;
    }

    其中 limit_conn perip 10 表示限制单个 IP 同时最多能持有 10 个连接;limit_conn perserver 100 表示 server 同时能处理并发连接的总数为 100 个。

    小贴士:只有当 request header 被后端处理后,这个连接才进行计数。

    二、服务端限流

    服务端限流需要配合限流的算法来执行,而算法相当于执行限流的“大脑”,用于指导限制方案的实现。


    有人看到「算法」两个字可能就晕了,觉得很深奥,其实并不是,算法就相当于操作某个事务的具体实现步骤汇总,其实并不难懂,不要被它的表象给吓到哦~


    限流的常见实现算法有以下三种:

    1. 时间窗口算法
    2. 漏桶算法
    3. 令牌算法

    接下来我们分别看来。

    2.1 时间窗口算法


    所谓的滑动时间算法指的是以当前时间为截止时间,往前取一定的时间,比如往前取 60s 的时间,在这 60s 之内运行最大的访问数为 100,此时算法的执行逻辑为,先清除 60s 之前的所有请求记录,再计算当前集合内请求数量是否大于设定的最大请求数 100,如果大于则执行限流拒绝策略,否则插入本次请求记录并返回可以正常执行的标识给客户端。


    滑动时间窗口如下图所示:


    其中每一小个表示 10s,被红色虚线包围的时间段则为需要判断的时间间隔,比如 60s 秒允许 100 次请求,那么红色虚线部分则为 60s。


    我们可以借助 Redis 的有序集合 ZSet 来实现时间窗口算法限流,实现的过程是先使用 ZSet 的 key 存储限流的 ID,score 用来存储请求的时间,每次有请求访问来了之后,先清空之前时间窗口的访问量,统计现在时间窗口的个数和最大允许访问量对比,如果大于等于最大访问量则返回 false 执行限流操作,负责允许执行业务逻辑,并且在 ZSet 中添加一条有效的访问记录,具体实现代码如下。


    我们借助 Jedis 包来操作 Redis,实现在 pom.xml 添加 Jedis 框架的引用,配置如下:

    <!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>3.3.0</version>
    </dependency>

    具体的 Java 实现代码如下:

    import redis.clients.jedis.Jedis;
    
    public class RedisLimit {
        // Redis 操作客户端
        static Jedis jedis = new Jedis("127.0.0.1", 6379);
    
        public static void main(String[] args) throws InterruptedException {
            for (int i = 0; i < 15; i++) {
                boolean res = isPeriodLimiting("java", 3, 10);
                if (res) {
                    System.out.println("正常执行请求:" + i);
                } else {
                    System.out.println("被限流:" + i);
                }
            }
            // 休眠 4s
            Thread.sleep(4000);
            // 超过最大执行时间之后,再从发起请求
            boolean res = isPeriodLimiting("java", 3, 10);
            if (res) {
                System.out.println("休眠后,正常执行请求");
            } else {
                System.out.println("休眠后,被限流");
            }
        }
    
        /**
         * 限流方法(滑动时间算法)
         * @param key      限流标识
         * @param period   限流时间范围(单位:秒)
         * @param maxCount 最大运行访问次数
         * @return
         */
        private static boolean isPeriodLimiting(String key, int period, int maxCount) {
            long nowTs = System.currentTimeMillis(); // 当前时间戳
            // 删除非时间段内的请求数据(清除老访问数据,比如 period=60 时,标识清除 60s 以前的请求记录)
            jedis.zremrangeByScore(key, 0, nowTs - period * 1000);
            long currCount = jedis.zcard(key); // 当前请求次数
            if (currCount >= maxCount) {
                // 超过最大请求次数,执行限流
                return false;
            }
            // 未达到最大请求数,正常执行业务
            jedis.zadd(key, nowTs, "" + nowTs); // 请求记录 +1
            return true;
        }
    }

    以上程序的执行结果为:

    正常执行请求:0
    正常执行请求:1
    正常执行请求:2
    正常执行请求:3
    正常执行请求:4
    正常执行请求:5
    正常执行请求:6
    正常执行请求:7
    正常执行请求:8
    正常执行请求:9
    被限流:10
    被限流:11
    被限流:12
    被限流:13
    被限流:14
    休眠后,正常执行请求

    此实现方式存在的缺点有两个:

    • 使用 ZSet 存储有每次的访问记录,如果数据量比较大时会占用大量的空间,比如 60s 允许 100W 访问时;
    • 此代码的执行非原子操作,先判断后增加,中间空隙可穿插其他业务逻辑的执行,最终导致结果不准确。

    2.1 漏桶算法

    漏桶算法的灵感源于漏斗,如下图所示:



    滑动时间算法有一个问题就是在一定范围内,比如 60s 内只能有 10 个请求,当第一秒时就到达了 10 个请求,那么剩下的 59s 只能把所有的请求都给拒绝掉,而漏桶算法可以解决这个问题。


    漏桶算法类似于生活中的漏斗,无论上面的水流倒入漏斗有多大,也就是无论请求有多少,它都是以均匀的速度慢慢流出的。当上面的水流速度大于下面的流出速度时,漏斗会慢慢变满,当漏斗满了之后就会丢弃新来的请求;当上面的水流速度小于下面流出的速度的话,漏斗永远不会被装满,并且可以一直流出。


    漏桶算法的实现步骤是,先声明一个队列用来保存请求,这个队列相当于漏斗,当队列容量满了之后就放弃新来的请求,然后重新声明一个线程定期从任务队列中获取一个或多个任务进行执行,这样就实现了漏桶算法。


    上面我们演示 Nginx 的控制速率其实使用的就是漏桶算法,当然我们也可以借助 Redis 很方便的实现漏桶算法。


    我们可以使用 Redis 4.0 版本中提供的 Redis-Cell 模块,该模块使用的是漏斗算法,并且提供了原子的限流指令,而且依靠 Redis 这个天生的分布式程序就可以实现比较完美的限流了。

    Redis-Cell 实现限流的方法也很简单,只需要使用一条指令 cl.throttle 即可,使用示例如下:

    > cl.throttle mylimit 15 30 60
    1)(integer)0 # 0 表示获取成功,1 表示拒绝
    2)(integer)15 # 漏斗容量
    3)(integer)14 # 漏斗剩余容量
    4)(integer)-1 # 被拒绝之后,多长时间之后再试(单位:秒)-1 表示无需重试
    5)(integer)2 # 多久之后漏斗完全空出来

    其中 15 为漏斗的容量,30 / 60s 为漏斗的速率。

    2.3 令牌算法

    在令牌桶算法中有一个程序以某种恒定的速度生成令牌,并存入令牌桶中,而每个请求需要先获取令牌才能执行,如果没有获取到令牌的请求可以选择等待或者放弃执行,如下图所示:



    我们可以使用 Google 开源的 guava 包,很方便的实现令牌桶算法,首先在 pom.xml 添加 guava 引用,配置如下:

    <!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>28.2-jre</version>
    </dependency>

    具体实现代码如下:

    import com.google.common.util.concurrent.RateLimiter;
    
    import java.time.Instant;
    
    /**
     * Guava 实现限流
     */
    public class RateLimiterExample {
        public static void main(String[] args) {
            // 每秒产生 10 个令牌(每 100 ms 产生一个)
            RateLimiter rt = RateLimiter.create(10);
            for (int i = 0; i < 11; i++) {
                new Thread(() -> {
                    // 获取 1 个令牌
                    rt.acquire();
                    System.out.println("正常执行方法,ts:" + Instant.now());
                }).start();
            }
        }
    }

    以上程序的执行结果为:

    正常执行方法,ts:2023-05-15T14:46:37.175Z
    正常执行方法,ts:2023-05-15T14:46:37.237Z
    正常执行方法,ts:2023-05-15T14:46:37.339Z
    正常执行方法,ts:2023-05-15T14:46:37.442Z
    正常执行方法,ts:2023-05-15T14:46:37.542Z
    正常执行方法,ts:2023-05-15T14:46:37.640Z
    正常执行方法,ts:2023-05-15T14:46:37.741Z
    正常执行方法,ts:2023-05-15T14:46:37.840Z
    正常执行方法,ts:2023-05-15T14:46:37.942Z
    正常执行方法,ts:2023-05-15T14:46:38.042Z
    正常执行方法,ts:2023-05-15T14:46:38.142Z

    从以上结果可以看出令牌确实是每 100ms 产生一个,而 acquire() 方法为阻塞等待获取令牌,它可以传递一个 int 类型的参数,用于指定获取令牌的个数。它的替代方法还有 tryAcquire(),此方法在没有可用令牌时就会返回 false 这样就不会阻塞等待了。当然 tryAcquire() 方法也可以设置超时时间,未超过最大等待时间会阻塞等待获取令牌,如果超过了最大等待时间,还没有可用的令牌就会返回 false。

    注意:使用 guava 实现的令牌算法属于程序级别的单机限流方案,而上面使用 Redis-Cell 的是分布式的限流方案。

    小结


    本文提供了 6 种具体的实现限流的手段,他们分别是:Tomcat 使用 maxThreads 来实现限流;Nginx 提供了两种限流方式,一是通过 limit_req_zone 和 burst 来实现速率限流,二是通过 limit_conn_zone 和 limit_conn 两个指令控制并发连接的总数。最后我们讲了时间窗口算法借助 Redis 的有序集合可以实现,还有漏桶算法可以使用 Redis-Cell 来实现,以及令牌算法可以解决 Google 的 guava 包来实现。


    需要注意的是借助 Redis 实现的限流方案可用于分布式系统,而 guava 实现的限流只能应用于单机环境。如果你嫌弃服务器端限流麻烦,甚至可以在不改代码的情况下直接使用容器限流(Nginx 或 Tomcat),但前提是能满足你的业务需求。


    好了,本节到这里就结束了,下期我们再会~


    参考 & 鸣谢

    https://www.cnblogs.com/biglittleant/p/8979915.html

    本文已收录到我的面试小站 www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。