记录些Spring+题集(42)

1、说说你觉得最有挑战的项目,有哪些难点,怎么解决的

  • 高并发访问难点

  • 高可用性和容错性难题

  • 应用内部的高性能难题

  • 大规模数据的处理和存储难题

1.1、高并发访问难点

项目需要支持大量用户的并发访问,并保证系统的稳定性和性能。为了解决这个问题,我采用了微服务架构,将系统拆分为多个独立的服务,每个服务负责处理特定的功能。这样可以提高系统的可伸缩性和并发处理能力。

同时,我使用Redis作为缓存和消息队列,通过缓存和异步消息处理来减轻数据库的负载和提高系统的响应速度。

为了应对高并发和大量请求,我们需要设计一个可伸缩性高、可扩展性好的系统架构。通过采用分布式架构、负载均衡和容器化技术,我们成功地解决了这个问题。

为了保证系统的稳定运行,我们需要实时监控系统的各项指标。我们采用了 Prometheus + Grafana进行系统监控和运维,及时发现并解决系统问题。我们使用了ELK(Elasticsearch、Logstash、Kibana)堆栈来进行日志收集、分析和可视化。

1.2、高可用性和容错性难题

项目需要具备高可用性和容错性,以应对系统故障和节点失效的情况。

为了解决这个问题,我使用了Redis的主从复制和哨兵机制。通过将Redis配置为主从模式,实现了数据的备份和故障转移。哨兵机制可以监控Redis节点的状态,并在主节点故障时自动进行故障转移,确保系统的可用性和稳定性。

1.3、应用内部的高性能难题

在处理大量请求时,我们需要将一些耗时的任务进行异步处理,以避免阻塞主线程,提升应用的性能。

并且充分应用池化架构,包括线程池、对象池、内存池,实现应用内部的高性能。

为了实现高效的异步处理,我们采用了 Redis 的定时任务功能,结合 Spring 框架的定时任务调度器,实现了任务的精确调度。

1.4、大规模数据的处理和存储难题

项目需要处理大量的数据,并对其进行实时计算和存储。难点在于如何高效地处理和存储这些数据。为了解决这个问题,我采用了flink框架进行数据处理和分析,利用其分布式计算和内存管理功能,实现了高效的数据处理。

同时,我使用Redis作为缓存层,将计算结果缓存起来,减少对后端存储系统的访问压力。

引入了redis后,由于高并发访问,数据一致性和并发控制成为了一个挑战。

为了解决这个问题,我使用了Redis的事务和分布式锁来保证数据的一致性和并发控制。在关键操作中,我使用Redis的事务功能将多个操作打包成一个原子操作,确保它们要么全部执行成功,要么全部回滚。

同时,我使用Redis的分布式锁来实现对共享资源的并发控制,避免了数据竞争和冲突。

2、项目中为什么要使用到ES,ES有什么优势,什么是倒排索引

ES的优势有很多,其中最重要的是它可以快速地对大量数据进行搜索和分析。ES使用倒排索引技术来优化搜索速度,虽然空间消耗比较大,但是搜索性能提高十分显著 。

在项目中,我选择使用Elasticsearch(简称ES)作为搜索引擎,主要原因如下:

  1. 高性能的全文搜索:Elasticsearch是一个高性能的全文搜索引擎,可以快速地处理大量的文本数据。在电商平台这样的高并发场景下,使用Elasticsearch可以显著提高搜索性能。

  2. 数据可视化和分析:Elasticsearch提供了丰富的数据可视化和分析工具,可以方便地进行数据分析和可视化。这对于我们平台这样的数据驱动型业务非常重要。

  3. 支持多种数据格式:Elasticsearch支持多种数据格式,包括文本、JSON、XML等。这使得我们可以将各种数据类型存储在Elasticsearch中,方便进行数据分析和搜索。

  4. 分布式部署:Elasticsearch支持分布式部署,可以将数据和应用分布在多台服务器上,以提高系统的可用性和容错性。这对于我们的平台这样的大规模系统非常重要。

Elasticsearch的优势在于其高性能的全文搜索和数据可视化能力,以及其支持多种数据格式和分布式部署的能力。

这些优势使得Elasticsearch成为项目中一个非常重要的组成部分。

  1. 倒排索引:倒排索引是一种用于搜索引擎的技术,它将所有文档中包含的关键词和它们在文档中出现的位置进行索引。在搜索时,搜索引擎会根据用户输入的关键词查找所有包含这些关键词的文档,并将这些文档返回给用户。倒排索引的优势在于它可以快速地搜索包含关键词的文档,从而提高搜索效率。

  2. 索引:索引是一种用于存储和检索数据的结构。在搜索引擎中,索引用于存储所有文档中包含的关键词和它们在文档中出现的位置。在搜索时,搜索引擎会根据用户输入的关键词查找所有包含这些关键词的文档,并将这些文档返回给用户。索引的优势在于它可以快速地检索包含关键词的文档,从而提高搜索效率。

总之,在项目中,我通过使用Elasticsearch作为搜索引擎,实现了高性能的全文搜索和数据可视化能力,以及其支持多种数据格式和分布式部署的能力。同时,我还使用了倒排索引和索引等技术手段,提高了搜索引擎的效率和性能。

3、Redis集群用的是什么方式,怎么做动态扩容,原理是什么

Redis 集群使用的方式是主从复制,其中一个主节点负责写入数据,从节点负责备份数据。

当需要对 Redis 集群进行扩容时,可以通过添加新的从节点来实现。Redis 集群的动态扩容是指在集群正在运行的过程中,添加或删除从节点,而不需要停机。

Redis Cluster的具体实现细节是采用了Hash槽的概念,集群会预先分配16384个槽,并将这些槽分配给具体的服务节点,通过对Key进行CRC16(key)%16384运算得到对应的槽是哪一个,从而将读写操作转发到该槽所对应的服务节点。

当有新的节点加入或者移除的时候,再来迁移这些槽以及其对应的数据。在这种设计之下,我们就可以很方便的进行动态扩容或缩容。

图片

Redis 集群的扩容原理是通过主节点将数据迁移到新的从节点上。

