[面面面]搞定计算机面试常见知识点——工具篇

之前的一篇总结已经写到了十万字,阅读起来太不方便了,所以按照类别拆分成多个短篇分享给大家。

文章目录

5. 工具类

5.1. Redis

5.1.1. Redis为什么要作为缓存?

使用内存数据库做缓存提高系统性能和并发能力。

5.1.2. redis和memcached的区别

  1. Redis和Memcache都是将数据存放在内存中,都是内存数据库。不过memcache还可用于缓存其他东西,例如图片、视频等等;
  2. Redis不仅仅支持简单的k/v类型的数据,同时还提供list,set,hash等数据结构的存储;
  3. 虚拟内存–Redis当物理内存用完时,可以将一些很久没用到的value 交换到磁盘;
  4. 过期策略–memcache在set时就指定,例如set key1 0 0 8,即永不过期。Redis可以通过例如expire 设定,例如expire name 10;
  5. 分布式–设定memcache集群,利用magent做一主多从;redis可以做一主多从。都可以一主一从;
  6. 存储数据安全–memcache挂掉后,数据没了;redis可以定期保存到磁盘(持久化);
  7. 灾难恢复–memcache挂掉后,数据不可恢复; redis数据丢失后可以通过aof恢复;
  8. Redis支持数据的备份,即master-slave模式的数据备份;
  9. 应用场景不一样:Redis出来作为NoSQL数据库使用外,还能用做消息队列、数据堆栈和数据缓存等;Memcached适合于缓存SQL语句、数据集、用户临时性数据、延迟查询数据和session等。

5.1.3. Redis常用数据结构以及数据结构底层

  1. 简单动态字符串
    Redis使用简单字符串SDS作为字符串的表示,相对于C语言字符串,SDS具有常数复杂度获取字符串长度,杜绝了缓存区的溢出,减少了修改字符串长度时所需的内存重分配次数,以及二进制安全能存储各种类型的文件,并且还兼容部分C函数。

SDS 定义:

struct sdshdr{
     //记录buf数组中已使用字节的数量
     //等于 SDS 保存字符串的长度
     int len;
     //记录 buf 数组中未使用字节的数量
     int free;
     //字节数组,用于保存字符串
     char buf[];
}
  1. 链表
    通过为链表设置不同类型的特定函数,Redis链表可以保存各种不同类型的值,除了用作列表键,还在发布与订阅、慢查询、监视器等方面发挥作用

  2. 字典
    Redis的字典底层使用哈希表实现,每个字典通常有两个哈希表,一个平时使用,另一个用于rehash时使用,使用链地址法解决哈希冲突。

  3. 跳跃表
    跳跃表通常是有序集合的底层实现之一,表中的节点按照分值大小进行排序。可参考:https://www.cnblogs.com/hunternet/p/11248192.html

  4. 整数集合(intset)
    整数集合是集合键的底层实现之一,底层由数组构成,升级特性能尽可能的节省内存。

  5. 压缩列表
    压缩列表是Redis为节省内存而开发的顺序型数据结构,通常作为列表键和哈希键的底层实现之一。

5.1.4. 定期删除、惰性删除

  1. 定时删除 节约内存,无占用 不分时段占用CPU资源,频度高 拿时间换空间
  2. 惰性删除 内存占用严重 延时执行,CPU利用率高 拿空间换时间
  3. 定期删除 内存定期随机删除 每秒花费固定的CPU资源维护内存 随机抽查,重点抽查

5.1.5. 内存淘汰机制

不管是定期采样删除还是惰性删除都不是一种完全精准的删除,就还是会存在key没有被删除掉的场景,所以就需要内存淘汰策略进行补充。

noeviction:当内存不足以容纳新写入数据时,新写入操作会报错,(一般没人用)

allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(最常用)

allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key,一般没人用

volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key(一般不太合适)

volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key

volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除

5.1.6. 持久化机制

参考:https://www.cnblogs.com/justinli/p/6809791.html

redis是基于内存的数据库,提供了内存数据持久化到文件的两种方式,一种是写RDB文件方式,另一种是写AOF文件,默认执行的是RDB文件持久化方式。

redis如何同步RDB文件 ,通常情况下,redis通过读取配置文件定期保存数据库的状态到RDB文件

Redis除了提供RDB文件的持久化方式外,还提供了AOF持久化机制,与RDB保存数据库的键值对的方式不同的是,AOF提供的持久化机制保存的是redis执行的命令

5.1.7. 缓存穿透,缓存击穿,缓存雪崩

参考:https://blog.csdn.net/zeb_perfect/article/details/54135506

缓存穿透
缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。

