秒杀项目

三、云端部署与性能压测

云端部署流程

  • 首先购买一台阿里云的服务器(我买的是阿里云开发者计划99元1年的2核40G服务器),配置连接本地电脑直接连接ssh(https://zhuanlan.zhihu.com/p/54643053),配置网络的安全组,目的是使得端口号符合项目的接入需求。修改ssh连接密码。
  • 使用root账号远程连接服务器
  • 安装jdk(需要注意的是每次对文件进行操作的时候需要更改一下文件的权限chmod -777 文件名 或者chmod -R 777 *)安装jdk的命令是:rpm -ivh jdk文件名.rpm。
  • 安装mysql,安装命令:yum install mysql* 安装mariadb依赖:yum install mariadb-server,启动命令:systemctl start mariadb.service.
    在这里插入图片描述

性能压力测试

jemeter性能压测所具备的四个组件:

  • 线程组
  • http请求
  • 查看结果树
  • 聚合报告
    在jemeter客户端内部启动多个并发的线程,并发的发送一些接口的请求,用来测试服务端的压力阈值。线程组负责发送http请求,因此需要有一个http发送模块。在http发送之后需要查看返回的结果,需要一个查看结果树用来查看http的请求正常或者异常。最后需要一个性能压测的报告,内部含有tps以及qps的报告,来表现对应接口的相应情况。
    在这里插入图片描述
    在这里插入图片描述
    查看java进程的命令:

ps -ef | grep java

查看进程端口号的命令(11310为进程号):

netstat -anp | grep 13847

查看进程的线程:

pstree -p 13847

查看进程的线程数量:

pstree -p 13847| wc -l

查看机器的性能数量:

top -H
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
聚合报告的指标含义

TPS:Transactions Per
Second(每秒传输的事物处理个数),即服务器每秒处理的事务数。TPS包括一条消息入和一条消息出,加上一次用户数据库访问。TPS是软件测试结果的测量单位。一个事务是指一个客户机向服务器发送请求然后服务器做出反应的过程。客户机在发送请求时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数。一般的,评价系统性能均以每秒钟完成的技术交易的数量来衡量。系统整体处理能力取决于处理能力最低模块的TPS值。
QPS:每秒查询率QPS是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准,在因特网上,作为域名系统服务器的机器的性能经常用每秒查询率来衡量。对应fetches/sec,即每秒的响应请求数,也即是最大吞吐能力。
QPS与TPS的区别是什么呢?
举个栗子:假如一个大胃王一秒能吃10个包子,一个女孩子0.1秒能吃1个包子,那么他们是不是一样的呢?答案是否定的,因为这个女孩子不可能在一秒钟吃下10个包子,她可能要吃很久。这个时候这个大胃王就相当于TPS,而这个女孩子则是QPS。虽然很相似,但其实是不同的。

在这里插入图片描述

四、单机容量问题

发现容量问题

在这里插入图片描述
SpringBoot的项目默认配置最大请求连接配置为10000,因此在上线之前需要手动更改配置才能获得更好的并发性能。在2核服务器上的进行如下的调优,可使项目的并发数从32提升到116。

server.tomcat.accept-count=1000
server.tomcat.max-threads=300
server.tomcat.min-spare-threads=100

继续优化并发问题:
在这里插入图片描述

//当Spring容器内没有TomcatEmbeddedServletContainerFactory这个bean时,会吧此bean加载进spring容器中
@Component
public class WebServerConfiguration implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory configurableWebServerFactory) {
            //使用对应工厂类提供给我们的接口定制化我们的tomcat connector
        ((TomcatServletWebServerFactory)configurableWebServerFactory).addConnectorCustomizers(new TomcatConnectorCustomizer() {
            @Override
            public void customize(Connector connector) {
                Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
                //定制化keepalivetimeout,设置30秒内没有请求则服务端自动断开keepalive链接
                protocol.setKeepAliveTimeout(30000);
                //当客户端发送超过10000个请求则自动断开keepalive链接
                protocol.setMaxKeepAliveRequests(10000);
            }
        });
    }
}

发现容量问题
响应时间边长,TPS上不去
在这里插入图片描述
大量时间花费在数据库的操作上面,因此如果所有的查询都落在主键查询以及唯一索引上的话,就会节省大量的时间。
在这里插入图片描述
在这里插入图片描述

ngnix反向代理负载均衡

在这里插入图片描述
单机容量问题水平解决方案:

  • Mysql数据库开放远端连接
  • 服务端水平部署
  • 验证访问

项目需要购买四台服务器(2Cpu 4Gb),可以选择按时收付费的方式购买,每次不用的时候停止实例,但是会产生一个问题,每次重新连接的时候公网的ip会发生变化:
其中一台是用来
两台使用来做秒杀服务器,一台用来做秒杀数据库服务器,一台用来做ngnix反向代理。

将主服务器的数据传送到附属服务器上:

scp -r //var/www root@公网ip/私有ip:/var/

需要注意的是每个服务器的properties都要设置自己mysql ip内网地址
在这里插入图片描述
改变mysql的访问权限,使得其他的服务器也能访问本服务器的mysql,但是本服务器只允许ip白名单的ip能访问到
在这里插入图片描述

ngnix的使用

  • 使用nginx作为web服务器(作为服务器静态资源的访问,存储html,css等文件)
  • 使用nginx作为动静分离服务器
  • 使用nginx作为反向代理服务器(将动态请求反向代理到后端来完成动态资源请求的代理的操作,并且以ajax请求的方式返回前端参数,来完成动静分离服务器请求的使用)
    部署流程:
    在这里插入图片描述
    整体流程思想:

使用nginx负载均衡策略对前端的请求进行解析,如果是ajax请求的话循环的负载均衡到两台服务器上,由于两台服务器的权值都是1,因此访问的几率是相同的。数据库分布式部署于整个框架内,每台服务器都需要向一个装有独立mysql和redis的数据库访问数据。如果访问的是静态static资源,则直接访问nginx目录下html文件下的静态html文件。