随着业务发展,数据量的不断上升。

当已有的缓存系统无法满足我们的需求时,需要对已有服务器集群进行扩容。

新增集群的主从节点,主节点7006,从节点7007。

图片

Redis集群的架构图可知,在整个集群新增节点的过程中涉及到一下几个步骤:建立主节点、建立从节点、槽范围移动、槽分区移动。

具体来说,当需要添加一个新的从节点时,先将该节点配置好,然后通过主节点将其数据迁移到该节点上。

迁移过程中,主节点会将部分槽和数据复制到新的从节点上,直到新节点上的数据与主节点上的数据一致。

此时,新节点就可以加入到 Redis 集群中,开始提供读写服务。

Redis 集群的动态扩容过程中,大概需要进行以下步骤:

步骤描述
1. 在已有集群中添加新节点添加新的 Redis 节点到已有的集群中
2. 将新节点加入集群将新的 Redis 节点加入到集群中,并进行握手和插槽分配
3. 迁移槽位自动将一部分槽位从已有的节点迁移到新的节点上, 主要可以通过 redis-cli --cluster reshard 命令完成

Redis 集群的动态扩容原理是通过主节点将数据迁移到新的从节点上,然后将新节点加入到集群中,从而实现集群的扩容。

这种方式可以保证集群在运行过程中不停机,并且能够快速响应节点故障和负载增加的情况。

4、怎么做限流,可以在什么层面上做限流,限流算法有哪些

  • 首先,说说为什么要限流

  • 其次,计数器限流

  • 再次:漏桶限流

  • 再次:令牌桶限流

  • 再次:Guava的 RateLimiter提供了令牌桶限流

  • 再次:Nginx漏桶限流

  • 最后:分布式限流组件

为什么要限流

限流是指在高并发的系统中,为了保护系统资源不被过度消耗,采取一定的措施来限制请求的速率。限流可以在不同的层面上进行,例如网络层、应用层等。

简单来说:

限流在很多场景中用来限制并发和请求量,比如说秒杀抢购,保护自身系统和下游系统不被巨型流量冲垮等。

以微博为例,例如某某明星公布了恋情,访问从平时的50万增加到了500万,系统的规划能力,最多可以支撑200万访问,那么就要执行限流规则,保证是一个可用的状态,不至于服务器崩溃,所有请求不可用。

限流的思想

在保证可用的情况下尽可能多增加进入的人数,其余的人在排队等待,或者返回友好提示,保证里面的进行系统的用户可以正常使用,防止系统雪崩。

日常生活中,有哪些需要限流的地方?

像我旁边有一个国家景区,平时可能根本没什么人前往,但是一到五一或者春节就人满为患,这时候景区管理人员就会实行一系列的政策来限制进入人流量, 为什么要限流呢?

假如景区能容纳一万人,现在进去了三万人,势必摩肩接踵,整不好还会有事故发生,这样的结果就是所有人的体验都不好,如果发生了事故景区可能还要关闭,导致对外不可用,这样的后果就是所有人都觉得体验糟糕透了。

限流的算法

限流算法很多,常见的有三类,分别是计数器算法、漏桶算法、令牌桶算法。

限流的手段通常有计数器、漏桶、令牌桶。注意限流和限速(所有请求都会处理)的差别,视 业务场景而定。

(1)计数器

在一段时间间隔内(时间窗/时间区间),处理请求的最大数量固定,超过部分不做处理。

(2)漏桶

漏桶大小固定,处理速度固定,但请求进入速度不固定(在突发情况请求过多时,会丢弃过多的请求)。

(3)令牌桶

令牌桶的大小固定,令牌的产生速度固定,但是消耗令牌(即请求)速度不固定(可以应对一些某些时间请求过多的情况);每个请求都会从令牌桶中取出令牌,如果没有令牌则丢弃该次请求。

计数器限流

计数器限流定义

在一段时间间隔内(时间窗/时间区间),处理请求的最大数量固定,超过部分不做处理。

比如指定线程池大小,指定数据库连接池大小、nginx连接数等,这都属于计数器算法。

计数器算法是限流算法里最简单也是最容易实现的一种算法。

举个例子,比如我们规定对于A接口,我们1分钟的访问次数不能超过100个。

那么我们可以这么做:

  • 在一开 始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个请求的间隔时间还在1分钟之内,那么说明请求数过多,拒绝访问;

  • 如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter,就是这么简单粗暴。

图片

计数器限流的严重问题

这个算法虽然简单,但是有一个十分致命的问题,那就是临界问题,我们看下图:

图片

从上图中我们可以看到,假设有一个恶意用户,他在0:59时,瞬间发送了100个请求,并且1:00又瞬间发送了100个请求,那么其实这个用户在 1秒里面,瞬间发送了200个请求。

我们刚才规定的是1分钟最多100个请求(规划的吞吐量),也就是每秒钟最多1.7个请求,用户通过在时间窗口的重置节点处突发请求, 可以瞬间超过我们的速率限制。

用户有可能通过算法的这个漏洞,瞬间压垮我们的应用。

漏桶限流

漏桶算法限流的基本原理为:水(对应请求)从进水口进入到漏桶里,漏桶以一定的速度出水(请求放行),当水流入速度过大,桶内的总水量大于桶容量会直接溢出,请求被拒绝,如图所示。大致的漏桶限流规则如下:(1)进水口(对应客户端请求)以任意速率流入进入漏桶。(2)漏桶的容量是固定的,出水(放行)速率也是固定的。(3)漏桶容量是不变的,如果处理速度太慢,桶内水量会超出了桶的容量,则后面流入的水滴会溢出,表示请求拒绝。

漏桶算法原理

漏桶算法思路很简单:

水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会超过桶可接纳的容量时直接溢出。

可以看出漏桶算法能强行限制数据的传输速率。

可以看出漏桶算法能强行限制数据的传输速率,起到了缓冲与削峰的作用,如图所示。

图片

漏桶算法其实很简单,可以粗略的认为就是注水漏水过程,往桶中以任意速率流入水,以一定速率流出水,当水超过桶容量(capacity)则丢弃,因为桶容量是不变的,保证了整体的速率。

