SSM+Redis秒杀系统---高并发优化

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


高并发优化

1.高并发优化分析

高并发一定发生在详情页,详情页流程图如下:
在这里插入图片描述
红色部分表示可能会高并发的点,绿色部分表示没有影响。

红色的流程遇到的问题:

1.为什么要单独获取系统时间?

  • 用户在等待秒杀时一定会大量点击刷新页面。此时系统通过CDN(内容分发网络)加载页面,进入页面是不用访问系统的,所以拿不到系统时间。而除了静态页面外其他请求是对应到秒杀系统上的。
    在这里插入图片描述
    对于CDN(内容分发网络) 的理解:
    在这里插入图片描述

PS:CDN系统可以理解为bootstrap模板,这些模板分布在各个网络节点中,不需要访问系统。

需要知道的一点:获取系统时间是不用优化的(只需要10ns)

2.秒杀地址接口问题?分析如下:
在这里插入图片描述
有接口问题,我们就需要对接口进行优化:

一致性维护问题,使用Redis。
在这里插入图片描述
注意超时穿透和主动更新。

3.秒杀操作遇到的问题?分析如下:
在这里插入图片描述
其他方案分析:
在这里插入图片描述
这种方案的痛点:成本高
在这里插入图片描述
所以为什么我们不选择用myql来解决这个问题?:

其实mysql一条指令的QPS很高,并不低。(可执行4万多条1秒)

但是从事务行为上分析:
在这里插入图片描述
由于行级锁的存在,mysql命令被串行化了,会有大量的阻塞存在。

由此我们进行瓶颈分析:
在这里插入图片描述
GC:Java垃圾回收操作

java客户端对Mysql进行操作会碰到网络延迟和GC,会延迟很多时间,就算一次2ms,对系统性能也会带来极大的下降。同城机房明细如下:
在这里插入图片描述
异地机房还要再增加异地距离,明细如下:
在这里插入图片描述
分析更新行为:
在这里插入图片描述
那我们如何把客户端逻辑放到Mysql服务端?
在这里插入图片描述

由此我们可以得出优化总结:

  • 1.前端控制上:暴露接口和按钮防止重复
  • 2.动静态数据分离:CDN缓存(静态页面加载),后端缓存(判断时间,库存等)
  • 3.事务竞争优化:减少事务锁时间(通过java去控制会产生延迟)。

针对第三点的优化:将所有操作集中到一起,让mysql一次执行完

2.进行后端缓存优化(暴露接口优化)

优化位置:
在这里插入图片描述

1.导入redis相关依赖

在pom.xml下导入redis相关依赖。

  <!--添加redis依赖-->
    <dependency>
      <groupId>redis.clients</groupId>
      <artifactId>jedis</artifactId>
      <version>2.7.3</version>
    </dependency>

2.导入序列化机制

redis是一个没有内部序列化机制的数据库,我们需要导入序列化机制。

百度搜索序列化机制排行榜,可以看到:

Java自身携带的序列化接口效率已经被证明了并不高。(空间占据大,速度还慢)

所以我们使用google推出的prostuff序列化机制。(占空间压缩到很小,速度快)

在此导入prostuff序列化依赖。

 <!--prostuff序列化依赖-->
	<dependency>
      <groupId>com.dyuproject.protostuff</groupId>
      <artifactId>protostuff-core</artifactId>
      <version>1.0.8</version>
    </dependency>
    <dependency>
      <groupId>com.dyuproject.protostuff</groupId>
      <artifactId>protostuff-runtime</artifactId>
      <version>1.0.8</version>
    </dependency>

3.优化暴露秒杀接口操作。

分析(看注释):
在这里插入图片描述

暴露接口操作需要访问DAO,而缓存我们使用redis,所以在DAO包下建立cache包,创建RedisDao。

目的:(此处涉及到序列化)

1.通过Redis获得SecKill对象(getSecKill方法)。流程:get对象—>byte[]–>反序列化—>得到对象Object(SecKIll)