由于分布式部署中,nginx需要负载均衡的指定某一台服务器来相应用户的请求,但是每一台服务器对应的访问路径是不一样的,因此需要nginx需要指定路径,为了方便,直接使用一个js配置来管理ip域名,每次只需要更改这个js即可。该js为:

var g_host = "localhost:8040";

对应url的访问路径更改为:

url:"http://"+g_host+"/user/getotp",

使用openResty框架来做nginx相关的调优和开发工作。

OpenResty® 是一个基于 Nginx 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。
OpenResty® 通过汇聚各种设计精良的 Nginx 模块(主要由 OpenResty 团队自主开发),从而将 Nginx 有效地变成一个强大的通用 Web 应用平台。这样,Web 开发人员和系统工程师可以使用 Lua 脚本语言调动 Nginx 支持的各种 C 以及 Lua 模块,快速构造出足以胜任 10K 乃至 1000K 以上单机并发连接的高性能 Web 应用系统。
OpenResty® 的目标是让你的Web服务直接跑在 Nginx 服务内部,充分利用 Nginx 的非阻塞 I/O 模型,不仅仅对 HTTP 客户端请求,甚至于对远程后端诸如 MySQL、PostgreSQL、Memcached 以及 Redis 等都进行一致的高性能响应。

Niginx部署前端静态页面:

配置openResty
安装完openResty之后,将nginx指定成对应的web服务器
在这里插入图片描述

ngnix动静分离服务器

在这里插入图片描述
直接在/usr/local/openresty/nginx/conf/nginx.conf中的配置,

重新加载nginx的命令:

cd /usr/local/nginx/nginx/sbin
sbin/nginx -s reload

开启Tomcat Log追踪日志:
首先在miaosha文件夹下创建一个tomcat文件夹
然后在application.properties中添加相关配置:

server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.directory=/var/www/miaosha/tomcat
server.tomcat.accesslog.pattern=%h %l %u %t “%r” %s %b %D

其中
%h:host IP地址
%l: 默认值
%u:远程主机的user
%t:处理时长
%r:请求方法
%s:http的返回状态码
%b:对应请求response的大小
%D:处理请求的时长
nginx服务器跟后端的java反向代理服务器是短连接的状态,需要修改为长连接的状态

相关命令:

ps us | grep java //查看java进程号
kill 进程ID // 杀死进程
tail -200f 文件名 //查看文件

四台服务器的部署情况:
在这里插入图片描述
压测结果:tps最高达到了1700,成功的解决了单级容量问题,解决了服务器针对某一台压力过大的问题,导致了原本对于mysql服务器产生的巨大压力没有了在这里插入图片描述

注意:原本的mysql和jar包是部署在同一台服务器上的,分布式部署必然产生局域网的消耗,使用长连接可以很解决这个问题。nginx和客户端也是长连接,通过修改nginx的keepalive配置
在这里插入图片描述
框架流程:

nginx高性能的原因

  • 基于epoll多路复用机制,完成非阻塞式的I/O操作
  • master work进程模型,可以允许平滑重启,平滑的加载配置
  • 基于协程的非阻塞式的编程机制,来完成单线程单进程调用机制,却又支持并发的调用基础。
  • 在这里插入图片描述

select多路复用机制:
在这里插入图片描述
linux 2.6诞生epoll模型
在这里插入图片描述
Master-worker进行的模型:
在这里插入图片描述

Nginx 在启动后,会有一个 master 进程和多个相互独立的 worker 进程。
接收来自外界的信号,向各worker进程发送信号,每个进程都有可能来处理这个连接。 master 进程能监控 worker
进程的运行状态,当 worker 进程退出后(异常情况下),会自动启动新的 worker 进程。

需要注意的是:

注意 worker 进程数,一般会设置成机器 cpu 核数。因为更多的worker 数,只会导致进程相互竞争 cpu,从而带来不必要的上下文切换。 使用多进程模式,不仅能提高并发率,而且进程之间相互独立,一个 worker 进程挂了不会影响到其他 worker 进程。

出现的问题:惊群现象

主进程(master 进程)首先通过 socket() 来创建一个 sock 文件描述符用来监听,然后fork生成子进程(workers进程),子进程将继承父进程的 sockfd(socket 文件描述符),之后子进程 accept() 后将创建已连接描述符(connected descriptor)),然后通过已连接描述符来与客户端通信 那么,由于所有子进程都继承了父进程的sockfd,那么当连接进来时,所有子进程都将收到通知并“争着”与它建立连接,这就叫“惊群现象”。大量的进程被激活又挂起,只有一个进程可以accept()到这个连接,这当然会消耗系统资源。

nginx对惊群现象的处理办法:

Nginx 提供了一个 accept_mutex 这个东西,这是一个加在accept上的一把互斥锁。即每个 worker 进程在执行accept 之前都需要先获取锁,获取不到就放弃执行 accept()。有了这把锁之后,同一时刻,就只会有一个进程去 accpet(),这样就不会有惊群问题了。accept_mutex 是一个可控选项,我们可以显示地关掉,默认是打开的。

worker进程工作流程

当一个 worker 进程在 accept() 这个连接之后,就开始读取请求,解析请求,处理请求,产生数据后,再返回给客户端,最后才断开连接,一个完整的请求。一个请求,完全由 worker进程来处理,而且只能在一个 worker 进程中处理。 这样做带来的好处:
1、节省锁带来的开销。每个 worker 进程都是独立的进程,不共享资源,不需要加锁。同时在编程以及问题查上时,也会方便很多。
2、独立进程,减少风险。采用独立的进程,可以让互相之间不会影响,一个进程退出后,其它进程还在工作,服务不会中断,master 进程则很快重新启动新的 worker 进程。当然,worker 进程的也能发生意外退出。
多进程模型每个进程/线程只能处理一路IO,那么 Nginx是如何处理多路IO呢?
如果不使用 IO 多路复用,那么在一个进程中,同时只能处理一个请求,比如执行
accept(),如果没有连接过来,那么程序会阻塞在这里,直到有一个连接过来,才能继续向下执行。
而多路复用,允许我们只在事件发生时才将控制返回给程序,而其他时候内核都挂起进程,随时待命。