以一定速率流出水

图片

削峰:有大量流量进入时, 会发生溢出,  从而限流保护服务可用

缓冲:不至于请求直接打到到后端服务器,  缓冲压力

因为后端计算性能固定,所以放行的速度,或者说消费速度固定

漏桶的问题

漏桶的出水速度固定,也就是请求放行速度是固定的。

网上抄来抄去的说法:

漏桶不能有效应对突发流量,但是能起到平滑突发流量(整流)的作用。

实际上的问题:

漏桶出口的速度固定,不能灵活的应对后端能力提升。比如,通过动态扩容,后端流量从1000QPS提升到1WQPS,漏桶没有办法。

令牌桶限流

令牌桶算法以一个设定的速率产生令牌并放入令牌桶,每次用户请求都得申请令牌,如果令牌不足,则拒绝请求。令牌桶算法中新请求到来时会从桶里拿走一个令牌,如果桶内没有令牌可拿,就拒绝服务。当然,令牌的数量也是有上限的。令牌的数量与时间和发放速率强相关,时间流逝的时间越长,会不断往桶里加入越多的令牌,如果令牌发放的速度比申请速度快,令牌桶会放满令牌,直到令牌占满整个令牌桶,如图所示。

令牌桶限流大致的规则如下:

(1)进水口按照某个速度,向桶中放入令牌。

(2)令牌的容量是固定的,但是放行的速度不是固定的,只要桶中还有剩余令牌,一旦请求过来就能申请成功,然后放行。

(3)如果令牌的发放速度,慢于请求到来速度,桶内就无牌可领,请求就会被拒绝。

总之,令牌的发送速率可以设置,从而可以对突发的出口流量进行有效的应对。

令牌桶算法

令牌桶与漏桶相似,不同的是令牌桶桶中放了一些令牌,服务请求到达后,要获取令牌之后才会得到服务,举个例子,我们平时去食堂吃饭,都是在食堂内窗口前排队的,这就好比是漏桶算法,大量的人员聚集在食堂内窗口外,以一定的速度享受服务,如果涌进来的人太多,食堂装不下了,可能就有一部分人站到食堂外了,这就没有享受到食堂的服务,称之为溢出,溢出可以继续请求,也就是继续排队,那么这样有什么问题呢?

如果这时候有特殊情况,比如有些赶时间的志愿者啦、或者高三要高考啦,这种情况就是突发情况,如果也用漏桶算法那也得慢慢排队,这也就没有解决我们的需求,对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。如图所示,令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

图片

图片

令牌桶限流大致的规则如下:

(1)进水口按照某个速度,向桶中放入令牌。

(2)令牌的容量是固定的,但是放行的速度不是固定的,只要桶中还有剩余令牌,一旦请求过来就能申请成功,然后放行。

(3)如果令牌的发放速度,慢于请求到来速度,桶内就无牌可领,请求就会被拒绝。

总之,令牌的发送速率可以设置,从而可以对突发的出口流量进行有效的应对。

令牌桶与漏桶相似,不同的是令牌桶桶中放了一些令牌,服务请求到达后,要获取令牌之后才会得到服务。令牌使用的灵活性赋予了令牌桶使用场景的灵活性,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。

令牌桶的好处

令牌桶的好处之一就是可以方便地应对  突发出口流量(后端能力的提升)。

比如,可以改变令牌的发放速度,算法能按照新的发送速率调大令牌的发放数量,使得出口突发流量能被处理。

Guava的 RateLimiter提供了令牌桶算法实现

1、平滑突发限流(SmoothBursty) 2、平滑预热限流(SmoothWarmingUp)

public abstract class RateLimiter {
 //平滑突发限流
 static RateLimiter create(double permitsPerSecond, SleepingStopwatch stopwatch) {
    RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */);
    rateLimiter.setRate(permitsPerSecond);
    return rateLimiter;
 }
 //平滑预热限流
 static RateLimiter create(double permitsPerSecond,long warmupPeriod,
                            TimeUnit unit,double coldFactor,SleepingStopwatch stopwatch) {
    RateLimiter rateLimiter = new SmoothWarmingUp(stopwatch, warmupPeriod, unit, coldFactor);
    rateLimiter.setRate(permitsPerSecond);
    return rateLimiter;
 }
}
一、Guava平滑突发限流

1、使用 RateLimiter的静态方法创建一个限流器,设置每秒放置的令牌数为5个。返回的RateLimiter对象可以保证1秒内不会给超过5个令牌,并且以固定速率进行放置,达到平滑输出的效果。

原理:创建的时候,记录当前时间,当需要获取令牌的时候,拿当前请求时间,减去创建时记录的时间,得到时间差除以速率,得到已经产生的令牌数,然后判断如果当前需要使用的令牌数和产生的令牌数对比

1、如果小于当前的令牌,没有问题,直接执行。

2、如果大于当前的令牌数,那就得到需要额外预先支付的令牌数量,通过速率得到超出的令牌所需要的时间,加上记录的时间,得到下次获取令牌的时间

3、当新的获取令牌的请求进来,进行判断,如果记录的时间大于当前请求的时间,那就说明,上一个请求拿了太多的令牌,此时你需要等待。

public void testSmoothBursty() {
 RateLimiter r = RateLimiter.create(5);
 while (true) {
  System.out.println("get 1 tokens: " + r.acquire() + "s");
 }
 /**
  * output: 基本上都是0.2s执行一次,符合一秒发放5个令牌的设定。
  * get 1 tokens: 0.0s 
  * get 1 tokens: 0.182014s
  * get 1 tokens: 0.188464s
  * get 1 tokens: 0.198072s
  * get 1 tokens: 0.196048s
  * get 1 tokens: 0.197538s
  * get 1 tokens: 0.196049s
  */
}

2、RateLimiter使用令牌桶算法,会进行令牌的累积,如果获取令牌的频率比较低,则不会导致等待,直接获取令牌。