**2.发现缓存没有时我们put一个SecKill(putSecKill方法)。**流程:put对象—>序列化—>byte[]–>存储到Redis数据库中

package cn.codingxiaxw.dao.cache;

import cn.codingxiaxw.entity.Seckill;
import cn.codingxiaxw.utils.JedisUtils;
import com.dyuproject.protostuff.LinkedBuffer;
import com.dyuproject.protostuff.ProtostuffIOUtil;
import com.dyuproject.protostuff.runtime.RuntimeSchema;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.UUID;
import java.util.function.Function;

/**
 * Created by codingBoy on 17/2/17.
 */
public class RedisDao {
    private final JedisPool jedisPool;

    public RedisDao(String ip, int port) {
        jedisPool = new JedisPool(ip, port);
    }
    //通过反射获取SecKill对象的字节码,按一定格式存到schema中
    private RuntimeSchema<Seckill> schema = RuntimeSchema.createFrom(Seckill.class);

    public Seckill getSeckill(long seckillId) {
        return getSeckill(seckillId, null);
    }

    /**
     * 从redis获取信息
     *
     * @param seckillId id
     * @return 如果不存在,则返回null
     */
    public Seckill getSeckill(long seckillId, Jedis jedis) {
        boolean hasJedis = jedis != null;
        //redis操作逻辑
        try {
            if (!hasJedis) {
                jedis = jedisPool.getResource();
            }
            try {
                String key = getSeckillRedisKey(seckillId);
                //不实现内部序列化操作(继承seriesial接口),因为慢!
                //采用自定义序列化
                //protostuff:必须要 pojo对象.它的速度最快!java内置的序列化机制慢!
                byte[] bytes = jedis.get(key.getBytes());
                //缓存重获取到
                if (bytes != null) {
                    Seckill seckill = schema.newMessage();
                    ProtostuffIOUtil.mergeFrom(bytes, seckill, schema);
                    //seckill被反序列化

                    return seckill;
                }
            } finally {
                if (!hasJedis) {
                    jedis.close();
                }
            }
        } catch (Exception e) {

        }
        return null;
    }

    /**
     * 从缓存获取,如果没有,则从数据库获取
     * 会用到分布式锁
     *
     * @param seckillId     id
     * @param getDataFromDb 从数据库获取的方法
     * @return 返回商品信息
     */
    public Seckill getOrPutSeckill(long seckillId, Function<Long, Seckill> getDataFromDb) {

        String lockKey = "seckill:locks:getSeckill:" + seckillId;
        String lockRequestId = UUID.randomUUID().toString();
        Jedis jedis = jedisPool.getResource();

        try {
            // 循环直到获取到数据
            while (true) {
                Seckill seckill = getSeckill(seckillId, jedis);
                if (seckill != null) {
                    return seckill;
                }
                // 尝试获取锁。
                // 锁过期时间是防止程序突然崩溃来不及解锁,而造成其他线程不能获取锁的问题。过期时间是业务容忍最长时间。
                boolean getLock = JedisUtils.tryGetDistributedLock(jedis, lockKey, lockRequestId, 1000);
                if (getLock) {
                    // 获取到锁,从数据库拿数据, 然后存redis
                    seckill = getDataFromDb.apply(seckillId);
                    putSeckill(seckill, jedis);
                    return seckill;
                }

                // 获取不到锁,睡一下,等会再出发。sleep的时间需要斟酌,主要看业务处理速度
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignored) {
                }
            }
        } catch (Exception ignored) {
        } finally {
            // 无论如何,最后要去解锁
            JedisUtils.releaseDistributedLock(jedis, lockKey, lockRequestId);
            jedis.close();
        }
        return null;
    }

    /**
     * 根据id获取redis的key
     *
     * @param seckillId 商品id
     * @return redis的key
     */
    private String getSeckillRedisKey(long seckillId) {
        return "seckill:" + seckillId;
    }

    public String putSeckill(Seckill seckill) {
        return putSeckill(seckill, null);
    }

    public String putSeckill(Seckill seckill, Jedis jedis) {
        boolean hasJedis = jedis != null;
        try {
            if (!hasJedis) {
                jedis = jedisPool.getResource();
            }
            try {
                String key = getSeckillRedisKey(seckill.getSeckillId());
                byte[] bytes = ProtostuffIOUtil.toByteArray(seckill, schema,
                        LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));
                //超时缓存,1小时
                int timeout = 60 * 60;
                String result = jedis.setex(key.getBytes(), timeout, bytes);

                return result;
            } finally {
                if (!hasJedis) {
                    jedis.close();
                }
            }
        } catch (Exception e) {

        }

        return null;
    }
}

  
/*
redis.dao并不在mybatis的配置中
所以我们要引用spring-dao.xml的话,就需要在spring-dao.xml下添加redisDao配置。
*/
<!--redisDao-->
    <bean id="redisDao" class="cn.codingxiaxw.dao.cache.RedisDao">
        <constructor-arg index="0" value="localhost"/>
        <constructor-arg index="1" value="6379"/>
    </bean>