协程机制:
单线程的编程模型,但是需要在一个worker内处理大量的请求,并且他们之间的操作是互相独立的,怎么使用一套变成模型来完成异步化的操作呢,nginx开发了协程的机制。

  • 依附于线程的内存模型,切换开销小
  • 遇阻塞即归还执行权,代码同步
  • 无需加锁

总结:Nginx为什么高效的原因?

  1. 首先依靠epoll多路复用机制,解决了I/o阻塞回调通知的问题。
  2. 依靠master woker模型平滑的过渡、平滑的重启,并且基于worker的单线程模型结合epoll多路复用机制完成高效的操作。
  3. 基于协程的机制,将每个用户的请求对应到线程中某一个协程中,然后在协程中使用epoll多路复用的机制,来完成对应的同步调用的开发,实现高性能的操作。

会话管理

在这里插入图片描述
引入redis存储用户的session:
1.导入redis和session所需要的库(pom.xml)
首先在pom.xml文件中添加

<!--Redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-redis</artifactId>
            <version>1.4.7.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
            <!--<version>2.0.6</version>-->
        </dependency>

2.Redis数据库的配置(application.properties)
在application.properties文件中,添加以下的配置(简化版,甚至都不用配置Configuration):

#配置springboot对redis的依赖
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.database=10
#spring.redis.password=
#设置jedis连接池
spring.redis.jedis.pool.max-active=50
spring.redis.jedis.pool.min-idle=20

3.session 有效时间设置:

@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisConfig {
}

在这里插入图片描述

centos下安装redis以及分布式部署

tar -zxvf redis-3.0.0.tar.gz //1解压
chmod - R 777 * //2解锁权限
//安装c和c++的依赖
yum -y install gcc  //安装gcc(c)编译器使用命令:
yum -y install gcc-c++ // 安装g++(c++)编译器使用命令:
//cd redis目录
./configure  //执行该命令,用来生成makefile文件
make //执行make命令,注意当前文件下一定要有makefile文件
cd /src
bin/redis-server & //后台启动redis服务
./bin/redis-cli // 进入redis服务

相关链接:

https://blog.csdn.net/weixin_43648683/article/details/84403729

分布式部署
在一般的实际的开发应用中,需要将redis以及mysql单独的布置到一台服务器上,但是redis默认只能本机ip才能访问到redis6379端口。因此我们需要对数据库服务器的redis做一些配置修改。

1在redis的配置文件redis.conf中,找到bind localhost注释掉。
2设置参数protected-mode 为 no,关闭redis的保护模式
3设置redis的连接密码,防止被恶意攻击

分布式下的会话管理

redis数据库独立部署,只需要将每台服务器配置相关的redis依赖即可,每次访问都到部署redis服务器的数据库存取session,不同服务器操作的是同一个redis数据库,解决了session分布式部署的问题。
基于token:客户端将服务器下发的token存储在本地,下一次请求的时候将这个token加到header请求头中传给服务器,从而验证身份。使用UUID来为每个用户生成唯一的登录凭证。尽量避免基于cookie传输session的方式,并非是cookie传输sessionId不安全,主要是因为登录的方式有可能不支持cookie,比如说微信登录。因此使用基于token来完成用户的登录凭证

五、查询性能优化技术之多级缓存

缓存

缓存设计原则

  • 用快速存取设备,用内存
  • 将缓存推到离用户最近的地方
  • 脏数据的缓存清理

多级缓存

  • redis緩存
  • 热点内存本地缓存
  • nginx proxy cache 缓存
  • nginx lua缓存

redis缓存

  • 单机模式:如果redis服务器发生故障,会造成数据丢失的问题,导致系统瘫痪。
  • sentinal哨兵模式:和主、从redis服务器建立长连接,通过心跳机制,获取所有redis服务器的状态。miaosha.jar只需要询问redis sentinal 就可以知道需要连接哪一台redis服务器。
  • 集群cluster模式(目前业界使用最多的模式)所有的redis节点彼此互联、节点的fail是通过集群中超过半数的节点检测失效时才生效、客户端与redis节点直连,不需要中间proxy层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可、Redis集群不需要 sentinel 哨兵也能完成节点移除和故障转移的功能、这种集群模式没有中心节点,可水平扩展。每个节点都可以感知到其他节点的状态。
    在这里插入图片描述
    在这里插入图片描述

商品详情动态内容实现

使用redis缓存用户信息

       //根据商品的id到redis内获取
        ItemModel itemModel = (ItemModel)redisTemplate.opsForValue().get("item_" + id);

        //若redis内不存在对应的itemModel,则访问下游的service也就是数据库
        if(itemModel == null){
            itemModel = itemService.getItemById(id);
            //将访问到的数据同步到redis中
            redisTemplate.opsForValue().set("item_"+id,itemModel);
            //设置redis过期时间,10分钟
            redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
        }

商品信息本地热点缓存

  • 热点数据
  • 脏读非常不敏感
  • 内存可控

本地缓存是将数据放进堆栈中,和放在数据库中是有所区别的。

本地缓存策略:使用java的HashMap结构,key存放商品id,value存放itemModel。需要HashMap支持并发读写的能力,因此想到ConCurrentHashMap来保证线程安全。但是ConCurrentHashMap是基于段的方式进行加锁,因此每次操作的时候加写锁会对读锁有影响。第二本地缓存需要考虑缓存失效时间,因此单纯的java HashMap无法满足。因此需要引入Guava cache

Guava cache:

  • 可控制的大小和超时时间
  • 可配置的lru策略
  • 线程安全