public void testSmoothBursty2() {
 RateLimiter r = RateLimiter.create(2);
 while (true)
 {
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  try {
   Thread.sleep(2000);
  } catch (Exception e) {}
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("end");
  /**
   * output:
   * get 1 tokens: 0.0s
   * get 1 tokens: 0.0s
   * get 1 tokens: 0.0s
   * get 1 tokens: 0.0s
   * end
   * get 1 tokens: 0.499796s
   * get 1 tokens: 0.0s
   * get 1 tokens: 0.0s
   * get 1 tokens: 0.0s
   */
 }
}

3、RateLimiter由于会累积令牌,所以可以应对突发流量。在下面代码中,有一个请求会直接请求5个令牌,但是由于此时令牌桶中有累积的令牌,足以快速响应。RateLimiter在没有足够令牌发放时,采用滞后处理的方式,也就是前一个请求获取令牌所需等待的时间由下一次请求来承受,也就是代替前一个请求进行等待。

public void testSmoothBursty3() {
 RateLimiter r = RateLimiter.create(5);
 while (true)
 {
  System.out.println("get 5 tokens: " + r.acquire(5) + "s");
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("end");
  /**
   * output:
   * get 5 tokens: 0.0s
   * get 1 tokens: 0.996766s 滞后效应,需要替前一个请求进行等待
   * get 1 tokens: 0.194007s
   * get 1 tokens: 0.196267s
   * end
   * get 5 tokens: 0.195756s
   * get 1 tokens: 0.995625s 滞后效应,需要替前一个请求进行等待
   * get 1 tokens: 0.194603s
   * get 1 tokens: 0.196866s
   */
 }
}
二、Guava平滑预热限流

RateLimiter的SmoothWarmingUp是带有预热期的平滑限流,它启动后会有一段预热期,逐步将分发频率提升到配置的速率。比如下面代码中的例子,创建一个平均分发令牌速率为2,预热期为3分钟。由于设置了预热时间是3秒,令牌桶一开始并不会0.5秒发一个令牌,而是形成一个平滑线性下降的坡度,频率越来越高,在3秒钟之内达到原本设置的频率,以后就以固定的频率输出。这种功能适合系统刚启动需要一点时间来“热身”的场景。

public void testSmoothwarmingUp() {
 RateLimiter r = RateLimiter.create(2, 3, TimeUnit.SECONDS);
 while (true)
 {
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("get 1 tokens: " + r.acquire(1) + "s");
  System.out.println("end");
  /**
   * output:
   * get 1 tokens: 0.0s
   * get 1 tokens: 1.329289s
   * get 1 tokens: 0.994375s
   * get 1 tokens: 0.662888s  上边三次获取的时间相加正好为3秒
   * end
   * get 1 tokens: 0.49764s  正常速率0.5秒一个令牌
   * get 1 tokens: 0.497828s
   * get 1 tokens: 0.49449s
   * get 1 tokens: 0.497522s
   */
 }
}

Nginx漏桶限流

Nginx限流的简单演示

每六秒才处理一次请求,如下

limit_req_zone  $arg_sku_id  zone=skuzone:10m      rate=6r/m;
limit_req_zone  $http_user_id  zone=userzone:10m      rate=6r/m;
limit_req_zone  $binary_remote_addr  zone=perip:10m      rate=6r/m;
limit_req_zone  $server_name        zone=perserver:1m   rate=6r/m;
这是从请求参数里边,提前参数做限流

这是从请求参数里边,提前参数,进行限流的次数统计key。

在http块里边定义限流的内存区域 zone。

limit_req_zone  $arg_sku_id  zone=skuzone:10m      rate=6r/m;
limit_req_zone  $http_user_id  zone=userzone:10m      rate=6r/m;
limit_req_zone  $binary_remote_addr  zone=perip:10m      rate=6r/m;
limit_req_zone  $server_name        zone=perserver:1m   rate=10r/s;

在location块中使用 限流zone,参考如下:

#  ratelimit by sku id
  location  = /ratelimit/sku {
  limit_req  zone=skuzone;
  echo "正常的响应";
}

测试

[root@cdh1 ~]# /vagrant/LuaDemoProject/sh/linux/openresty-restart.sh
shell dir is: /vagrant/LuaDemoProject/sh/linux
Shutting down openrestry/nginx:  pid is 13479 13485
Shutting down  succeeded!
OPENRESTRY_PATH:/usr/local/openresty
PROJECT_PATH:/vagrant/LuaDemoProject/src
nginx: [alert] lua_code_cache is off; this will hurt performance in /vagrant/LuaDemoProject/src/conf/nginx-seckill.conf:90
openrestry/nginx starting succeeded!
pid is 14197

[root@cdh1 ~]# curl  http://cdh1/ratelimit/sku?sku_id=1
正常的响应
root@cdh1 ~]#  curl  http://cdh1/ratelimit/sku?sku_id=1
正常的响应
[root@cdh1 ~]#  curl  http://cdh1/ratelimit/sku?sku_id=1
限流后的降级内容
[root@cdh1 ~]#  curl  http://cdh1/ratelimit/sku?sku_id=1
限流后的降级内容
[root@cdh1 ~]#  curl  http://cdh1/ratelimit/sku?sku_id=1
限流后的降级内容
[root@cdh1 ~]#  curl  http://cdh1/ratelimit/sku?sku_id=1
限流后的降级内容
[root@cdh1 ~]#  curl  http://cdh1/ratelimit/sku?sku_id=1
限流后的降级内容
[root@cdh1 ~]#  curl  http://cdh1/ratelimit/sku?sku_id=1
正常的响应
从Header头部提前参数

1、nginx是支持读取非nginx标准的用户自定义header的,但是需要在http或者server下开启header的下划线支持:

underscores_in_headers on;

2、比如我们自定义header为X-Real-IP,通过第二个nginx获取该header时需要这样:

$http_x_real_ip; (一律采用小写,而且前面多了个http_)

underscores_in_headers on;