解决方案
有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法(我们采用的就是这种),如果一个查询返回的数据为空(不管是数 据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

缓存雪崩
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

解决方案
缓存失效时的雪崩效应对底层系统的冲击非常可怕。大多数系统设计者考虑用加锁或者队列的方式保证缓存的单线 程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上。这里分享一个简单方案就时讲缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

缓存击穿
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。

缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

解决方案

  1. 使用互斥锁(mutex key)
    业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。

  2. "提前"使用互斥锁(mutex key):
    在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。‘

  3. “永远不过期”
    把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期

5.1.8. 并发竞争key问题

分布式锁(zookeeper 和 redis 都可以实现分布式锁)。(如果不存在 Redis 的并发竞争 Key 问题,不要使用分布式锁,这样会影响性能)基于zookeeper临时有序节点可以实现的分布式锁。

5.1.9. 如何保证缓存与数据库的双写一致性

参考:https://gitee.com/shishan100/Java-Interview-Advanced/blob/master/docs/high-concurrency/redis-consistence.md

深入理解缓存之缓存和数据库的一致性

Cache Aside Pattern最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
更新的时候,先更新数据库,然后再删除缓存。

上述方法可以解决初级的问题,但显然不够严谨。更严谨的方案是使用队列等待缓存更新:

更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个 jvm 内部队列中。

一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。这样的话,一个数据变更的操作,先删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,没有读到缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。

这里有一个优化点,一个队列中,其实多个更新缓存请求串在一起是没意义的,因此可以做过滤

5.1.10. Redis为什么是单线程还这么快?

  1. redis是基于内存的,内存的读写速度非常快;

  2. redis是单线程的,省去了很多上下文切换线程的时间;

  3. redis使用多路复用技术,可以处理并发的连接。非阻塞IO 内部实现采用epoll,采用了epoll+自己实现的简单的事件框架。epoll中的读、写、关闭、连接都转化成了事件,然后利用epoll的多路复用特性,绝不在io上浪费一点时间。

5.1.11. select、poll、epoll区别

参考:https://blog.csdn.net/wteruiycbqqvwt/article/details/90299610

(1)select==>时间复杂度O(n)

它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

(2)poll==>时间复杂度O(n)

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.

(3)epoll==>时间复杂度O(1)

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

5.1.12. 五种IO模型

参考:https://www.jianshu.com/p/486b0965c296

同步阻塞 IO(blocking IO)、 同步非阻塞 IO(nonblocking IO)、IO 多路复用( IO multiplexing)、信号驱动式IO(signal-driven IO)、异步非阻塞 IO(asynchronous IO)

5.1.13. 哨兵机制

在一个典型的一主多从的系统中,slave在整个体系中起到了数据冗余备份和读写分离的作用。当master遇到异常终端后,需要从slave中选举一个新的master继续对外提供服务,这种机制在前面提到过N次,比如在zk中通过leader选举、kafka中可以基于zk的节点实现master选举。所以在redis中也需要一种机制去实现master的决策,redis并没有提供自动master选举功能,而是需要借助一个哨兵来进行监控。

5.1.14. 分布式锁

Redis提供一些命令SETNX,GETSET,可以方便实现分布式锁机制。

SETNX命令(SET if Not eXists)
语法:

SETNX key value

功能:
当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。

GETSET命令
语法:

GETSET key value

功能:
将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。

GET命令
语法:

GET key

功能:
返回 key 所关联的字符串值,如果 key 不存在那么返回特殊值 nil 。

DEL命令
语法:

DEL key [KEY …]

功能:
删除给定的一个或多个 key ,不存在的 key 会被忽略。

实现
SETNX 可以直接加锁操作,比如说对某个关键词foo加锁,客户端可以尝试

SETNX foo.lock <current unix time>

如果返回1,表示客户端已经获取锁,可以往下操作,操作完成后,通过

DEL foo.lock

命令来释放锁。
如果返回0,说明foo已经被其他客户端上锁,如果锁是非堵塞的,可以选择返回调用。如果是堵塞调用,就需要进入以下个重试循环,直至成功获得锁或者重试超时。

处理死锁
在上面的处理方式中,如果获取锁的客户端端执行时间过长,进程被kill掉,或者因为其他异常崩溃,导致无法释放锁,就会造成死锁。所以,需要对加锁要做时效性检测。因此,我们在加锁时,把当前时间戳作为value存入此锁中,通过当前时间戳和Redis中的时间戳进行对比,如果超过一定差值,认为锁已经时效,防止锁无限期的锁下去,但是,在大并发情况,如果同时检测锁失效,并简单粗暴的删除死锁,再通过SETNX上锁,可能会导致竞争条件的产生,即多个客户端同时获取锁。

C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,获得foo.lock的时间戳,通过比对时间戳,发现锁超时。
C2 向foo.lock发送DEL命令。
C2 向foo.lock发送SETNX获取锁。
C3 向foo.lock发送DEL命令,此时C3发送DEL时,其实DEL掉的是C2的锁。
C3 向foo.lock发送SETNX获取锁。

此时C2和C3都获取了锁,产生竞争条件,如果在更高并发的情况,可能会有更多客户端获取锁。所以,DEL锁的操作,不能直接使用在锁超时的情况下,幸好我们有GETSET方法,假设我们现在有另外一个客户端C4,看看如何使用GETSET方式,避免这种情况产生。

C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,调用GET命令获得foo.lock的时间戳T1,通过比对时间戳,发现锁超时。
C4 向foo.lock发送GESET命令,

GETSET foo.lock <current unix time>

并得到foo.lock中老的时间戳T2

如果T1=T2,说明C4获得时间戳。
如果T1!=T2,说明C4之前有另外一个客户端C5通过调用GETSET方式获取了时间戳,C4未获得锁。只能sleep下,进入下次循环中。

5.1.15. 消息队列

  1. 使用list
    先介绍要使用的几个命令:
命令描述
LPUSH从左边插入数据
RPUSH从右边插入数据
LPOP从左边取出一个数据
RPOP从右边取出一个数据
BLPOP取数据时如果没有数据会等待指定时间
BRPOP取数据时如果没有数据会等待指定时间
  1. 发布/订阅模式
    Redis除了对消息队列提供支持外,还提供了一组命令用于支持发布/订阅模式。
    参考: https://blog.csdn.net/qq_34212276/article/details/78455004
命令描述
PUBLISH指令可用于发布一条消息,格式 PUBLISH channel message
SUBSCRIBE指令用于接收一条消息,格式 SUBSCRIBE channel
SUBSCRIBE可以使用指令UNSUBSCRIBE退订,如果不加参数,则会退订所有由SUBSCRIBE指令订阅的频道。
PSUBSCRIBERedis还支持基于通配符的消息订阅,使用指令PSUBSCRIBE (pattern subscribe)
  1. Sortes Set(有序列表)
    可以自定义消息ID,在消息ID有意义时,比较重要。

  2. stream
    Redis5.0带来了Stream类型。从字面上看是流类型,但其实从功能上看,应该是Redis对消息队列(MQ,Message Queue)的完善实现。

命令描述
XADD命令用于在某个stream(流数据)中追加消息
XREAD从Stream中读取消息
XGROUP用于管理消费者组,提供创建组,销毁组,更新组起始消息ID等操作
XREADGROUP分组消费消息操作

Stream 是基于 RadixTree 数据结构实现的。

5.2. Spring全家桶

5.2.1. Spring的启动流程

spring的启动其实就是IOC容器的启动

  1. 首先,对于一个web应用,其部署在web容器中,web容器提供其一个全局的上下文环境,这个上下文就是ServletContext,其为后面的spring IoC容器提供宿主环境;

  2. 其 次,在web.xml中会提供有contextLoaderListener。在web容器启动时,会触发容器初始化事件,此时 contextLoaderListener会监听到这个事件,其contextInitialized方法会被调用,在这个方法中,spring会初始 化一个启动上下文,这个上下文被称为根上下文,即WebApplicationContext,这是一个接口类,确切的说,其实际的实现类是 XmlWebApplicationContext。这个就是spring的IoC容器,其对应的Bean定义的配置由web.xml中的 context-param标签指定。在这个IoC容器初始化完毕后,spring以WebApplicationContext.ROOTWEBAPPLICATIONCONTEXTATTRIBUTE为属性Key,将其存储到ServletContext中,便于获取;

  3. 再次,contextLoaderListener监听器初始化完毕后,开始初始化web.xml中配置的Servlet,这里是DispatcherServlet,这个servlet实际上是一个标准的前端控制器,用以转发、匹配、处理每个servlet请 求。DispatcherServlet上下文在初始化的时候会建立自己的IoC上下文,用以持有spring mvc相关的bean。在建立DispatcherServlet自己的IoC上下文时,会利用WebApplicationContext.ROOTWEBAPPLICATIONCONTEXTATTRIBUTE先从ServletContext中获取之前的根上下文(即WebApplicationContext)作为自己上下文的parent上下文。有了这个 parent上下文之后,再初始化自己持有的上下文。这个DispatcherServlet初始化自己上下文的工作在其initStrategies方 法中可以看到,大概的工作就是初始化处理器映射、视图解析等。这个servlet自己持有的上下文默认实现类也是 XmlWebApplicationContext。初始化完毕后,spring以与servlet的名字相关(此处不是简单的以servlet名为 Key,而是通过一些转换,具体可自行查看源码)的属性为属性

5.2.2. Spring如何解决循环依赖问题

Spring是通过递归的方式获取目标bean及其所依赖的bean的;Spring实例化一个bean的时候,是分两步进行的,首先实例化目标bean,然后为其注入属性

结合这两点,也就是说,Spring在实例化一个bean的时候,是首先递归的实例化其所依赖的所有bean,直到某个bean没有依赖其他bean,此时就会将该实例返回,然后反递归的将获取到的bean设置为各个上层bean的属性的。

5.2.3. Spring事务传播行为

事务传播行为用来描述由某一个事务传播行为修饰的方法被嵌套进另一个方法的时事务如何传播。

5.2.4. Spring自动配置

参考:https://blog.csdn.net/u014745069/article/details/83820511

Spring Boot启动的时候会通过@EnableAutoConfiguration注解找到META-INF/spring.factories配置文件中的所有自动配置类,并对其进行加载,而这些自动配置类都是以AutoConfiguration结尾来命名的,它实际上就是一个JavaConfig形式的Spring容器配置类,它能通过以Properties结尾命名的类中取得在全局配置文件中配置的属性如:server.port,而XxxxProperties类是通过@ConfigurationProperties注解与全局配置文件中对应的属性进行绑定的。

5.2.4.1. 流程分析

@SpringBootApplication是一个复合注解或派生注解,在@SpringBootApplication中有一个注解@EnableAutoConfiguration,翻译成人话就是开启自动配置,其定义如下:

@Import(AutoConfigurationImportSelector.class)

而这个注解也是一个派生注解,其中的关键功能由@Import提供,其导入的AutoConfigurationImportSelector的selectImports()方法通过SpringFactoriesLoader.loadFactoryNames()扫描所有具有META-INF/spring.factories的jar包。spring-boot-autoconfigure-x.x.x.x.jar里就有一个这样的spring.factories文件。

这个spring.factories文件也是一组一组的key=value的形式,其中一个key是EnableAutoConfiguration类的全类名,而它的value是一个xxxxAutoConfiguration的类名的列表,这些类名以逗号分隔。

这个@EnableAutoConfiguration注解通过@SpringBootApplication被间接的标记在了Spring Boot的启动类上。在SpringApplication.run(…)的内部就会执行selectImports()方法,找到所有JavaConfig自动配置类的全限定名对应的class,然后将所有自动配置类加载到Spring容器中。

5.2.4.2. 自动配置生效

每一个XxxxAutoConfiguration自动配置类都是在某些条件之下才会生效的,这些条件的限制在Spring Boot中以注解的形式体现,常见的条件注解有如下几项:

@ConditionalOnBean:当容器里有指定的bean的条件下。
@ConditionalOnMissingBean:当容器里不存在指定bean的条件下。
@ConditionalOnClass:当类路径下有指定类的条件下。
@ConditionalOnMissingClass:当类路径下不存在指定类的条件下。
@ConditionalOnProperty:指定的属性是否有指定的值,比如@ConditionalOnProperties(prefix=”xxx.xxx”, value=”enable”, matchIfMissing=true),代表当xxx.xxx为enable时条件的布尔值为true,如果没有设置的情况下也为true。

5.2.4.3. 配置文件如何生效?

例如在ServletWebServerFactoryAutoConfiguration类上,有一个@EnableConfigurationProperties注解:开启配置属性,而它后面的参数是一个ServerProperties类,这就是习惯优于配置的最终落地点。

在这个类上,我们看到了一个非常熟悉的注解:@ConfigurationProperties,它的作用就是从配置文件中绑定属性到对应的bean上,而@EnableConfigurationProperties负责导入这个已经绑定了属性的bean到spring容器中(见上面截图)。那么所有其他的和这个类相关的属性都可以在全局配置文件中定义,也就是说,真正“限制”我们可以在全局配置文件中配置哪些属性的类就是这些XxxxProperties类,它与配置文件中定义的prefix关键字开头的一组属性是唯一对应的。

至此,我们大致可以了解。在全局配置的属性如:server.port等,通过@ConfigurationProperties注解,绑定到对应的XxxxProperties配置实体类上封装为一个bean,然后再通过@EnableConfigurationProperties注解导入到Spring容器中。

5.2.5. Spring cloud 基本使用

5.2.5.1. 服务的注册与发现(Eureka )

创建一个服务提供者 (eureka client),当client向server注册时,它会提供一些元数据,例如主机和端口,URL,主页等。Eureka server 从每个client实例接收心跳消息。 如果心跳超时,则通常将该实例从注册server中删除。

5.2.5.2. 负载均衡 (Ribbon)

Spring Cloud Ribbon是一个基于HTTP和TCP的客户端负载均衡工具,它基于Netflix Ribbon实现。通过Spring Cloud的封装,可以让我们轻松地将面向服务的REST模版请求自动转换成客户端负载均衡的服务调用。

5.2.5.3. 服务消费者(Feign)

Feign是一个声明式的伪Http客户端,它使得写Http客户端变得更简单。使用Feign,只需要创建一个接口并注解。Feign默认集成了Ribbon,并和Eureka结合,默认实现了负载均衡的效果。

定义一个feign接口

@FeignClient(value = "service-hi")
public interface SchedualServiceHi {
    @RequestMapping(value = "/hi", method = RequestMethod.GET)
    String sayHiFromClientOne(@RequestParam(value = "name") String name);
}

当有hi请求的时候,通过调用sayHiFromClientOne自动将请求分配到其他注册了service-hi的主机。

@RestController
public class HiController {
    @Autowired
    SchedualServiceHi schedualServiceHi;
    @RequestMapping(value = "/hi",method = RequestMethod.GET)
    public String sayHi(@RequestParam String name){
        return schedualServiceHi.sayHiFromClientOne(name);
    }
}
5.2.5.4. Hystrix断路器

在Spring Cloud可以用RestTemplate+Ribbon和Feign来调用。为了保证其高可用,单个服务通常会集群部署。由于网络原因或者自身的原因,服务并不能保证100%可用,如果单个服务出现问题,调用这个服务就会出现线程阻塞,此时若有大量的请求涌入,Servlet容器的线程资源会被消耗完毕,导致服务瘫痪。服务与服务之间的依赖性,故障会传播,会对整个微服务系统造成灾难性的严重后果,这就是服务故障的“雪崩”效应。

5.3. mybatis

5.3.1. mybatis工作流程

  1. 创建SqlSessionFactoryBuilder对象,调用build(inputstream)方法读取并解析配置文件,返回SqlSessionFactory对象
  2. 由SqlSessionFactory创建SqlSession 对象,没有手动设置的话事务默认开启
  3. 调用SqlSession中的api,传入Statement Id和参数,内部进行复杂的处理,最后调用jdbc执行SQL语句,封装结果返回。其中MappedStatement是通过动态代理从mapper中生成的。

5.3.2. mybatis常用注解

  1. @TableName:映射数据库表名
  2. @TableFieId:映射非主键字段
  3. @TableId:表主键标识
  4. @Version:更新时,实体对象的version属性必须有值,才会生成SQL
    @Version
    @TableField(value="version", fill = FieldFill.INSERT, update="%s+1")
    protected Long version;

5.3.3. 常用标签

Mapper中一个提供了9个顶层标签,除了1个已经过期的我们不需要去了解,另外8个都是必须要掌握的,只要熟练掌握了标签的使用,使用MyBatis才能如鱼得水。

5.3.3.1. select

select用来映射查询语句,比如下面就是一个简单的select标签的使用:

<select id="listUserByUserName" parameterType="String" resultType="lwUser">
        select user_id,user_name from lw_user where user_name=#{userName}
</select>
5.3.3.2. insert
<insert
id="insertLwUser"
parameterType="lwUser"
parameterMap="deprecated"
flushCache="true"
statementType="PREPARED"
keyProperty=""
keyColumn=""
useGeneratedKeys=""
timeout="20"
databaseId="mysql"
lang="">
5.3.3.3. update

update用来映射更新语句。

5.3.3.4. delete

delete用来映射删除语句。

5.3.3.5. sql

这个元素可以被用来定义可重用的 SQL 代码段,可以包含在其他语句中。
如下是一个最简单的例子:

    <select id="selectUserAddress" resultType="com.lonelyWolf.mybatis.model.UserAddress">
        select <include refid="myCloumn"></include> from lw_user_address
    </select>

    <sql id="myCloumn" >
        id,address
    </sql>
5.3.3.6. cache

MyBatis 包含一个非常强大的查询缓存特性,它可以非常方便地配置和定制。但是默认情况下只开启了一级缓存,即局部的session缓存,如果想要开启二级缓存。那么就需要使用到cache标签

<cache
type="com.lonelyWolf.xxx"
eviction="FIFO"
flushInterval="60000"
readOnly="true"
size="512"/>

5.4. 大数据

5.4.1. Kafka

5.4.1.1. 为什么快?
  1. 顺序读写
  2. Page Cache
    Kafka利用了操作系统本身的Page Cache,就是利用操作系统自身的内存而不是JVM空间内存。这样做的好处有:
    1、避免Object消耗:如果是使用 Java 堆,Java对象的内存消耗比较大,通常是所存储数据的两倍甚至更多。
    2、避免GC问题:随着JVM中数据不断增多,垃圾回收将会变得复杂与缓慢,使用系统缓存就不会存在GC问题
  3. 零拷贝
    linux操作系统 “零拷贝” 机制使用了sendfile方法, 允许操作系统将数据从Page Cache 直接发送到网络,只需要最后一步的copy操作将数据复制到 NIC 缓冲区, 这样避免重新复制数据 。
  4. 分区分段+索引
    Kafka的message是按topic分类存储的,topic中的数据又是按照一个一个的partition即分区存储到不同broker节点。每个partition对应了操作系统上的一个文件夹,partition实际上又是按照segment分段存储的。这也非常符合分布式系统分区分桶的设计思想。
  5. 批量读写
    Kafka数据读写也是批量的而不是单条的。
    除了利用底层的技术外,Kafka还在应用程序层面提供了一些手段来提升性能。最明显的就是使用批次。在向Kafka写入数据时,可以启用批次写入,这样可以避免在网络上频繁传输单个消息带来的延迟和带宽开销。假设网络带宽为10MB/S,一次性传输10MB的消息比传输1KB的消息10000万次显然要快得多。
  6. 批量压缩
5.4.1.2. Topic 和 Partition

在 Kafka 中,Topic 是一个存储消息的逻辑概念,可以认为是一个消息集合。每条消息发送到 Kafka 集群的消息都有一个类别。物理上来说,不同的 Topic 的消息是分开存储的,每个 Topic 可以有多个生产者向它发送消息,也可以有多个消费者去消费其中的消息。

每个 Topic 可以划分多个分区(每个 Topic 至少有一个分区),同一 Topic 下的不同分区包含的消息是不同的。每个消息在被添加到分区时,都会被分配一个 offset,它是消息在此分区中的唯一编号,Kafka 通过 offset 保证消息在分区内的顺序,offset 的顺序不跨分区,即 Kafka 只保证在同一个分区内的消息是有序的。

5.4.1.3. 消息分发

Kafka 中最基本的数据单元就是消息,而一条消息其实是由 Key + Value 组成的(Key 是可选项,可传空值,Value 也可以传空值),这也是与 ActiveMQ 不同的一个地方。在发送一条消息时,我们可以指定这个 Key,那么 Producer 会根据 Key 和 partition 机制来判断当前这条消息应该发送并存储到哪个 partition 中(这个就跟分片机制类似)。我们可以根据需要进行扩展 Producer 的 partition 机制(默认算法是 hash 取 %)。

要注意的是如果 Consumer 数量比 partition 数量多,会有的 Consumer 闲置无法消费,这样是一个浪费。如果 Consumer 数量小于 partition 数量会有一个 Consumer 消费多个 partition。Kafka 在 partition 上是不允许并发的。Consuemr 数量建议最好是 partition 的整数倍。 还有一点,如果 Consumer 从多个 partiton 上读取数据,是不保证顺序性的,Kafka 只保证一个 partition 的顺序性,跨 partition 是不保证顺序性的。增减 Consumer、broker、partition 会导致 Rebalance。

5.4.1.4. Kafka 分区分配策略

在 Kafka 中,同一个 Group 中的消费者对于一个 Topic 中的多个 partition 存在一定的分区分配策略。

在 Kafka 中,存在两种分区分配策略,一种是 Range(默认)、另一种是 RoundRobin(轮询)。通过partition.assignment.strategy 这个参数来设置。

Range strategy(范围分区)
Range 策略是对每个主题而言的,首先对同一个主题里面的分区按照序号进行排序,并对消费者按照字母顺序进行排序。假设我们有10个分区,3个消费者,排完序的分区将会是0,1,2,3,4,5,6,7,8,9;消费者线程排完序将会是C1-0, C2-0, C3-0。然后将 partitions 的个数除于消费者线程的总数来决定每个消费者线程消费几个分区。如果除不尽,那么前面几个消费者线程将会多消费一个分区。

RoundRobin strategy(轮询分区)
轮询分区策略是把所有 partition 和所有 Consumer 线程都列出来,然后按照 hashcode 进行排序。最后通过轮询算法分配partition 给消费线程。如果所有 Consumer 实例的订阅是相同的,那么 partition 会均匀分布。

5.4.1.5. Reblance

当出现以下几种情况时,Kafka 会进行一次分区分配操作,也就是 Kafka Consumer 的 Rebalance

  1. 同一个 Consumer group内新增了消费者
  2. 消费者离开当前所属的 Consumer group,比如主动停机或者宕机
  3. Topic 新增了分区(也就是分区数量发生了变化)

Kafka Consuemr 的 Rebalance 机制规定了一个 Consumer group 下的所有 Consumer 如何达成一致来分配订阅 Topic 的每个分区。而具体如何执行分区策略,就是前面提到过的两种内置的分区策略。而 Kafka 对于分配策略这块,提供了可插拔的实现方式,也就是说,除了这两种之外,我们还可以创建自己的分配机制。

谁来执行 Rebalance 以及管理 Consumer 的 group 呢?

Consumer group 如何确定自己的 coordinator 是谁呢,消费者向 Kafka 集群中的任意一个 broker 发送一个 GroupCoord inatorRequest 请求,服务端会返回一个负载最小的 broker 节点的 id,并将该 broker 设置为 coordinator。

5.4.1.6. JoinGroup 的过程

在 Rebalance 之前,需要保证 coordinator 是已经确定好了的,整个 Rebalance 的过程分为两个步骤,Join和Syncjoin:表示加入到 Consumer group中,在这一步中,所有的成员都会向 coordinator 发送 joinGroup 的请求。一旦所有成员都发了 joinGroup请求,那么 coordinator 会选择一个 Consumer 担任 leader 角色,并把组成员信息和订阅信息发送给消费者

5.4.1.7. kafka中的ISR、AR又代表什么?

​分区中的所有副本统称为AR (Assigned Repllicas)。所有与leader副本保持一定程度同步的副本(包括Leader)组成ISR(In-Sync Replicas),ISR集合是AR集合中的一个子集。消息会先发送到leader副本,然后follower副本才能从leader副本中拉取消息进行同步,同步期间内follower副本相对于leader副本而言会有一定程度的滞后。前面所说的“一定程度”是指可以忍受的滞后范围,这个范围可以通过参数进行配置。与leader副本同步滞后过多的副本(不包括leader)副本,组成OSR(Out-Sync Relipcas),由此可见:AR=ISR+OSR。在正常情况下,所有的follower副本都应该与leader副本保持一定程度的同步,即AR=ISR,OSR集合为空。

Leader副本负责维护和跟踪ISR集合中所有的follower副本的滞后状态,当follower副本落后太多或者失效时,leader副本会吧它从ISR集合中剔除。如果OSR集合中follower副本“追上”了Leader副本,之后再ISR集合中的副本才有资格被选举为leader,而在OSR集合中的副本则没有机会(这个原则可以通过修改对应的参数配置来改变)

5.4.1.8. 序列化器、拦截器、分区器

KafkaProducer的消息在send()之后,可能会经过拦截器、序列化器、分区器之后才会真正到达Kafka-Broker中的Partition中

5.4.1.9. 高水位

在Kafka中,高水位的作用主要有两个

  1. 定义消息可见性,即用来标识分区下的哪些消息是可以被消费者消费的。
  2. 帮助Kafka完成副本同步

**Log End Offset(LEO)**表示副本写入下一条消息的位移值。注意,数字 15 所在的方框是虚线,这就说明,这个副本当前只有 15 条消息,位移值是从 0 到 14,下一条新消息的位移是 15。显然,介于高水位和 LEO 之间的消息就属于未提交消息。这也从侧面告诉了我们一个重要的事实,那就是:同一个副本对象,其高水位值不会大于 LEO 值。

高水位和 LEO 是副本对象的两个重要属性。Kafka 所有副本都有对应的高水位和 LEO 值,而不仅仅是 Leader 副本。只不过 Leader 副本比较特殊,Kafka 使用 Leader 副本的高水位来定义所在分区的高水位。换句话说,分区的高水位就是其 Leader 副本的高水位。

5.4.1.10. Kafka如何保证消息可靠性和一致性

生产者数据的不丢失
kafka 的 ack 机制:在 kafka 发送数据的时候,每次发送消息都会有一个确认反馈机制,确保消息正常的能够被收到。

消费者数据的不丢失
通过 offset commit 来保证数据的不丢失,kafka 自己记录了每次消费的 offset 数值,下次继续消费的时候,接着上次的 offset 进行消费即可。

保存的数据不丢失
kafka按照partition保存, 数据可以保存多个副本 , 副本中有一个副本是 Leader,其余的副本是 follower , follower会定期的同步leader的数据

5.4.1.11. Kafka事务

Kafka 的事务处理,主要是允许应用可以把消费和生产的 batch 处理(涉及多个 Partition)在一个原子单元内完成,操作要么全部完成、要么全部失败。为了实现这种机制,我们需要应用能提供一个唯一 id,即使故障恢复后也不会改变,这个 id 就是 TransactionnalId(也叫 txn.id,后面会详细讲述),txn.id 可以跟内部的 PID 1:1 分配,它们不同的是 txn.id 是用户提供的,而 PID 是 Producer 内部自动生成的(并且故障恢复后这个 PID 会变化),有了 txn.id 这个机制,就可以实现多 partition、跨会话的 EOS 语义。

当用户使用 Kafka 的事务性时,Kafka 可以做到的保证:

  1. 跨会话的幂等性写入:即使中间故障,恢复后依然可以保持幂等性;
  2. 跨会话的事务恢复:如果一个应用实例挂了,启动的下一个实例依然可以保证上一个事务完成(commit 或者 abort);
  3. 跨多个 Topic-Partition 的幂等性写入,Kafka 可以保证跨多个 Topic-Partition 的数据要么全部写入成功,要么全部失败,不会出现中间状态。

上面是从 Producer 的角度来看,那么如果从 Consumer 角度呢?Consumer 端很难保证一个已经 commit 的事务的所有 msg 都会被消费,有以下几个原因:

  1. 对于 compacted topic,在一个事务中写入的数据可能会被新的值覆盖;
  2. 一个事务内的数据,可能会跨多个 log segment,如果旧的 segmeng 数据由于过期而被清除,那么这个事务的一部分数据就无法被消费到了;
  3. Consumer 在消费时可以通过 seek 机制,随机从一个位置开始消费,这也会导致一个事务内的部分数据无法消费;
  4. Consumer 可能没有订阅这个事务涉及的全部 Partition。

因此为了解决上述问题,Kafka大体采用了三个措施一起来解决这个问题。

LSO
Kafka添加了一个很重要概念,叫做LSO,即last stable offset。对于同一个TopicPartition,其offset小于LSO的所有transactional message的状态都已确定,要不就是committed,要不就是aborted。而broker对于read_committed的consumer,只提供offset小于LSO的消息。这样就避免了consumer收到状态不确定的消息,而不得不buffer这些消息。

Aborted Transaction Index
对于每个LogSegment(对应于一个log文件),broker都维护一个aborted transaction index. 这是一个append only的文件,每当有事务被abort时,就会有一个entry被append进去。
Consumer端根据aborted transactions的消息过滤(以下对只针对read_committed的consumer)
consumer端会根据fetch response里提供的aborted transactions里过滤掉aborted的消息,只返回给用户committed的消息。

5.4.2. 选举算法

5.4.2.1. raft

包括三种角色:leader,candidate和follower。

follow:所有节点都以follower的状态开始,如果没有收到leader消息则会变成candidate状态。
candidate:会向其他节点拉选票,如果得到大部分的票则成为leader,这个过程是Leader选举。
leader:所有对系统的修改都会先经过leader。

其有两个基本过程

Leader选举:每个candidate随机经过一定时间都会提出选举方案,最近阶段中的票最多者被选为leader。
同步log:leader会找到系统中log(各种事件的发生记录)最新的记录,并强制所有的follow来刷新到这个记录。
Raft一致性算法是通过选出一个leader来简化日志副本的管理,例如,,日志项(log entry)只允许从leader流向follower。

5.4.2.2. paxos

两个阶段分别是准备(prepare)和提交(commit)。准备阶段解决大家对哪个提案进行投票的问题,提交阶段解决确认最终值的问题。

简单来说,提案者发出提案后,收到一些反馈,有两种结果,一种结果是自己的提案被大多数节点接受了,另外一种是没被接受,没被接受就过会再试试。
提案者收到来自大多数的接受反馈,也不能认为这就是最终确认。因为这些接收者并不知道自己刚反馈的提案就是全局的绝对大多数。
所以,引入新的一轮再确认阶段是必须的,提案者在判断这个提案可能被大多数接受的情况下,发起一轮新的确认提案。这就进入了提交阶段。
提交阶段的提案发送出去,其他阶段进行提案值比较,返回最大的,所以提案者收到返回消息不带新的提案,说明锁定成功,如果有新的提案内容,进行提案值最大比较,然后替换更大的值。如果没有收到足够多的回复,则需要再次发出请求。

一旦多数接受了共同的提案值,则形成决议,称为最终确认的提案。

5.5. Netty Nio

5.5.1. Netty介绍

Netty 对 JDK 自带的 NIO 的 API 进行封装,解决上述问题,主要特点有:

  1. 设计优雅,适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池;真正的无连接数据报套接字支持(自 3.1 起)。
  2. 使用方便,详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK 5(Netty 3.x)或 6(Netty 4.x)就足够了。
  3. 高性能,吞吐量更高,延迟更低;减少资源消耗;最小化不必要的内存复制。
  4. 安全,完整的 SSL/TLS 和 StartTLS 支持。
  5. 社区活跃,不断更新,社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入。

5.5.2. BIO和NIO

阻塞IO:当阻塞IO调用inputStream.read()方法是阻塞的,一直等到数据到来时才返回,同样ServerScoket.accpt()方法时,也是阻塞,直到客户端连接才返回。

缺点:当客户端多时,才创建大量的处理线程,并且每一个线程分配一定的资源;阻塞可能打来频繁切换上下文,引用NIO。

NIO:jdk1.4引入。基于通过和缓存区的IO方式,NIO是一种非阻塞IO的模型,通过不断轮询IO事件是否就绪,非阻塞是在指线程在等待IO的时候,可以做其他的任务,同步的核心Selector,Selector代替线程本身的轮询IO事件。避免阻塞同时减少不想必要的线程消耗,非阻塞的核心是通道和缓存区,当IO事件的就绪是,可以缓冲区的数据写入通道。

5.5.3. IO多路复用

Netty 的 IO 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端连接。

当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。

线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。

由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。

一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

5.5.4. 基于 Buffer

传统的 I/O 是面向字节流或字符流的,以流式的方式顺序地从一个 Stream 中读取一个或多个字节, 因此也就不能随意改变读取指针的位置。

在 NIO 中,抛弃了传统的 I/O 流,而是引入了 Channel 和 Buffer 的概念。在 NIO 中,只能从 Channel 中读取数据到 Buffer 中或将数据从 Buffer 中写入到 Channel。

基于 Buffer 操作不像传统 IO 的顺序操作,NIO 中可以随意地读取任意位置的数据。

5.5.5. 事件驱动

主要包括 4 个基本组件:

事件队列(event queue):接收事件的入口,存储待处理事件。
分发器(event mediator):将不同的事件分发到不同的业务逻辑单元。
事件通道(event channel):分发器与处理器之间的联系渠道。
事件处理器(event processor):实现业务逻辑,处理完成后会发出事件,触发下一步操作。
可以看出,相对传统轮询模式,事件驱动有如下优点:

  1. 可扩展性好,分布式的异步架构,事件处理器之间高度解耦,可以方便扩展事件处理逻辑。
  2. 高性能,基于队列暂存事件,能方便并行异步处理事件。

5.5.6. Reactor 线程模型

Reactor 是反应堆的意思,Reactor 模型是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。

服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式,即 I/O 多了复用统一监听事件,收到事件后分发(Dispatch 给某进程),是编写高性能网络服务器的必备技术之一。

Reactor 模型中有 2 个关键组成:

Reactor,Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人。
Handlers,处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。

5.5.7. Netty 线程模型

Netty 主要基于主从 Reactors 多线程模型(如下图)做了一定的修改,其中主从 Reactor 多线程模型有多个 Reactor:

MainReactor 负责客户端的连接请求,并将请求转交给 SubReactor。
SubReactor 负责相应通道的 IO 读写请求。
非 IO 请求(具体逻辑处理)的任务则会直接写入队列,等待 worker threads 进行处理。

5.5.8. 异步处理

异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。

Netty 中的 I/O 操作是异步的,包括 Bind、Write、Connect 等操作会简单的返回一个 ChannelFuture。

调用者并不能立刻获得结果,而是通过 Future-Listener 机制,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果。

当 Future 对象刚刚创建时,处于非完成状态,调用者可以通过返回的 ChannelFuture 来获取操作执行的状态,注册监听函数来执行完成后的操作。

常见有如下操作:

通过 isDone 方法来判断当前操作是否完成。
通过 isSuccess 方法来判断已完成的当前操作是否成功。
通过 getCause 方法来获取已完成的当前操作失败的原因。
通过 isCancelled 方法来判断已完成的当前操作是否被取消。
通过 addListener 方法来注册监听器,当操作已完成(isDone 方法返回完成),将会通知指定的监听器;如果 Future 对象已完成,则理解通知指定的监听器。

5.6. Nginx

5.6.1. Nginx进程模型

Nginx在启动后,会有一个master进程和多个worker进程。

5.6.1.1. Master进程

master进程主要用来管理worker进程,包含:

  • 接收来自外界的信号;
  • 向各worker进程发送信号;
  • 监控worker进程的运行状态,当worker进程退出后(异常情况下),会自动重新启动新的worker进程。

master进程充当整个进程组与用户的交互接口,同时对进程进行监护。它不需要处理网络事件,不负责业务的执行,只会通过管理worker进程来实现重启服务、平滑升级、更换日志文件、配置文件实时生效等功能。

我们要控制nginx,只需要通过kill向master进程发送信号就行了。比如kill -HUP pid,则是告诉nginx,从容地重启nginx,我们一般用这个信号来重启nginx,或重新加载配置,因为是从容地重启,因此服务是不中断的。

5.6.2. Worker进程

基本的网络事件,则是放在worker进程中来处理了。多个worker进程之间是对等的,他们同等竞争来自客户端的请求,各进程互相之间是独立的。一个请求,只可能在一个worker进程中处理,一个worker进程,不可能处理其它进程的请求。

worker进程的个数是可以设置的,一般我们会设置与机器cpu核数一致,这里面的原因与nginx的进程模型以及事件处理模型是分不开的。

worker进程之间是平等的,每个进程,处理请求的机会也是一样的。当我们提供80端口的http服务时,一个连接请求过来,每个进程都有可能处理这个连接,怎么做到的呢?

首先,每个worker进程都是从master进程fork过来,在master进程里面,先建立好需要listen的socket(listenfd)之后,然后再fork出多个worker进程。所有worker进程的listenfd会在新连接到来时变得可读,为保证只有一个进程处理该连接,所有worker进程在注册listenfd读事件前抢accept_mutex,抢到互斥锁的那个进程注册listenfd读事件,在读事件里调用accept接受该连接。当一个worker进程在accept这个连接之后,就开始读取请求,解析请求,处理请求,产生数据后,再返回给客户端,最后才断开连接,这样一个完整的请求就是这样的了。我们可以看到,一个请求,完全由worker进程来处理,而且只在一个worker进程中处理。

5.6.3. Nginx处理HTTP请求流程

Http请求是典型的请求-响应类型的的网络协议。Http是文件协议,所以我们要分析请求行与请求头,以及输出响应行与响应头,这些往往是一行一行的进行处理。通常在一个连接建立好后,读取一行数据,分析出请求行中包含的method、uri、http_version信息;然后再一行一行处理请求头,并根据请求method与请求头的信息来决定是否有请求体以及请求体的长度,然后再去读取请求体;得到请求后,我们处理请求产生需要输出的数据,然后再生成响应行,响应头以及响应体。在将响应发送给客户端之后,一个完整的请求就处理完了。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

丧心病狂の程序员

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值