一般不适用mybatis自带的二级缓存,因为不利于扩展。
缓存一般是不需要加锁的,因为缓存本身就没有必要保证一定不能脏读,加锁反而会印象影响性能。

二级缓存思路:

先从本地缓存中取数据,若本地缓存中不存在,到redis中取,redis中不存在到数据库中取
代码实现:

//本地缓存接口
package com.imooc.miaoshaproject.service;
//封装本地缓存操作类
//只需要写一个抽象类接口,然后在接口的实现类中注入@Service注解,就可以自动装配
//每次在controller里面直接@AutoWride接口就行
public interface CacheService {
    //存方法
    void setCommonCache(String key,Object value);

    Object getFromCommonCache(String key);
}

//本地缓存实现类
package com.imooc.miaoshaproject.service;
//封装本地缓存操作类
//只需要写一个抽象类接口,然后在接口的实现类中注入@Service注解,就可以自动装配
//每次在controller里面直接@AutoWride接口就行
public interface CacheService {
    //存方法
    void setCommonCache(String key,Object value);

    Object getFromCommonCache(String key);

}

//二级缓存商品信息实现
ItemModel itemModel = null;
//先从本地缓存中取数据,若本地缓存中不存在,到redis中取,redis中不存在到数据库中取
itemModel = (ItemModel) cacheService.getFromCommonCache("ittem_" + id);
if(itemModel == null){
    //根据商品的id到redis内获取
    itemModel = (ItemModel)redisTemplate.opsForValue().get("item_" + id);
    //若redis内不存在对应的itemModel,则访问下游的service也就是数据库
    if(itemModel == null){
        itemModel = itemService.getItemById(id);
        //将访问到的数据同步到redis中
        redisTemplate.opsForValue().set("item_"+id,itemModel);
        //设置redis过期时间,10分钟
        redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
    }
    //填充本地缓存
    cacheService.setCommonCache("item_id"+id,itemModel);
}

本地热点缓存压测结果验证

使用了二级缓存,tps从2000多上升到了3000多,可见二级缓存是非常有效果的。

nginx缓存

因为每次浏览器的请求都是由nginx负载均衡轮询的访问两个服务器,服务器再去访问缓存,这样会造成很多不必要的消耗,因此如果将缓存直接设置在nginx上,会大大提升效率。

nginx proxy cache缓存

  • nginx反向代理前置
  • 依靠文件系统存索引级的文件
  • 依靠内存缓存文件地址
    #申请一个cache缓存节点的内容,levels=1:2代表二级目录索引hash值,keys_zone=tmp_cache:100m  总共100m的空间,inactive=7d 存储7天 ,max_size=10g  最大10g,超过的话会采取lru策略
    proxy_cache_path /usr/local/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;
		//反向代理设置
        proxy_pass http://backend_server;
        proxy_cache tmp_cache;
        proxy_cache_key $uri;
        //只有状态码为200 206 304 302 10d  才会触发缓存。
        proxy_cache_valid 200 206 304 302 10d;

sbin/nginx -s reload //重启nginx
nginx的缓存读取本地缓存,效率较低,不推荐使用。

nginx lua

  • lua协程机制:编写代码的时候不需异步的方式,完全可以使用同步的方式去编写。一旦对应的协程在遇到了任何的阻塞。
  • nginx协程机制
  • nginx lua插载点

协程机制:

  • 依附于线程的内存模型,切换开销小
  • 遇到阻塞就归还执行权,代码同步
  • 不需要进行加锁

nginx协程:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
通过content_by_lua设置,直接在nginx 的lua上访问item,从而避免访问java的服务器。
具体配置:

    location /staticitem/get{
        default_type "text/html";
        content_by_lua_file ../lua/staticitem.lua;
    }

OpenResty应用

概念:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

六、性能查询优化技术之页面静态化

静态请求CDN

在这里插入图片描述

在这里插入图片描述

Cache Control响应头

在这里插入图片描述

在这里插入图片描述
_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQxNzk3Nzgy,size_16,color_FFFFFF,t_70)

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

全页面静态化

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

七、交易性能瓶颈

  • jmeter性能压测
  • 交易验证完全依赖数据库
  • 库存行锁
  • 后置处理逻辑

交易流程:
在这里插入图片描述

交易验证优化

  • 用户风控策略优化:策略缓存模型化
  • 活动校验策略优化:优化活动发布流程,模型缓存化,紧急下线能力

将UserModel和ItemMoel放进redis缓存中

//在ItemModel接口中添加抽象方法
//item及promo model缓存模型
ItemModel getItemByIdInCache(Integer id);
//实现类
    @Override
    public ItemModel getItemByIdInCache(Integer id) {
        ItemModel itemModel = (ItemModel) redisTemplate.opsForValue().get("item_validate_" + id);
        if(itemModel == null){
            itemModel = this.getItemById(id);
            redisTemplate.opsForValue().set("item_validate_"+id,itemModel);
            //设置过期时间,10分钟
            redisTemplate.expire("item_validate_"+id,10, TimeUnit.MINUTES);
        }
        return itemModel;
    }

//在usermodel接口中添加抽象方法
UserModel getUserByIdInCache(Integer id);
    @Override
    public UserModel getUserByIdInCache(Integer id) {
        //检查一下缓存中是否有数据
        UserModel userModel = (UserModel) redisTemplate.opsForValue().get("user_validate_" + id);
        if(userModel == null){
            userModel = this.getUserById(id);
            //更新缓存
            redisTemplate.opsForValue().set("user_validate_"+id,userModel);
            redisTemplate.expire("user_validate_"+id,10, TimeUnit.MINUTES);
        }
        return userModel;
    }

库存行锁优化

为什么要做库存行锁的优化?