limit_req_zone  $http_user_id  zone=userzone:10m      rate=6r/m;
server {
    listen       80 default;
    server_name  nginx.server *.nginx.server;
    default_type 'text/html';
    charset utf-8;


#  ratelimit by user id
    location  = /ratelimit/demo {
      limit_req  zone=userzone;
      echo "正常的响应";
    }


  
    location = /50x.html{
      echo "限流后的降级内容";
    }

    error_page 502 503 =200 /50x.html;
}

测试

[root@cdh1 ~]# curl -H "USER-ID:1" http://cdh1/ratelimit/demo
正常的响应
[root@cdh1 ~]# curl -H "USER-ID:1" http://cdh1/ratelimit/demo
限流后的降级内容
[root@cdh1 ~]# curl -H "USER-ID:1" http://cdh1/ratelimit/demo
限流后的降级内容
[root@cdh1 ~]# curl -H "USER-ID:1" http://cdh1/ratelimit/demo
限流后的降级内容
[root@cdh1 ~]# curl -H "USER-ID:1" http://cdh1/ratelimit/demo
限流后的降级内容
[root@cdh1 ~]# curl -H "USER-ID:1" http://cdh1/ratelimit/demo
限流后的降级内容
[root@cdh1 ~]# curl -H "USER-ID:1" http://cdh1/ratelimit/demo
限流后的降级内容
[root@cdh1 ~]# curl -H "USER_ID:2" http://cdh1/ratelimit/demo
正常的响应
[root@cdh1 ~]# curl -H "USER_ID:2" http://cdh1/ratelimit/demo
限流后的降级内容
[root@cdh1 ~]#
[root@cdh1 ~]# curl -H "USER_ID:2" http://cdh1/ratelimit/demo
限流后的降级内容
[root@cdh1 ~]# curl -H "USER-ID:3" http://cdh1/ratelimit/demo
正常的响应
[root@cdh1 ~]# curl -H "USER-ID:3" http://cdh1/ratelimit/demo
限流后的降级内容

Nginx漏桶限流的细分类型 ,即burst、nodelay参数详解

每六秒才处理一次请求,如下

limit_req_zone  $arg_user_id  zone=limti_req_zone:10m      rate=10r/m;
不带缓冲队列的漏桶限流

limit_req  zone=limti_req_zone;

  • 严格依照在limti_req_zone中配置的rate来处理请求

  • 超过rate处理能力范围的,直接drop

  • 表现为对收到的请求无延时

假设1秒内提交10个请求,可以看到一共10个请求,9个请求都失败了,直接返回503

接着再查看 /var/log/nginx/access.log,印证了只有一个请求成功了,其它就是都直接返回了503,即服务器拒绝了请求。

图片

带缓冲队列的漏桶限流

limit_req  zone=limti_req_zone burst=5;

  • 依照在limti_req_zone中配置的rate来处理请求

  • 同时设置了一个大小为5的缓冲队列,在缓冲队列中的请求会等待慢慢处理

  • 超过了burst缓冲队列长度和rate处理能力的请求被直接丢弃

  • 表现为对收到的请求有延时

假设1秒内提交10个请求,则可以发现在1s内,在服务器接收到10个并发请求后,先处理1个请求,同时将5个请求放入burst缓冲队列中,等待处理。而超过(burst+1)数量的请求就被直接抛弃了,即直接抛弃了4个请求。burst缓存的5个请求每隔6s处理一次。

接着查看 /var/log/nginx/access.log日志

图片

带瞬时处理能力的漏桶限流

limit_req zone=req_zone burst=5 nodelay;

如果设置nodelay,会在瞬时提供处理(burst + rate)个请求的能力,请求数量超过(burst + rate)的时候就会直接返回503,峰值范围内的请求,不存在请求需要等待的情况

假设1秒内提交10个请求,则可以发现在1s内,服务器端处理了6个请求(峰值速度:burst+10s内一个请求)。对于剩下的4个请求,直接返回503,在下一秒如果继续向服务端发送10个请求,服务端会直接拒绝这10个请求并返回503。

接着查看 /var/log/nginx/access.log日志

图片

可以发现在1s内,服务器端处理了6个请求(峰值速度:burst+原来的处理速度)。对于剩下的4个请求,直接返回503。

但是,总数额度和速度*时间保持一致, 就是额度用完了,需要等到一个有额度的时间段,才开始接收新的请求。如果一次处理了5个请求,相当于占了30s的额度,6*5=30。因为设定了6s处理1个请求,所以直到30s 之后,才可以再处理一个请求,即如果此时向服务端发送10个请求,会返回9个503,一个200

分布式限流组件

但是Nginx的限流指令只能在同一块内存区域有效,而在生产场景中秒杀的外部网关往往是多节点部署,所以这就需要用到分布式限流组件。

高性能的分布式限流组件可以使用Redis+Lua来开发,京东的抢购就是使用Redis+Lua完成的限流。并且无论是Nginx外部网关还是Zuul内部网关,都可以使用Redis+Lua限流组件。

理论上,接入层的限流有多个维度

(1)用户维度限流:在某一时间段内只允许用户提交一次请求,比如可以采取客户端IP或者用户ID作为限流的key。

(2)商品维度的限流:对于同一个抢购商品,在某个时间段内只允许一定数量的请求进入,可以采取秒杀商品ID作为限流的key。

什么时候用nginx限流

用户维度的限流,可以在ngix 上进行,因为使用nginx限流内存来存储用户id,比用redis 的key,来存储用户id,效率高。

什么时候用redis+lua分布式限流

商品维度的限流,可以在redis上进行,不需要大量的计算访问次数的key,另外,可以控制所有的接入层节点的访问秒杀请求的总量。

redis+lua分布式限流组件
--- 此脚本的环境:redis 内部,不是运行在 nginx 内部