此步骤告诉测试文件。必须引用两个参数,一个是用户名,一个是端口号
如果是正式路径话我们就可以再建立一个propertis来存放这些信息。

配置好后,回到暴露接口server,完成业务逻辑测试.
在这里插入图片描述

3.进行并发优化(秒杀操作优化)

1.回顾事务执行过程

在这里插入图片描述
可以看到行级锁从上锁到释放,延迟了很多时间,所以我们的目的就是缩短延迟时间。

我们发现,先插入购买明细(重复的信息直接过滤),再执行减库存操作,可以在MySQL上减少一些重复秒杀操作。更新后的逻辑如下:
在这里插入图片描述
根据逻辑,在SecKillServerImpl中修改executeSeckill方法:

public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
            throws SeckillException, RepeatKillException, SeckillCloseException {

        if (md5 == null || !md5.equals(getMD5(seckillId))) {
            //秒杀数据被重写了
            throw new SeckillException("seckill data rewrite");
        }
        //执行秒杀逻辑:减库存+增加购买明细
        Date nowTime = new Date();

        try {

            //增加购买明细明细
            int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);
            //看是否该明细被重复插入,即用户是否重复秒杀
            if (insertCount <= 0) {
                throw new RepeatKillException("seckill repeated");
            } else {

                //减库存,热点商品竞争
                int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
                if (updateCount <= 0) {
                    //没有更新库存记录,说明秒杀结束 rollback
                    throw new SeckillCloseException("seckill is closed");
                } else {
                    //秒杀成功,得到成功插入的明细记录,并返回成功秒杀的信息 commit
                    SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);
                    return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled);
                }

            }


        } catch (SeckillCloseException e1) {
            throw e1;
        } catch (RepeatKillException e2) {
            throw e2;
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
            //所以编译期异常转化为运行期异常
            throw new SeckillException("seckill inner error :" + e.getMessage());
        }

    }

2.深度优化,将事务放在MySQL端执行。

经过上述分析,执行秒杀操作不采用服务端判断的方式,否则GC和延迟和行级锁叠加,效率太低

为此,使用MySQL存储过程(不会的需要学一会儿,直接B站搜索什么是存储过程即可),代码如下:

-- 秒杀执行的存储过程
delimiter $$ -- console 中的 ; 转化为 $$
-- 定义存储过程
-- 参数:in输入参数;out输出参数
-- row_count():返回上一条修改类型sql(delete,insert,update)的影响行数
-- row_count():0 未修改数据;>0 修改的行数;<0 sql错误/未执行修改sql。
create  procedure `seckill`.`execute_seckill`
    (in v_seckill_id bigint, in v_phone bigint,
    in v_kill_time timestamp, out r_result int)
    begin
        declare insert_count int default 0;
        start transaction;
        insert ignore into success_killed
            (seckill_id,user_phone,create_time)
            values (v_seckill_id,v_phone,v_kill_time);
        select row_count() into insert_count;
        if (insert_count = 0) then
            rollback ;
            set r_result = -1;
        elseif (insert_count < 0) then
            rollback;
            set r_result = -2;
        else
            update seckill
                set number = number - 1
            where seckill_id = v_seckill_id
                and end_time > v_kill_time
                and start_time < v_kill_time
            and number > 0;
            select row_count() into insert_count;
            if(insert_count = 0) then
                rollback ;
                set r_result = 0;
            elseif(insert_count < 0) then
                rollback ;
                set r_result = -2;
            else
                commit;
                set r_result = 1;
            end if ;
        end if ;
     end;