因为每次执行更新商品订单库存的时候都要执行一条删除库存的sql语句,需要查询商品的id,这个商品的Id使用唯一索引来避免每次操作的时候都要锁定一整张表,有了唯一索引只需要加行锁就可以保证事务的一致性。另外抢购的商品不只有一个,因此不需要所有的商品都串行,只需要对应的抢购商品减库存即可。但是串行化减库存的操作是无法避免的,因此是一个性能瓶颈。

优化方案:

  • 扣减库存缓存化:扣减数据库有一个磁盘的操作,扣减内存是特别快的操作
  • 异步同步数据库:缓存是不安全的,内存会丢失,因此要将数据异步的同步到数据库内
  • 库存数据库最终一致性保证

方案一:扣减库存缓存化

  1. 活动发布同步库存进缓存
  2. 下单交易减缓库存

活动发布同步库存进缓存:


	//定义promo接口方法
    //活动发布
    void publishPromo(Integer promoId);
    
	//接口实现类方法
	    @Override
    public void publishPromo(Integer promoId) {
        //通过活动id获取活动
        PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
        if(promoDO == null || promoDO.getItemId().intValue() == 0){
            return;
        }
        ItemModel itemModel = itemService.getItemByIdInCache(promoDO.getItemId());
        //注意:商品从数据库内读取出来后,拿到对应的商品存入缓存内这段时间,对应的商品可能是售卖的。
        //将库存同步到redis内
        redisTemplate.opsForValue().set("promo_item_stock_"+itemModel.getId(),itemModel.getStock());
    }

下单减缓存:

    @Override
    @Transactional
    public boolean decreaseStock(Integer itemId, Integer amount) throws BusinessException {
    //不在更新数据库,而是更新缓存
        //int affectedRow =  itemStockDOMapper.decreaseStock(itemId,amount);
        long result = redisTemplate.opsForValue().increment("promo_item_stock_"+itemId,amount.intValue()*-1);
        if(result >= 0){
            //更新库存成功
            return true;
        }else{
            //更新库存失败
            return false;
        }

    }

问题:

数据库的记录跟缓存内的数据是完全不一致的

方案二:通过异步消息队列rocketmq实现异步同步数据库

  1. 活动发布同步库存进缓存
  2. 下单交易减缓存库存
  3. 异步消息扣减数据库内库存:采用异步消息队列的方式,将异步扣减的消息同步给消息的consummer端,由这个消息的consummer端完成数据库内库存的扣减操作。

主要使用活动发布同步库存进缓存、下单交易减缓存库存、使用异步消息的方式将数据库内的库存进行扣减。这样就可以使得C端用户既可以体验通过redis完成高效购买体验,又可以保证异步扣减数据库的操作能够保证一个最终的一致性。

异步消息队列rocketmq:

RocketMQ是一个纯Java、分布式、队列模型的开源消息中间件,前身是MetaQ,是阿里参考Kafka特点研发的一个队列模型的消息中间件,后开源给apache基金会成为了apache的顶级开源项目,具有高性能、高可靠、高实时、分布式特点。

  • 高性能、高并发、分布式消息中间件
  • 典型应用场景:分布式事务、异步解耦

概念模型:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
异步同步数据库存在的问题:

  • 异步消息发送失败 :只有通过回滚事务来避免数据不一致
  • 库存扣减操作执行失败:通过回滚事务来避免数据不一致
  • 下单失败无法正确的回补库存

消费者:消费者监听nameserver,一旦生产者生产了消息,就立刻从队里中抓取信息,一旦抓取到了商品库存信息,立刻修改数据库中的库存信息。

@Component
public class MqConsumer {

    private DefaultMQPushConsumer consumer;
    @Value("${mq.nameserver.addr}")
    private String nameAddr;

    @Value("${mq.topicname}")
    private String topicName;

    @Autowired
    private ItemStockDOMapper itemStockDOMapper;

    @PostConstruct
    public void init() throws MQClientException {
        consumer = new DefaultMQPushConsumer("stock_consumer_group");
        consumer.setNamesrvAddr(nameAddr);
        consumer.subscribe(topicName,"*");
        //消费者监听nameserver,一旦生产者生产了消息,就立刻从队里中抓取信息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                //实现库存真正到数据库内扣减的逻辑
                Message msg = msgs.get(0);
                String jsonString  = new String(msg.getBody());
                Map<String,Object>map = JSON.parseObject(jsonString, Map.class);
                Integer itemId = (Integer) map.get("itemId");
                Integer amount = (Integer) map.get("amount");
                //数据库内存扣减,是在消费者从消息队列中拿到了扣减的信息之后,对数据库进行的一些操作
                itemStockDOMapper.decreaseStock(itemId,amount);
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        consumer.start();
    }
}

八、交易优化技术之事务性消息

库存流水状态

异步同步数据库会出现的问题:

  • 异步消息发送失败
  • 扣减操作执行失败
  • 下单失败无法正确回补库存

问题本质:没有一个库存的流水

为什么好不容易才把库存的操作移到redis上,还要设置一个库存流水的数据库层级的操作来维护整个库存的扣减操作呢?

之前库存的扣减操作是针对单个商品级别的,也就说一个商品所有减库存的操作都落在单个商品级别的行锁上面。每次有下订单的操作时,对应的库存流水会发生变化,每次的库存流水操作都会写入到mysql中,这个操作不同于商品级别的行锁,是一个单独的行锁,对数据库的压力非常小。

        //设置库存流水状态为成功
        StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
        if(stockLogDO == null){
            throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
        }
        stockLogDO.setStatus(2);
        //更新数据库中的stockLogDO
        stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO);

库存数据库最终一致性保证

方案:

  • 引入库存操作流水:来完成库存状态的跟踪,避免了库存状态断链之后,不知道怎么跟踪商品的情况
  • 引入事务性消息机制

引用库存操作流水和引入事务性消息机制来保证redis和数据库的最终一致性。

但是仍然会存在问题:

  • redis不可用时如何处理?
  • 扣减流水错误如何处理?