---方法:申请令牌
--- -1 failed
--- 1 success
--- @param key key 限流关键字
--- @param apply  申请的令牌数量
local function acquire(key, apply)
    local times = redis.call('TIME');
    -- times[1] 秒数   -- times[2] 微秒数
    local curr_mill_second = times[1] * 1000000 + times[2];
    curr_mill_second = curr_mill_second / 1000;

    local cacheInfo = redis.pcall("HMGET", key, "last_mill_second", "curr_permits", "max_permits", "rate")
    --- 局部变量:上次申请的时间
    local last_mill_second = cacheInfo[1];
    --- 局部变量:之前的令牌数
    local curr_permits = tonumber(cacheInfo[2]);
    --- 局部变量:桶的容量
    local max_permits = tonumber(cacheInfo[3]);
    --- 局部变量:令牌的发放速率
    local rate = cacheInfo[4];
    --- 局部变量:本次的令牌数
    local local_curr_permits = 0;

    if (type(last_mill_second) ~= 'boolean' and last_mill_second ~= nil) then
        -- 计算时间段内的令牌数
        local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate);
        -- 令牌总数
        local expect_curr_permits = reverse_permits + curr_permits;
        -- 可以申请的令牌总数
        local_curr_permits = math.min(expect_curr_permits, max_permits);
    else
        -- 第一次获取令牌
        redis.pcall("HSET", key, "last_mill_second", curr_mill_second)
        local_curr_permits = max_permits;
    end

    local result = -1;
    -- 有足够的令牌可以申请
    if (local_curr_permits - apply >= 0) then
        -- 保存剩余的令牌
        redis.pcall("HSET", key, "curr_permits", local_curr_permits - apply);
        -- 为下次的令牌获取,保存时间
        redis.pcall("HSET", key, "last_mill_second", curr_mill_second)
        -- 返回令牌获取成功
        result = 1;
    else
        -- 返回令牌获取失败
        result = -1;
    end
    return result
end
--eg
-- /usr/local/redis/bin/redis-cli  -a 123456  --eval   /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua key , acquire 1  1

-- 获取 sha编码的命令
-- /usr/local/redis/bin/redis-cli  -a 123456  script load "$(cat  /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua)"
-- /usr/local/redis/bin/redis-cli  -a 123456  script exists  "cf43613f172388c34a1130a760fc699a5ee6f2a9"

-- /usr/local/redis/bin/redis-cli -a 123456  evalsha   "cf43613f172388c34a1130a760fc699a5ee6f2a9" 1 "rate_limiter:seckill:1"  init 1  1
-- /usr/local/redis/bin/redis-cli -a 123456  evalsha   "cf43613f172388c34a1130a760fc699a5ee6f2a9" 1 "rate_limiter:seckill:1"  acquire 1

--local rateLimiterSha = "e4e49e4c7b23f0bf7a2bfee73e8a01629e33324b";

---方法:初始化限流 Key
--- 1 success
--- @param key key
--- @param max_permits  桶的容量
--- @param rate  令牌的发放速率
local function init(key, max_permits, rate)
    local rate_limit_info = redis.pcall("HMGET", key, "last_mill_second", "curr_permits", "max_permits", "rate")
    local org_max_permits = tonumber(rate_limit_info[3])
    local org_rate = rate_limit_info[4]

    if (org_max_permits == nil) or (rate ~= org_rate or max_permits ~= org_max_permits) then
        redis.pcall("HMSET", key, "max_permits", max_permits, "rate", rate, "curr_permits", max_permits)
    end
    return 1;
end
--eg
-- /usr/local/redis/bin/redis-cli -a 123456 --eval   /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua key , init 1  1
-- /usr/local/redis/bin/redis-cli -a 123456 --eval   /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua  "rate_limiter:seckill:1"  , init 1  1


---方法:删除限流 Key
local function delete(key)
    redis.pcall("DEL", key)
    return 1;
end
--eg
-- /usr/local/redis/bin/redis-cli  --eval   /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua key , delete


local key = KEYS[1]
local method = ARGV[1]
if method == 'acquire' then
    return acquire(key, ARGV[2], ARGV[3])
elseif method == 'init' then
    return init(key, ARGV[2], ARGV[3])
elseif method == 'delete' then
    return delete(key)
else
    --ignore
end

在redis中,为了避免重复发送脚本数据浪费网络资源,可以使用script load命令进行脚本数据缓存,并且返回一个哈希码作为脚本的调用句柄,每次调用脚本只需要发送哈希码来调用即可。

分布式令牌限流实战

可以使用redis+lua,实战一票下边的简单案例:

令牌按照1个每秒的速率放入令牌桶,桶中最多存放2个令牌,那系统就只会允许持续的每秒处理2个请求,或者每隔2 秒,等桶中2 个令牌攒满后,一次处理2个请求的突发情况,保证系统稳定性。

商品维度的限流

当秒杀商品维度的限流,当商品的流量,远远大于涉及的流量时,开始随机丢弃请求。

Nginx的令牌桶限流脚本getToken_access_limit.lua执行在请求的access阶段,但是,该脚本并没有实现限流的核心逻辑,仅仅调用缓存在Redis内部的rate_limiter.lua脚本进行限流。

getToken_access_limit.lua脚本和rate_limiter.lua脚本的关系,具体如下图所示。

图片

图 getToken_access_limit.lua脚本和rate_limiter.lua脚本关系

什么时候在Redis中加载rate_limiter.lua脚本呢?

和秒杀脚本一样,该脚本是在Java程序启动商品秒杀时,完成其在Redis的加载和缓存的。

还有一点非常重要,Java程序会将脚本加载完成之后的sha1编码,去通过自定义的key(具体为"lua:sha1:rate_limiter")缓存在Redis中,以方便Nginx的getToken_access_limit.lua脚本去获取,并且在调用evalsha方法时使用。

注意:使用redis集群,因此每个节点都需要各自缓存一份脚本数据

/**
* 由于使用redis集群,因此每个节点都需要各自缓存一份脚本数据
* @param slotKey 用来定位对应的slot的slotKey
*/
public void storeScript(String slotKey){
    if (StringUtils.isEmpty(unlockSha1) || !jedisCluster.scriptExists(unlockSha1, slotKey)){
        //redis支持脚本缓存,返回哈希码,后续可以继续用来调用脚本
        unlockSha1 = jedisCluster.scriptLoad(DISTRIBUTE_LOCK_SCRIPT_UNLOCK_VAL, slotKey);
    }
}

