项目开发:秒杀系统模块开发记录

秒杀系统简单开发

参考资料:编程不良人

视频教程:https://www.bilibili.com/video/BV13a4y1t7Wh

参考内容:https://github.com/engureguo/miaosha

项目源码:https://gitee.com/gengkunyuan/second-kill-case

系统简单开发

搭建环境

导入依赖:

 <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.2.6</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
</dependencies>

配置文件:

server.port=8999
server.servlet.context-path=/ms

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/ms
spring.datasource.username=root
spring.datasource.password=88888888

mybatis.mapper-locations=classpath:com/lut/mapper/*.xml
mybatis.type-aliases-package=com.lut.entity

logging.level.root=info
logging.level.com.lut.dao=debug

建立数据表和数据库表:

DROP TABLE IF EXISTS `order`;
CREATE TABLE `order`  (
  `id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `sid` int(11) NULL DEFAULT NULL,
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `createDate` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `order` VALUES ('001', 1, 'zhangsan', '2021-11-12 00:00:00');

DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '名称',
  `count` int(11) NOT NULL COMMENT '库存',
  `sale` int(11) NOT NULL COMMENT '已售',
  `version` int(11) NOT NULL COMMENT '乐观锁,版本号',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `stock` VALUES (1, 'IPhoneX', 100, 0, 0);

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `user` VALUES (1, 'admin', '123456');

SET FOREIGN_KEY_CHECKS = 1;

存在问题

商品超卖现象,使用 ApacheJMeter 做压力测试,当多个请求过来时,会创建大量的订单

悲观锁解决方案

悲观锁:synchronized锁整个调用的方法

    @GetMapping("/kill")
    public synchronized String kill(Integer id){
        System.out.println("商品ID为:"+id);
        try {
            //根据秒杀的商品ID,去调用秒杀业务
            String orderid=orderService.kill(id);
            return "秒杀成功,订单ID为: "+orderid;
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

坑:悲观锁 和 事务作用的范围

起因:使用悲观锁之后,做测试还会出现 “超卖”的现象。

注意!!!

错误说法:业务层加同步代码块

悲观锁大坑!多提交的问题: Transactional和synchronized同时使用初始并发问题。 事务同步范围要比线程同步范围大。 synchronized代码块执行是在事务之内执行的,可以推断在代码块执行完时,事务还未提交,因此其它线程进入synchronized代码块后,读取的数据库数据不是最新的。

解决方法: synchronized同步范围大于事务同步范围,在 业务层kill方法之外进行同步,保证释放锁的时候事务已经提交

改进:↓↓↓

悲观锁:synchronized锁实际调用的方法

    @GetMapping("/kill")
    public String kill(Integer id){
        System.out.println("商品ID为:"+id);
        try {
            synchronized (this){
                //根据秒杀的商品ID,去调用秒杀业务
                String orderid=orderService.kill(id);
                return "秒杀成功,订单ID为: "+orderid;
            }
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

带来的问题:

1、造成线程阻塞,系统吞吐量下降

2、给用户带来的体验不好

乐观锁解决方案

说明:使用乐观锁解决商品的超卖问题,实际上是把主要防止超卖问题交给数据库,解决利用数据库中定义的version字段,以及数据库中的事务,实现在并发情况下商品的超卖问题

数据库层面上过滤掉某些请求

简单修改代码

@Service
@Transactional
public class OrderServiceImpl implements OrderService{

    @Autowired
    private StockDao stockDao;

    @Autowired
    private OrderDao orderDao;

    @Override
    public String kill(Integer id) {
        Stock stock=checkStock(id);
        updateSale(stock);
        return createOrder(stock);
    }

    //校验库存
    private Stock checkStock(Integer id){
        Stock stock = stockDao.checkStock(id);
        if (stock.getSale().equals(stock.getCount())){
            throw new RuntimeException("库存不足!!!");
        }
        return stock;
    }

    //扣除库存
    private void updateSale(Stock stock){
        stock.setSale(stock.getSale()+1);
        stockDao.updateSale(stock);
    }

    //创建订单
    private String createOrder(Stock stock){
        Order order=new Order();
        order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
        order.setId(UUID.randomUUID().toString().substring(0,8));
        orderDao.createOrder(order);
        return order.getId();
    }
}

Dao层:

@Component
public interface StockDao {
    //检查库存
    Stock checkStock(Integer id);
    //扣除库存
    void updateSale(Stock stock);
    //扣除库存,使用版本号,返回值:数据库操作影响的条数
    int updateSaleWithVersion(Stock stock);
}

Service层:

    //扣除库存,使用版本号,返回值:数据库操作影响的条数
    private void updateSaleWithVersion(Stock stock){
        int updaterows=stockDao.updateSaleWithVersion(stock);
        if (updaterows==0){
            throw new RuntimeException("抢购失败,请重试");
        }
    }

Mapper文件:

<!--    扣除库存,使用版本号,返回值:数据库操作影响的条数-->
    <update id="updateSaleWithVersion" parameterType="Stock">
        update stock set
            sale=sale+1,version=version+1
        where
            id=#{id} and version=#{version}
    </update>

接口限流方案

限流:是对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢和宕机

在面临高并发的抢购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大的压力。大量的请求抢购成功时需要调用下单的接口,过多的请求打到数据库会对系统的稳定性造成影响.

接口限流解决方法

常用的限流算法有 令牌桶漏桶(漏斗算法),而 Google 开源项目 Guava 中的 RateLimiter 使用的就是令牌桶控制算法。在开发高并发系统时有三把利器用来保护系统:缓存降级限流

  • 缓存:缓存的目的是提升系统访问速度和增大系统处理容量
  • 降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行
  • 限流:限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。

漏斗算法和令牌桶算法

image-20211112094008159

  • 漏斗算法:漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
  • 令牌桶算法:最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。

漏斗算法:平时不常用,因为超过桶的容量的请求会直接被抛弃

令牌桶算法:拿到令牌的请求去执行业务,拿不到令牌的请求可以一直等待直到获取令牌,或在一定的时间内尝试获取令牌,超时后再抛弃

令牌桶算法简单使用

导入依赖:

<dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
      <version>30.1.1-jre</version>
</dependency>
    private RateLimiter rateLimiter= RateLimiter.create(30);

    @GetMapping("/testLimiter")
    public String TestRateLimiter(Integer id){

        //方案1:没有获取到token请求一直等待获取到token令牌
        //log.info("等待时间: "+rateLimiter.acquire());

        //方案2:设置等待时间,如果指定时间内没有获取令牌,就抛弃
        if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
            System.out.println("当前请求被限流,直接抛弃,无法调用后序逻辑");
            return "当前人数过多,请重试!";
        }

        System.out.println("处理业务.....");
        return "抢购成功";
    }

改造秒杀项目

多个请求进来之后,部分请求被限流,导致数据库中的商品大部分被卖出,有一小部分商品不能卖出,属于正常现象,这些商品可用于后面的退换货

好处:库存备份

增大销售数量:1.更多的请求 2.增大超时时间 3.放行的请求增多

image-20211112103046455

    private RateLimiter rateLimiter= RateLimiter.create(30);


    @GetMapping("killwithtoken")
    public String killWithToken(Integer id){
        System.out.println("秒杀商品的ID=" + id);
        if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
            log.info("抢购失败,当前活动过于火爆,请稍后重试");
            return "抢购失败,当前活动过于火爆,请稍后重试";
        }
        try {
            //根据商品ID去秒杀商品
            String orderid=orderService.kill(id);
            return "秒杀成功,订单ID为:"+orderid;
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

隐藏秒杀接口

在前几次课程中,我们完成了防止超卖商品和抢购接口的限流,已经能够防止大流量把我们的服务器直接搞炸,这篇文章中,我们要开始关心一些细节问题。我们现在设计的系统还有一些问题:

1.我们应该在一定的时间内执行秒杀处理,不能再任意时间都接受秒杀请求。如何加入时间验证?

2.对于稍微懂点电脑的,又会动歪脑筋的人来说开始通过抓包方式获取我们的接口地址。然后通过脚本进行抢购怎么办?

3.秒杀开始之后如何限制单个用户的请求频率,即单位时间内限制访问次数?

这个章节主要讲解秒杀系统中,关于抢购(下单)接口相关的单用户防刷措施,主要说几块内容:

  • 限时抢购

  • 抢购接口隐藏(避免F12、抓包来获取接口)

  • 单用户限制频率(单位时间内限制访问次数)

限时抢购

引入Redis配置:

<!--        redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.5.1</version>
        </dependency>
spring.redis.port=6379
spring.redis.host=localhost
spring.redis.database=0

在项目中使用:

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    //秒杀的具体实现
    @Override
    public String kill(Integer id) {
        if (!stringRedisTemplate.hasKey("kill"+id)){
            throw new RuntimeException("当前商品的抢购活动已经结束了");
        }

        Stock stock=checkStock(id);
        updateSaleWithVersion(stock);
        return createOrder(stock);
    }

在Redis-cli中设置 key 的存活时间:

set kill1 1 EX 10  
//key: kill+商品ID   value:1    EX 设置存活时间为 10s

抢购接口隐蔽

对于稍微懂点电脑的,又会动歪脑筋的人来说,点击F12打开浏览器的控制台,就能在点击抢购按钮后,获取我们抢购接口的链接。(手机APP等其他客户端可以抓包来拿到)一旦坏蛋拿到了抢购的链接,只要稍微写点爬虫代码,模拟一个抢购请求,就可以不通过点击下单按钮,直接在代码中请求我们的接口,完成下单。所以就有了成千上万的菇羊毛军团,写一些脚本抢购各种秒杀商品。

他们只需要在抢购时刻的000毫秒,开始不间断发起大量请求,觉得比大家在APP上点抢购按钮要快,毕竟人的速度又极限,更别说APP说不定还要经过几层前端验证才会真正发出请求。

image-20211112150926094

image-20211112113932811

添加用户表:

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;


INSERT INTO `user` VALUES (1, 'admin', '123456');
生成MD5的功能

控制器:生成MD5

    @Autowired
    private OrderService orderService;
    
	//根据商品ID和用户ID生成一个MD5
    @RequestMapping("md5")
    public String getMd5(Integer id,Integer userid){
        String md5;
        try {
            md5=orderService.getMd5(id,userid);
        }catch (Exception e){
            e.printStackTrace();
            return "获取MD5失败:"+e.getMessage();
        }
        return "获取到的MD5为:"+md5;
    }

ServiceImpl:

    @Override
    public String getMd5(Integer goodid, Integer userid) {
        //1.检验用户的合法性
        User user=userDao.findById(userid);
        if (user==null){
            throw new RuntimeException("用户信息不存在!");
        }
        log.info("用户信息:"+ user);

        //2.检验商品的合法性
        Stock stock = stockDao.checkStock(goodid);
        if (stock==null){
            throw new RuntimeException("商品信息不存在!");
        }
        log.info("商品信息:"+stock);

        //3.生成 hashkey:用户ID + 商品ID
        String hashkey="KEY_"+userid+"_"+goodid;

        //4.生成MD5,随机盐:#$%587!@SOLT ,并放入Redis
        String md5Str=DigestUtils.md5DigestAsHex((userid+goodid+"#$%587!@SOLT").getBytes());
        stringRedisTemplate.opsForValue().set(hashkey,md5Str,60, TimeUnit.SECONDS);
        return md5Str;
    }

Dao层:

@Mapper
public interface UserDao {
    User findById(Integer id);
}

@Mapper
public interface StockDao {
    //检查库存
    Stock checkStock(Integer id);
    //扣除库存
    void updateSale(Stock stock);
    //扣除库存,使用版本号,返回值:数据库操作影响的条数
    int updateSaleWithVersion(Stock stock);
}

Mapper文件:UserDaoMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lut.dao.UserDao">

   <select id="findById" parameterType="Integer" resultType="User">
       select id,name,password from user where id=#{id}
   </select>

</mapper>
改造秒杀接口

Controller:现在需要传递三个参数:商品ID,用户ID,MD5字符串

    @Autowired
    private OrderService orderService;

    private RateLimiter rateLimiter= RateLimiter.create(30);

    //乐观锁 + 令牌桶 + MD5
    @GetMapping("killWithTokenMd5")
    public String killWithTokenMD5(Integer stockid,Integer userId,String md5Str){
        System.out.println("秒杀商品的ID=" + stockid);
        //令牌桶限流
        if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
            log.info("抢购失败,当前活动过于火爆,请稍后重试");
            return "抢购失败,当前活动过于火爆,请稍后重试";
        }
        try {
            //根据商品ID去秒杀商品,在这里先 Redis中判断 MD5
            String orderid=orderService.kill(stockid,userId,md5Str);
            return "秒杀成功,订单ID为:"+orderid;
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

ServiceImpl:秒杀实现,传递三个参数

    @Override
    public String kill(Integer stockid, Integer userId, String md5Str) {
        //校验Redis中的秒杀商品是否超时
//        if (!stringRedisTemplate.hasKey("kill"+stockid)){
//            throw new RuntimeException("当前商品的抢购活动已经结束了");
//        }

        //验证传递过来的签名是否有效
        String hashkey="KEY_"+userId+"_"+stockid;
        String redisStr=stringRedisTemplate.opsForValue().get(hashkey);

        if (redisStr==null){
            throw new RuntimeException("没有携带验证签名,请求不合法");
        }
        if (!redisStr.equals(md5Str)){
            throw new RuntimeException("当前请求数据不合法,请重试!");
        }

        Stock stock=checkStock(stockid);
        updateSaleWithVersion(stock);
        return createOrder(stock);
    }
访问测试

先访问MD5接口,获取MD5字符串,并放到Redis中:

http://localhost:8999/ms/stock/md5?id=1&userid=1

再访问订单接口,传递参数:商品ID、用户ID、MD5字符串

http://localhost:8999/ms/stock/killWithTokenMd5?stockid=1&userId=1&md5Str=7177028f5b50bd46a448263d4cff1183
单用户限制频率

假设我们做好了接口隐藏,但是像我上面说的,总有无聊的人会写一个复杂的脚本,先请求hash值,再立刻请求购买,如果你的app下单按钮做的很差,大家都要开抢后0.5秒才能请求成功,那可能会让脚本依然能够在大家前面抢购成功。

我们需要在做一个额外的措施,来限制单个用户的抢购频率。

其实很简单的就能想到用redis给每个用户做访问统计,甚至是带上商品id,对单个商品做访问统计,这都是可行的。我们先实现一个对用户的访问频率限制,我们在用户申请下单时,检查用户的访问次数,超过访问次数,则不让他下单!

image-20211112141815223

修改Controller:

    //乐观锁 + 令牌桶 + MD5(接口隐藏) + 单用户访问频率限制
    @GetMapping("/killWithTokenMD5Count")
    public String killWithTokenMD5Count(Integer stockid,Integer userId,String md5Str){
        System.out.println("秒杀商品的ID=" + stockid);

        //1.令牌桶限流
        if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
            log.info("抢购失败,当前活动过于火爆,请稍后重试");
            return "抢购失败,当前活动过于火爆,请稍后重试";
        }
        try {

            //2.加入单用户限制调用频率
            //2.1 Redis中加计数
            int count=userService.saveUserCount(userId);
            log.info("用户"+userId+"截止此次的访问次数为: "+count);
            //2.2 判断是否超过次数
            boolean isBanned=userService.getUserCount(userId);
            if (isBanned){
                return "您的点击过于频繁,请稍后再试!";
            }

            //3.根据商品ID去秒杀商品,在这里先 Redis中判断 MD5
            String orderid=orderService.kill(stockid,userId,md5Str);
            return "秒杀成功,订单ID为:"+orderid;
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

UserService:

public interface UserService {
    boolean getUserCount(Integer userId);
    int saveUserCount(Integer userId);
}

UserServiceImpl:在Redis中存放访问次数

/**
 * @Author: GengKY
 * @Date: 2021/11/11 18:14
 */
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService{

    @Autowired
    private StockDao stockDao;

    @Autowired
    private OrderDao orderDao;

    @Autowired
    private UserDao userDao;

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Override
    public String kill(Integer stockid, Integer userId, String md5Str) {
        //校验Redis中的秒杀商品是否超时
//        if (!stringRedisTemplate.hasKey("kill"+stockid)){
//            throw new RuntimeException("当前商品的抢购活动已经结束了");
//        }

        //验证传递过来的签名是否有效
        String hashkey="KEY_"+userId+"_"+stockid;
        String redisStr=stringRedisTemplate.opsForValue().get(hashkey);

        if (redisStr==null){
            throw new RuntimeException("没有携带验证签名,请求不合法");
        }
        if (!redisStr.equals(md5Str)){
            throw new RuntimeException("当前请求数据不合法,请重试!");
        }

        Stock stock=checkStock(stockid);
        updateSaleWithVersion(stock);
        return createOrder(stock);
    }

    //秒杀的具体实现
    @Override
    public String kill(Integer id) {
        if (!stringRedisTemplate.hasKey("kill"+id)){
            throw new RuntimeException("当前商品的抢购活动已经结束了");
        }

        Stock stock=checkStock(id);
        updateSaleWithVersion(stock);
        return createOrder(stock);
    }

    @Override
    public String getMd5(Integer goodid, Integer userid) {
        //1.检验用户的合法性
        User user=userDao.findById(userid);
        if (user==null){
            throw new RuntimeException("用户信息不存在!");
        }
        log.info("用户信息:"+ user);

        //2.检验商品的合法性
        Stock stock = stockDao.checkStock(goodid);
        if (stock==null){
            throw new RuntimeException("商品信息不存在!");
        }
        log.info("商品信息:"+stock);

        //3.生成 hashkey:用户ID + 商品ID
        String hashkey="KEY_"+userid+"_"+goodid;

        //4.生成MD5,随机盐:#$%587!@SOLT ,并放入Redis
        String md5Str=DigestUtils.md5DigestAsHex((userid+goodid+"#$%587!@SOLT").getBytes());
        stringRedisTemplate.opsForValue().set(hashkey,md5Str,60, TimeUnit.SECONDS);
        return md5Str;
    }



    //校验库存
    private Stock checkStock(Integer id){
        Stock stock = stockDao.checkStock(id);
        if (stock.getSale().equals(stock.getCount())){
            throw new RuntimeException("库存不足!!!");
        }
        return stock;
    }

    //扣除库存
    private void updateSale(Stock stock){
        stock.setSale(stock.getSale()+1);
        stockDao.updateSale(stock);
    }

    //扣除库存,使用版本号,返回值:数据库操作影响的条数
    private void updateSaleWithVersion(Stock stock){
        int updaterows=stockDao.updateSaleWithVersion(stock);
        if (updaterows==0){
            throw new RuntimeException("抢购失败,请重试");
        }
    }

    //创建订单
    private String createOrder(Stock stock){
        Order order=new Order();
        order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
        order.setId(UUID.randomUUID().toString().substring(0,8));
        orderDao.createOrder(order);
        return order.getId();
    }
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值