redis出现问题时,将数据库回滚,保证一致性。那么所有的异步消息的同步状态是否都完整了呢?是不是在redis不可用的时候去减数据库呢?
什么情况下会产生商品超卖?如何解决这个问题?
举个例子:redis里面已经减掉了5件商品,但是这五件商品的库存流水的消息并没有被生产者完全的投放出去,导致库存的扣减操作没有做完,那可能对应的数据库内的item_stock表里面的数据会比redis里面的数据多。如果重复扣减的话就会产生超卖的情况。

扣减流水出错了该如何处理?是直接让用户下单失败吗?

设计原则:

  • 宁可少卖,不能超卖

设计方案:

  • redis可以比实际数据库中少,但是redis出现问题的时候,绝对不能将还原数据库。因为没有办法确定数据库是否是正常的。
  • 超时释放,如果orderService.createOrder下订单操作长时间不返回或者程序死掉,就会产生超时的问题,意味着永远无法执行后面的代码,库存流水的状态永远都是初始化状态,如果程序出现大面积的假死,那么这个商品的信息相当于是废掉了,这个商品永远也卖不出去,库存在redis内永远已经被减掉,但是库存流水号stock_log永远无法跟踪成功还是失败。redis内存无法重新被加回来。因此订单体系都会有一个超时释放的功能。当下单动作触发15分钟以上,用户还是没有一个明确的指示操作是成功还是失败。后台要有一个程序,将对应的库存回滚释放掉,把redis的库存成功加回来。
try {
//如果orderService.createOrder操作长时间不返回,就会产生超时的问题,意味着永远无法执行后面的代码
//库存流水的状态永远都是1,
orderService.createOrder(userId,itemId,promoId,amount,stockLogId);
} catch (BusinessException e) {
e.printStackTrace();
//设置对应的stockLog为回滚状态
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
stockLogDO.setStatus(3);
stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO);
return LocalTransactionState.ROLLBACK_MESSAGE;
                }

库存售罄

下单之前初始化库存流水,执行库存流水init状态,将库存流水设置为1,然后再去完成对应的事务型消息机制下单异步扣减。这样就会出现一个问题:
如果说redis和数据库中都没有了库存,此时商品售罄,但是用户操作还是会触发initStockLog库存流水初始化。或者说1亿个人同时抢购一个商品,系统会执行1亿次库存流水初始化操作,但是只有极少数的用户可以秒杀成功,也就是有效库存流水。此时该商品已经被抢购完,也就是售罄,其余的大量库存流水都是无用的

解决方案

  • 加入一个库存售罄标识
  • 售罄后不去操作后续流程
  • 售罄后通知各系统售罄
  • 回补上新

首先加入一个库存售罄标识,这个标识并非是在扣减redis失败后得来的,而是通过一种机制来将库存售罄的标识打到一个对应的一个内存上,或者打到一个对应的缓存上。若对应的库存已经售罄了,那系统什么操作也不做,包括后台为用户生成库存流水操作。直接返回给前端下单失败,库存已经售罄。售罄后需要通过异步的rocketmq通知各系统,各个系统清除该商品信息的缓存。售罄之后商家可以回补库存,回补库存后系统将售罄的标志清除。

每次在redis中更新库存缓存的时候都需要维护一个售罄标识,如果redis中库存数量等于0,则打上有一个售罄标识


//每次在redis中更新库存缓存的时候都需要维护一个售罄标识,如果redis中库存数量等于0,则打上有一个售罄标识
        long result = redisTemplate.opsForValue().increment("promo_item_stock_"+itemId,amount.intValue()*-1);
        if(result > 0){
            //更新库存成功
            return true;
        }else if(result == 0){
            //打上一个库存已经售罄的标识
            redisTemplate.opsForValue().set("promo_item_stock_invalid_"+itemId,true);
            return true;

//每次下单之前都要检查一下商品的售罄标识,如果已经售罄,则直接抛出异常并且直接返回下单失败

//每次下单之前都要检查一下商品的售罄标识,如果已经售罄,则直接抛出异常并且直接返回下单失败
        //先判断库存时候已经售罄,若对应的售罄key存在,则直接返回下单失败
        if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
            throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH,"库存不足");
        }
       
        //下单之前初始化库存流水,加入库存流水init状态
        String stockLogId = itemService.initStockLog(itemId,amount);

总结

最初引入REDIS ,是为了增强查询性能,但会导致redis缓存和Mysql数据不一致,没办法保证一致性。
这个问题通过引入MQ,通过消息中间件解决。解决方式,redis扣减库存成功后,produce会向broker发消息,通过consumer接收消息来完成Mysql数据库级别的扣减。但又引来新的问题, 数据库扣减之后,创建订单失败,就导致回滚,redis回滚可以通过自己定义increaseStock方法。但是由于Mysql的更新是远程服务器实现,本地回滚是没有办法回滚到的,就会导致 “少卖”的现象,Mysql库存没了,但其实没有卖这么多。
这个问题通过TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter()这个类的aftercommit方法解决,每次都在commit成功之后再扣减Mysql数据库,但是使用这种方式 redis就无法回滚了,并且如果commit之后,异步消息发送失败数据库库存就没有减,会出现“超卖”现象。
为了解决 异步消息发送失败的问题,引入分布式事务,
创建订单的操作直接交给 消息队列,produce发送的消息处于prepare状态,对Consumer隐藏,只有提交CommitMessage之后,Consumer发现消息,完成Mysql级别的库存扣减。 后对于prepare很久的消息,引入库存流水让消息中间件知道订单究竟有没有创建成功。

问题1:
前端显示的库存和销量与数据库中的数据不一致问题,前端在一段时间内一直显示一个数字?