5、说说dubbo的rpc原理

  • 首先,说说Dubbo 的 RPC 的大致功能流程

  • 其次,从dubbo的源码角度,说说dubbo的总体分层架构

  • 再次:源码视角,说说Dubbo总体RPC调用过程图

  • 最后:嘚瑟一下,Dubbo底层netty框架的高性能通信原理

Dubbo 是一个开源的 RPC 框架,它主要用于构建分布式服务治理框架,提供服务注册、调用和负载均衡等功能。

首先,说说Dubbo 的 RPC 的大致功能流程

Dubbo 的 RPC 的大致功能流程:可以概括为以下几个步骤:

  1. 服务提供者注册:服务提供者在启动时,会将自己的地址、端口、接口全限定类名等信息注册到注册中心(通常是 ZooKeeper)。

  2. 服务消费者订阅:服务消费者在启动时,会从注册中心订阅可用的服务提供者信息。

  3. 调用服务:当服务消费者需要调用某个服务时,它会根据订阅到的服务提供者信息,选择一个可用的服务提供者,并通过 RPC 调用该服务的接口。

  4. 负载均衡:Dubbo支持多种负载均衡算法,如随机、轮询、最少活跃调用等。其中,随机算法是最简单的一种负载均衡算法。

  5. 服务提供者处理请求:服务提供者接收到请求后,会根据请求中的参数和方法名称,调用对应的方法实现,并返回结果。

  6. 返回结果:服务提供者将结果返回给服务消费者,服务消费者再将结果返回给客户端。

  7. 服务调用链路追踪:Dubbo支持服务调用链路追踪功能,可以通过日志等方式查看服务调用过程中的详细信息。

Dubbo 的 RPC 原理采用了客户端和服务端双向通信的方式,通过服务提供者注册和消费者订阅的方式实现服务的发现和调用。在使用 Dubbo 时,用户可以通过配置注册中心、服务提供者和服务消费者的地址和端口等信息,实现分布式服务的搭建和调用。

其次,从dubbo的源码角度,说说dubbo的总体分层架构

从API、SPI角度来说,dubbo分为2层

  • Service和Config两层可以认为是API层,主要提供给API使用者,使用者只需要配置和完成业务代码就可以了。

  • 后面所有的层级是SPI层,主要提供给扩展者使用主要是用来做Dubbo的二次开发扩展功能。

从大的范围来说,Dubbo 分为三层:

  • Business 业务逻辑层:由我们自己来提供接口和实现,还有一些配置信息。

  • RPC 层:就是真正的 RPC 调用的核心层,封装整个 RPC 的调用过程、负载均衡、集群容错、代理。

  • Remoting 则是对网络传输协议和数据转换的封装。

从大的范围来说,dubbo分为三层

图片

business业务逻辑层由我们自己来提供,接口和实现还有一些配置信息

RPC层就是真正的RPC调用的核心层,封装整个RPC的调用过程、负载均衡、集群容错、代理

remoting则是对网络传输协议和数据转换的封装。

整个分层依赖由上至下,除 Business业务逻辑之外,其他的几层都是 SPI 机制。

再划分到更细的层面,就是图中的10层模式。

图片

更细的层面,分 10 层:

第一层:service 层,接口层,给服务提供者和消费者来实现的(留给开发人员来实现);

第二层:config 层,配置层,主要是对 Dubbo 进行各种配置的,Dubbo 相关配置;

第三层:proxy 层,服务代理层,透明生成客户端的 stub 和服务单的 skeleton,调用的是接口,实现类没有,所以得生成代理,代理之间再进行网络通讯、负责均衡等;

第四层:registry 层,服务注册层,负责服务的注册与发现;

第五层:cluster 层,集群层,封装多个服务提供者的路由以及负载均衡,将多个实例组合成一个服务;

第六层:monitor 层,监控层,对 rpc 接口的调用次数和调用时间进行监控;第七层:protocol 层,远程调用层,封装 rpc 调用;

第八层:exchange 层,信息交换层,封装请求响应模式,同步转异步;

第九层:transport 层,网络传输层,抽象 mina 和 netty 为统一接口;

第十层:serialize 层,数据序列化层

再次:源码视角,说说Dubbo总体RPC调用过程图:

图片

  1. Proxy持有一个Invoker对象,使用Invoker调用

  2. 之后通过Cluster进行负载容错,失败重试

  3. 调用Directory获取远程服务的Invoker列表

  4. 负载均衡用户配置了路由规则,则根据路由规则过滤获取到的Invoker列表用户没有配置路由规则或配置路由后还有很多节点,则使用LoadBalance方法做负载均衡,选用一个可以调用的Invoker

  5. 经过一个一个过滤器链,通常是处理上下文、限流、计数等。

  6. 使用Client做数据传输

  7. 私有化协议的构造(Codec)

  8. 进行序列化

  9. 服务端收到这个Request请求,将其分配到ThreadPool中进行处理

  10. Server来处理这些Request

  11. 根据请求查找对应的Exporter

  12. 之后经过一个服务提供者端的过滤器链

  13. 然后找到接口实现并真正的调用,将请求结果返回

最后、底层netty框架的高性能通信原理

Netty是一个异步事件驱动的网络应用程序框架, 用于快速开发可维护的高性能协议服务器和客户端。它极大地简化并简化了TCP和UDP套接字服务器等网络编程。

传统的通讯,使用BIO:(Blocking IO)

图片

而Netty是NIO (Non-Blocking IO)

图片

虽然最新的tomcat也是nio,但是Netty棋高一着,是reactor反应器模式

图片

参考文献

https://www.processon.com/view/link/60fb9421637689719d246739

https://www.processon.com/view/link/61148c2b1e08536191d8f92f

假设每天有几千万请求,该如何部署?

一、什么是QPS?

QPS Queries Per Second 是每秒查询率 ,是一台服务器每秒能够相应的查询次数,是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准, 即每秒的响应请求数,也即是最大吞吐能力。

二、什么是TPS?

TPS Transactions Per Second 也就是事务数/秒。

什么是事务?

一个事务是指一个客户机向服务器发送一起完整的 开始 start 请求,内部各种ACID  事务属性的 并发数据操作, 最后 提交一个commit操作结束整个Transaction的过程。