$$
-- 存储过程定义结束

delimiter ;

set @r_result = -3;
-- 执行存储过程
call execute_seckill(1000,13314455555,now(),@r_result);
-- 获取结果
select @r_result;

-- 存储过程
-- 1.存储过程优化:事务行级锁持有的时间
-- 2.不要过度依赖存储过程
-- 3.简单的逻辑,可以应用存储过程
-- 4.QPS:一个秒杀单6000/qps

定义完存储过程,接下来编写业务层代码

在SecKillService接口中定义executeSecKillProdure()方法,在缓存中执行存储过程

/**
 * 执行秒杀操作 by 存储过程
 * @param seckillId
 * @param userPhone
 * @param md5
 * @return
 * @throws SeckillException
 * @throws RepeatKillException
 * @throws SeckillClosedException
 */
SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5);

再在SecKill.Dao中定义方法killByProcedure

/**
 * 使用存储过程执行秒杀
 * @param paramMap
 */
void killByProcedure(Map<String,Object> paramMap);

在SecKill.xml中调用存储过程(Mybatis体现)

<!--mybatis调用存储过程-->
<select id="killByProcedure" statementType="CALLABLE">
    call execute_seckill(
        #{seckillId,jdbcType=BIGINT,mode=IN},
        #{phone,jdbcType=BIGINT,mode=IN},
        #{killTime,jdbcType=TIMESTAMP,mode=IN},
        #{result,jdbcType=INTEGER,mode=OUT}
        )
</select>

然后编写Service的实现:

@Override
public SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5) {
    if(md5 == null || !md5.equals(getMD5(seckillId))){
        return new SeckillExecution(seckillId,SeckillStatEnum.DATA_REWRITE);
    }
    Date killTime = new Date();
    Map<String,Object> map = new HashMap<>();
    map.put("seckillId",seckillId);
    map.put("phone",userPhone);
    map.put("killTime",killTime);
    map.put("result",null);
    //执行存储过,result被赋值
    try{
        seckillDao.killByProcedure(map);
        //获取result,MapUtils需要在pom.xml中引入commons-collections依赖
        int result = MapUtils.getInteger(map,"result",-2);
        if(result == 1){
            SuccessKilled sk = successKilledDao.queryByIdWithSeckill(seckillId,userPhone);
            return new SeckillExecution(seckillId,SeckillStatEnum.SUCCESS,sk);
        } else {
            return new SeckillExecution(seckillId,SeckillStatEnum.stateOf(result));
        }
    } catch (Exception e){
        logger.error(e.getMessage(),e);
        return new SeckillExecution(seckillId,SeckillStatEnum.INNER_ERROR);
    }
}

注意使用MapUtils时需要引入依赖:

<dependency>
  <groupId>commons-collections</groupId>
  <artifactId>commons-collections</artifactId>
  <version>3.2.2</version>
</dependency>

编写测试:

@Test
public void executeSeckillProcedure(){
    long seckillId = 1000;
    long phone = 13315566780L;
    Exposer exposer = seckillService.exportSeckillUrl(seckillId);
    if(exposer.isExposed()){
        String md5 = exposer.getMd5();
        SeckillExecution seckillExecution = seckillService.executeSeckillProcedure(seckillId, phone, md5);
        logger.info(seckillExecution.getStateInfo());
    }
}

至此项目结束,完结撒花~


问题

1、如何防刷?

IP限流+限制点击次数
限流手段
1.使用数学公式验证码
描述:点击秒杀前,先让用户输入数学公式验证码,验证正确才能进行秒杀。
好处:

  • 防止恶意的机器人和爬虫
  • 分散用户的请求