在ItemController.java中,对于‘/get’这个api,每次的itemModel都是首先用getCommonCache的方法来获得的,但是每次下单完了以后并没有对CommonCache中的数据进行更新,导致的一个问题是,前端显示的库存和Redis中的实际库存不一致(前端显示库存一直都是10,但实际Redis中已经是0了)。

        //设计二级缓存
        //先从本地缓存中取数据,若本地缓存中不存在,到redis中取,redis中不存在到数据库中取
        itemModel = (ItemModel) cacheService.getFromCommonCache("ittem_" + id);
        if(itemModel == null){
            //根据商品的id到redis内获取
            itemModel = (ItemModel)redisTemplate.opsForValue().get("item_" + id);
            //若redis内不存在对应的itemModel,则访问下游的service也就是数据库
            if(itemModel == null){
                itemModel = itemService.getItemById(id);
                //将访问到的数据同步到redis中
                redisTemplate.opsForValue().set("item_"+id,itemModel);
                //设置redis过期时间,10分钟
                redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
            }
            //填充本地缓存
            cacheService.setCommonCache("item_id"+id,itemModel);
        }
        ItemVO itemVO = convertVOFromModel(itemModel);

        return CommonReturnType.create(itemVO);
    }

问题2: 为什么要引入RocketMq事务型消息?
引入 RocketMQ 事务型消息,我的理解是将 “下订单” 和 “投递扣减库存消息” 置为同一个 “事务”,并且引入了 “库存流水” 来解决 “下订单” 在极端条件下的失败重试问题。
那么,“扣减库存消息” 投递到 RocketMQ 之后,是否存在消费端正常扣减了库存,但是未返回 CONSUMER_SUCCESS 而引起多次扣减库存呢?是否可以在 stock_log 表中多设置一个状态来解决此问题?

九、流量削峰技术引入

秒杀令牌

秒升令牌生成的方式:秒杀活动id+用户id+商品id三个维度为每个商品生成秒杀令牌。
存放在redis中

用户的风控策略前置到秒杀令牌当中:
也就是在秒杀令牌的发放时要检查一下用户的id以及商品的id和秒杀活动的id是否无误,无误之后则发放秒杀令牌。

PromoSeviceImpl中令牌发放

   @Override
    public String generateSecondKillToken(Integer promoId,Integer itemId, Integer userId) {		       

 //先判断库存时候已经售罄,若对应的售罄key存在,则直接返回下单失败
        if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
            return null;
        }
        PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
        //dataobject -> model
        PromoModel promoModel = convertFromDataObject(promoDO);
        if(promoModel == null){
            return null;
        }
        //判断当前时间是否秒杀活动即将开始或正在进行
        if(promoModel.getStartDate().isAfterNow()){
            promoModel.setStatus(1);
        }else if(promoModel.getEndDate().isBeforeNow()){
            promoModel.setStatus(3);
        }else{
            promoModel.setStatus(2);
        }
        //只有状态为2的时候才生成秒杀令牌,判断活动是否正在进行
        if(promoModel.getStatus().intValue() != 2){
            return null;
        }
        //判断item信息是否存在
        ItemModel itemModel = itemService.getItemByIdInCache(itemId);
        if(itemModel == null){
            return null;
        }
        //判断用户是否存在
        UserModel userModel = userService.getUserByIdInCache(userId);
        if(userModel == null){
            return null;
        }

        //生成token并且存入redis内并给一个5分钟的有效期
        String token = UUID.randomUUID().toString().replace("-","");
        //redis 缓存秒杀令牌
        redisTemplate.opsForValue().set("promo_token_"+promoId+"_userid_"+userId+"_itemid_",token);
        //设置令牌的过期时间
        redisTemplate.expire("promo_token_"+promoId+"_userid_"+userId+"_itemid_",5, TimeUnit.MINUTES);
        return token;
    }

问题:秒杀令牌只要活动一开始就无限制生成,影响系统的性能。假设说某个活动非常的火爆,有一亿用户来参加活动,系统就要生成一亿个秒杀的令牌,首先一亿个秒杀令牌的生成是非常损伤系统性能的,而且这一亿个秒杀令牌也不一定都能为用户实现商品的抢占先机。因此,对系统的消耗是非常大的。
因此—>引入了秒杀大闸来应对大量用户的瞬时请求

秒杀大闸

原理:

  • 依靠秒杀令牌的授权定制化发牌逻辑,做到大闸功能。
  • 根据秒杀商品初始库存颁发对应数量令牌,控制大闸流量。(令牌大闸初始化数量为库存数乘以5)
  • 用户风控策略前置到秒杀令牌发放中。
  • 库存售罄判断前置到秒杀令牌发放中

评论问题:如果有人恶意请求获取令牌,他拿了大部分令牌,导致正常用户走到获取大闸count那块代码就走不下去了,应该怎么防止出现这种事情?
:这个需要做防刷风控 可以限制每个ip可领取的令牌数

评论问题:秒杀大闸和令牌桶有什么区别,令牌桶师用来替代秒杀大闸的吗??
:大闸是根据库存的多少决定颁发多少令牌 令牌桶是限流算法 控制每秒进入的流量 两个维度的概念

评论问题:队列泄洪和限流的区别?
:这个是说限制并发20个线程,和tps是两个纬度的概念。泄洪是在限制线程的角度进行操作,而限流是tps级别的

出现的问题:

  • 浪涌流量涌入后系统无法应对,比如有一款爆款商品,有10万个,就会产生数十万个秒杀大闸,导致瞬间会有海量tps涌入进来
  • 多库存,多商品等令牌限制能力弱

秒杀令牌答发放和秒杀大闸没办法很好的控制洪峰流量的流量峰值,因此引入----->队列泄洪

队列泄洪

队列泄洪原理:

  • 排队有时候比并发更高效(例如redis单线程模型,innodb mutex key等),因为多线程并发的时候会出现锁的竞争和等待,大量的切换线程会消耗cpu的性能。
  • 依靠排队去限制并发流量
  • 依靠排队和下游拥塞窗口调整队列释放流量大小(也就是说在队列的消费端,不一定是消费完一个之后才取下一个,也可以一次性的取多个)
  • 支付宝银行网关队列举例:支付宝在双十一的时候会有大量的秒杀订单产生,支付宝需要将订单的付款渠道转接到用户对应的银行中,但是由于银行金额交易峰值是远远低于支付宝的,因此支付宝开发了一个支付宝银行网关队列来解决这个问题,将用户的支付请求队列化到支付宝内部的存储队列当中,然后依据下游的银行网关处理tps流量来进行队列泄洪。