所以从上面可以看出来,一个事务包含明确的三阶段:开始,处理,commit/rollback。

一个事务的中间环节,会包含多个并行的sql的操作。

图片

本质上事务是对多个并发操作进行数据一致性的管理,事务的ACID规则如下:

图片

spring框架有本身自带的事务传播性,数据库也有事务,数据库事务包含事务的start,数据操作,事务commit等非常清晰的阶段。当数据库开启事务后,当前线程改变数据库数据,并未提交当前事务,那其他线程读数据库的时候会出现脏读,幻读。

在web服务器领域来说,事务可以指用户的一次完整的交互处理,这次交互处理里边, 包含了多次的服务端api调用。一个web服务器包含包含多次api请求,多次api响应。

比如,用户执行一次搜索操作,浏览器 client通过vue框架要调用后端的上10个api接口,类似如下:

图片

三、QPS和TPS的关系

1、Tps 即每秒处理事务数,从web应用的角度来说,包括了

1)用户通过client工具,开始发起请求

2)client工具执行N个服务端的API调用

3)client进行API结果的聚合再渲染,最后呈现给用户

这三个过程组成一个事务,每秒能够完成N事务,Tps也就是N;

2、Qps Queries Per Second 是每秒查询率,从web应用的角度来说,就是单次api的调用

Qps 基本类似于 Tps,但是不同的是:

  • 用户通过client工具完成一个页面的一次访问,形成一个Tps;

  • 如果一次页面请求,产生多次对服务器的api请求,这个Tps 包含多个 qps。

  • 如果一次页面请求,产生1次对服务器的api请求,这个Tps 包含1个 qps。也就是 tps=qps

四、什么是RT,响应时间

响应时间:执行一个请求从开始到最后收到响应数据所花费的总体时间。

RT 即从客户端发起请求到收到服务器响应结果的时间。

响应时间RT(Response-time),是一个系统最重要的指标之一,它的数值大小直接反应了系统的快慢。

单节点QPS公式:QPS=1000ms/RT

假设一个节点RT是10ms,则可以很容易的计算出QPS,QPS = 1000/10 = 100

对同一个分布式系统而言,支持的节点数越多,QPS越高。

多节点场景,如果把节点提升到2,那么整个系统的QPS则为 2*(1000/10) = 200,可见QPS随着节点横向扩展、节点的增加而线性增长,当然,那QPS上不去就加节点,听起来很有道理,但是往往现实并非如此

为啥:一个请求的处理链路上受影响的环节很多, 不能只解决某一层的吞吐量,而是需要所有的层都要同步提升。

五、什么是并发数?

并发数(并发度):指系统同时能处理的请求数量,同样反应了系统的负载能力。

并发数:系统同时处理的request/事务数

并发数 = QPS*平均响应时间

这个是一个理论的并发数。

注意,这个并发数,和jemeter的并发数不一样。jmeter中的并发数,就是同时启动的线程数

线程组设置为100个线程,运行过程中未出现任何异常,满足100个线程并发操作需求,那么并发数就是100

六、吐吞量

系统的吞吐量(承压能力)与request对CPU的消耗、外部接口、IO等等紧密关联。

单个request 对CPU消耗越高,IO速度越慢,那么,系统吞吐能力越低,反之越高。

系统吞吐量几个重要参数:QPS(TPS)、并发数、响应时间。

  1. QPS(TPS):(Query Per Second)每秒钟request/事务 数量

  2. 并发数:系统同时处理的request/事务数

  3. 响应时间:一般取平均响应时间

理解了上面三个要素的意义之后,就能推算出它们之间的关系:

QPS(TPS)= 并发数 / 平均响应时间

并发数 = QPS * 平均响应时间

七、什么是PV、UV、DAU、MAU

PV

PV(Page View):页面访问量,即页面浏览量或点击量,用户每次刷新即被计算一次。

可以统计服务一天的访问日志得到。

UV

UV(Unique Visitor):独立访客,统计1天内访问某站点的用户数。

可以统计服务一天的访问日志并根据用户的唯一标识去重得到。

响应时间(RT):响应时间是指系统对请求作出响应的时间,一般取平均响应时间。

可以通过Nginx、Apache之类的Web Server得到。

DAU

DAU(Daily Active User):日活跃用户数量。

常用于反映网站、互联网应用或网络游戏的运营情况。

DAU通常统计一日(统计日)之内,登录或使用了某个产品的用户数(去除重复登录的用户),与UV概念相似

MAU

MAU(Month Active User):月活跃用户数量,指网站、app等去重后的月活跃用户数量

八、最佳线程数量

刚好消耗完服务器的瓶颈资源的临界线程数,公式如下

最佳线程数量=((线程等待时间+线程cpu时间)/线程cpu时间)* cpu数量

特性:

  • 在达到最佳线程数的时候,线程数量继续递增,则QPS不变,而响应时间变长,持续递增线程数量,则QPS开始下降。

  • 每个系统都有其最佳线程数量,但是不同状态下,最佳线程数量是会变化的。

  • 瓶颈资源可以是CPU,可以是内存,可以是锁资源,IO资源:超过最佳线程数-导致资源的竞争,超过最佳线程数-响应时间递增。

九、吞吐量评估

假设你的项目的用户量有百万级,然后每天有几千万请求,高峰期每秒有好几千请求。

每个服务会有多高的QPS?

那么这个时候,你的服务会有多高的QPS?

按二八定律来看,如果每天 80% 的访问集中在 20% 的时间里,这 20% 时间就叫做峰值时间。

  • 公式:( 总PV数 * 80% ) / ( 每天秒数 * 20% ) = 峰值时间每秒请求数(QPS)

  • 机器:峰值时间每秒QPS / 单台机器的QPS = 需要的机器

1、每天300w PV 的在单台机器上,这台机器需要多少QPS?

( 3000000 * 0.8 ) / (86400 * 0.2 ) = 139 (QPS)

2、如果一台机器的QPS是58,需要几台机器来支持?139 / 58 = 3

  • 20
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值