实现:

  • 前端通过把商品id作为参数调用服务端创建验证码接口
  • 服务端根据前端传过来的商品id和用户id生成验证码,并将商品id+用户id作为key,生成的验证码作为value存入redis,同时将生成的验证码输入图片写入imageIO让前端展示。
  • 将用户输入的验证码与根据商品id+用户id从redis查询到的验证码对比,相同就返回验证成功,进入秒杀;不同或从redis查询的验证码为空都返回验证失败,刷新验证码重试

2.禁止重复提交:用户提交之后按钮置灰,禁止重复提交

2、压测没有?用什么压测?什么情况?QPS是多少?

有,使用jmeter对秒杀项目进行压力测试,具体操作例子链接:https://blog.csdn.net/weixin_42902577/article/details/106426650

在jmeter中创建一个线程组,设置1s内100个线程的并发量,让这100个线程去抢60个名额
经过测试,在并发量骤增的情况下,整合redis作缓存的结果没有发生超量的现象。
只使用mybatis作缓存时发现一共插入了96条结果记录,而课程容量最大只有60,所以出现了超量现象。

3、系统瓶颈在哪?如何查找,如何再优化?

瓶颈分析:在update减库存—insert购买明细—commit/rollback的过程中会出现网络延迟或GC。
为此分别进行了后端缓存优化和秒杀操作优化,目的都是为了降低数据库的操作次数,减少网络延迟。
优化步骤:
首先改变顺序,调整为insert购买明细—update减库存—commit/rollback,可以减少一次事务提交,也就把延迟量减低了一倍。
再通过Redis缓存秒杀列表的信息,在Redis里判断用户是否重复秒杀,降低对数据库的访问量。
最后使用在MySQL服务端使用存储过程:使整个事务在MySQL端完成,省去了客户端重复提交事务所造成的延迟

4、针对单个商品,有10w+的库存,怎么优化Redis?

建立多个相互独立的Redis集群。比如10w个库存,10个Redis集群,一个集群分1w。
这种方法存在的问题:某个集群卖完了,但实际其他集群还有?
答:可以采用放1.3倍N的思想,每个集群放2W或3W,再配合nginx的最少访问负载策略,能很大程度解决这个问题。

5、使用客户端时间来进行倒计时展示是否会有安全问题?

如果说每一秒都要请求一次服务器那么是否会对服务器的性能有影响?回答是没有影响,经过查询检测,系统获取时间只需要10ns左右。通过配置对redis进行一个通用的json序列化配置,然后对日期格式进行格式化存取配置。速度很快

6、为什么要进行MD5加密?

我们用MD5加密的方式对秒杀地址(seckill_id)进行加密,暴露给前端用户。当用户执行秒杀的时候传递seckill_id和MD5,程序拿着seckill_id根据设置的值计算MD5,如果与传递的md5不一致,则表示地址被篡改了。

7、为什么要进行秒杀接口暴露的控制或者说进行秒杀接口的隐藏?

现实中有的用户回通过浏览器插件提前知道秒杀接口,填入参数和地址来实现自动秒杀,这对于其他用户来说是不公平的,我们也不希望看到这种情况。所以我们可以控制让用户在没有到秒杀时间的时候不能获取到秒杀地址,只返回秒杀的开始时间。当到秒杀时间的时候才返回秒杀地址。
即seckill_id以及根据seckill_id和salt加密的MD5,前端再次拿着seckill_id和MD5才能执行秒杀。假如用户在秒杀开始前猜测到秒杀地址seckill_id去请求秒杀,也是不会成功的,因为它拿不到需要验证的MD5。这里的MD5相当于是用户进行秒杀的凭证

8、除了你项目里面的优化,你还有什么优化策略吗?