队列泄洪的实现:

ordercontroller

private ExecutorService executorService;

 @PostConstruct
public void init(){
    //定义一个只有20个可工作线程的线程池
   executorService = Executors.newFixedThreadPool(20);
}
 //同步调用线程池的submit方法
//拥塞窗口为20的等待队列,用来队列化泄洪
 Future<Object> future = executorService.submit(new Callable<Object>() {
   @Override
   public Object call() throws Exception {
      //加入库存流水init状态
       String stockLogId = itemService.initStockLog(itemId,amount);
       //再去完成对应的下单事务型消息机制
       if(!mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId)){
           throw new BusinessException(EmBusinessError.UNKNOWN_ERROR,"下单失败");
          }
          return null;
        }
        });

        try {
            future.get();
        } catch (InterruptedException e) {
            throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
        } catch (ExecutionException e) {
            throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
        }
        return CommonReturnType.create(null);
}

本地or分布式

  • 本地:将队列维护在本地内存中
  • 分布式:将队列设置到外部的redis内

比如说我们有100台机器,假设每台机器设置20个队列,那我们的拥塞窗口就是2000,但是由于负载均衡的关系,很难保证每台机器都能够平均收到对应的createOrder的请求,那如果将这2000个排队请求放入redis中,每次让redis去实现以及去获取对应拥塞窗口设置的大小,这种就是分布式队列

本地队列的好处就是完全维护在内存当中的,因此其对应的没有网络请求的消耗,只要JVM不挂,应用是存活的,那本地队列的功能就不会失效。因此企业级开发应用还是推荐使用本地队列,本地队列的性能以及高可用性对应的应用性和广泛性。可以使用外部的分布式集中队列,当外部集中队列不可用时或者请求时间超时,可以采用降级的策略,切回本地的内存队列。

十、防刷限流技术

验证码

  • 包装秒杀令牌前置,需要验证码来错峰
  • 数学公式验证码生成器

限流

限流目的:

  • 流量远比想象的多
  • 系统活着比挂着要好
  • 宁愿让少数人使用,也不要让所有人不用

限流方案:

  • 限制用户的并发数量,同一时间固定只有n个线程可以访问该接口。在controller入口处维护一个count,count代表接口所能容纳的最大的线程数,每次有线程访问的时候都要检查一下count的数量,如果大于0就可以执行,并且减1,执行结束+1。
  • 令牌桶算法
  • 漏桶算法

tps主要是用来衡量对数据库产生写操作的容量指标,qps主要是衡量查询的指标

令牌桶算法

令牌桶算法原理:
在这里插入图片描述

令牌桶算法可以做到客户端一秒访问10个流量,下一秒就是下一个10个流量,限定某个时刻tps的最大值

漏桶算法

漏桶算法原理:
在这里插入图片描述
漏桶算法的目的用来平滑网络流量,没有办法应对突发流量

限流力度和范围

  • 接口维度:在接口维度引入令牌桶算法,限制tps
  • 总维度:所有接口的tps总和。

集群限流:依赖redis或其他中间件技术做统一计数器,往往会产生性能瓶颈
单机限流:负载均衡的前提下单机平均限流效果更好。

评论问题:限制并发和令牌桶的区别?限制并发是限制正在调用的接口的个数,令牌桶是针对qps,每秒可以最多允许多少请求进来。
:不同维度 限制并发:限制一共多少人同时干活 令牌桶:限制一秒中可以有几个人干活。

评论问题:今天面试中我提到项目中的限流方式,面试官马上就说我们的限流粒度太粗。然后,他提出要针对不同商家进行不同访问量的限流怎么实现?针对用户限流怎么实现?请问老师,这个要怎么回答?。
:将限流器带上用户或者商户id 每个id一个ratelimiter。还可以使用热淘汰,比如一个用户长时间没访问 就淘汰掉对应的缓存
评论问题:令牌桶:每秒提供10个令牌(流入10tps)
漏桶:每秒处理10滴水(处理10tps)
那令牌桶每秒就能处理10个tps吗?要是不能,那这10个请求怎么办呢?若是能,那跟漏桶不久差不多了?。
:处理10 令牌桶可以处理突发流量 漏桶是平滑流量处理

评论问题:老师,课程中说介绍的一些防黄牛的方法,是指前面提到的验证码方法?
:验证码是其中一块,还有借助设备指纹+token的方式做身份风险识别的

防刷

  • 排队,限流,令牌均只能控制总流量,无法控制黄牛流量

传统防刷

  • 限制一个会话(session_id,token)同一秒/分钟接口调用多少次:多会话接入绕开无效(黄牛开多个会话)
  • 限制一个ip同一秒钟/分钟 接口调用多少次:数量不好控制,容易误伤,黑客仿制ip

黄牛为什么那么难防

  • 模拟器作弊:模拟硬件设备,可修改设备信息
  • 设备牧场作弊:工作室里一批移动设备 人工作弊
  • 靠佣金吸引兼职人员刷单

—>引入设备指纹

  • 采集终端设备各项参数,启动应用时生成唯一设备指纹
  • 根据对应设备指纹的参数猜测出模拟器等可疑设备概率

凭证系统:
根据设备指纹下发凭证
关键业务链路上带上凭证并由业务系统到凭证服务器上验证
凭证服务器根据对应凭证所等价的设备指纹参数并根据实时行为风控系统判定对应凭证的可疑度分数
若分数低于某个数值则由业务系统返回固定错误码,拉起前端验证码验身,验身成功后加入凭证服务器对应分数

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值