有的,当用户量非常大的时候,拦截流量后的请求访问量还是非常大,此时仍需进一步优化。

  1. 业务分离:将秒杀业务系统和其他业务分离,单独放在高配服务器上,可以集中资源对访问请求抗压。——应用的拆分

  2. 采用消息队列缓存请求:将大流量请求写到消息队列缓存,利用服务器根据自己的处理能力主动到消息缓存队列中抓取任务处理请求,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。

  3. 利用缓存应对读请求:对于读多写少业务,大部分请求是查询请求,所以可以读写分离,利用缓存分担数据库压力。

  4. 利用缓存应对写请求:缓存也是可以应对写请求的,可把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。

可以将缓存和消息中间件 组合起来,缓存系统负责接收记录用户请求,消息中间件负责将缓存中的请求同步到数据库。

这时候项目的最终方案为:本地标记 + redis预处理 + RabbitMQ异步下单 + 客户端轮询

描述:通过三级缓冲保护,1、本地标记 2、redis预处理 3、RabbitMQ异步下单,最后才会访问数据库,这样做是为了最大力度减少对数据库的访问。

实现:

在秒杀阶段使用本地标记对用户秒杀过的商品做标记,若被标记过直接返回重复秒杀,未被标记才查询redis,通过本地标记来减少对redis的访问
抢购开始前,将商品和库存数据同步到redis中,所有的抢购操作都在redis中进行处理,通过Redis预减少库存减少数据库访问
为了保护系统不受高流量的冲击而导致系统崩溃的问题,使用RabbitMQ用异步队列处理下单,实际做了一层缓冲保护,做了一个窗口模型,窗口模型会实时的刷新用户秒杀的状态。
client端用js轮询一个接口,用来获取处理状态

9、 如何解决库存的超卖问题?

卖超原因:

  • 一个用户同时发出了多个请求,如果库存足够,没加限制,用户就可以下多个订单。
  • 减库存的sql上没有加库存数量的判断,并发的时候也会导致把库存减成负数。

解决办法:

  • 在后端的秒杀表中,对user_id和goods_id加唯一索引,确保一个用户对一个商品绝对不会生成两个订单。
  • 我们的减库存的sql上应该加上库存数量的判断

数据库自身是有行级锁的,每次减库存的时候判断count>0,它实际上是串行的执行update的,因此绝对不会卖超!。

UPDATE seckill
        SET number = number-1
        WHERE seckill_id=#{seckillId}
        AND start_time <#{killTime}
        AND end_time >= #{killTime}
        AND number > 0;

10、如何解决少卖问题—Redis预减成功而DB扣库存失败?

前面的方案中会出现一个少卖的问题。Redis在预减库存的时候,在初始化的时候就放置库存的大小,redis的原子减操作保证了多少库存就会减多少,也就会在消息队列中放多少。

现在考虑两种情况:

1)数据库那边出现非库存原因比如网络等造成减库存失败,而这时redis已经减了。

2)万一一个用户发出多个请求,而且这些请求恰巧比别的请求更早到达服务器,如果库存足够,redis就会减多次,redis提前进入卖空状态,并拒绝。不过这两种情况出现的概率都是非常低的。

两种情况都会出现少卖的问题,实际上也是缓存和数据库出现不一致的问题!

但是我们不是非得解决不一致的问题,本身使用缓存就难以保证强一致性:

在redis中设置库存比真实库存多一些就行。

11、 秒杀过程中怎么保证redis缓存和数据库的一致性?

在其他一般读大于写的场景,一般处理的原则是:缓存只做失效,不做更新。

采用Cache-Aside pattern:

失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

更新:先把数据存到数据库中,成功后,再让缓存失效

12、 Redis中的库存如何与DB中的库存保持一致?

Redis中的数量不是库存,它的作用仅仅时候只是为了阻挡多余的请求透传到db,起到一个保护DB的作用。因为秒杀商品的数量是有限的,比如只有10个,让1万个请求去访问DB是没有意义的,因为最多只有10个请求会下单成功,剩余的9990个请求都是无效的,是可以不用去访问db而直接失败的。

因此,这是一个伪问题,我们是不需要保持一致的。

13、 一个秒杀系统,500用户同时登陆访问服务器A,服务器B如何快速利用登录名(假设是电话号码或者邮箱)做其他查询?

主从复制,读写分离

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值