redis实战


这人笔记很好

这人笔记也很好

0. noSQL 非结构化数据库 基础知识

  1. 无结构
  2. 没有关联,有重复数据
  3. 没有固定格式的查询语句
  4. 不满足ACID,不支持事务
    在这里插入图片描述

1 redis(Remote Dictionary Server)

1.1 特点

在这里插入图片描述
前台启动转为后台启动(修改配置文件):【也可配置为开机自启,参考黑马】
在这里插入图片描述
然后执行:redis-server redis.conf

1.2 数据类型

在这里插入图片描述

1.3 常用指令

在这里插入图片描述
关于有效期:
在这里插入图片描述
关于hash:
在这里插入图片描述

2. 使用spring集成的redis

@SpringBootTest
class RedisDemoApplicationTests {

    @Autowired
    private RedisTemplate redisTemplate;
    @Test
    void testString() {
        redisTemplate.opsForValue().set("name","google");
        Object name = redisTemplate.opsForValue().get("name");
        System.out.println(name);
    }

}

执行上述代码通过,但是redis中并不能查到name字段为Google的记录:
在这里插入图片描述
原因:我们加入的name,被spring默认当成对象,序列化后塞进redis。解决方案:对key进行String序列化,对value进行json序列化(自定义序列化)。通过配置类实现:

@Configuration

public class RedisConfig {
    @Bean
    public RedisTemplate<String,Object> redisTemplate( RedisConnectionFactory factory){
        RedisTemplate<String,Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // 设置key的序列化方式
        template.setKeySerializer(RedisSerializer.string());
        // 设置value的序列化方式
        template.setValueSerializer(RedisSerializer.json());
        // 设置hash的key的序列化方式
        template.setHashKeySerializer(RedisSerializer.string());
        // 设置hash的value的序列化方式
        template.setHashValueSerializer(RedisSerializer.json());

        template.afterPropertiesSet();
        return template;
    }
}

在这里插入图片描述
但是这个过程,value是类的时候,把类的字节码写进去了,是业务无关的,且占空间。解决方案:

在这里插入图片描述

这个String序列化器Spring提供了,直接用:

private static final ObjectMapper mapper = new ObjectMapper();//字符串与对象互转
    @Test
    void test2() throws JsonProcessingException {
        User user = new User("google", 21);
        String s = mapper.writeValueAsString(user);//转为String
        stringRedisTemplate.opsForValue().set("user:200",s);
        String jsonUser = stringRedisTemplate.opsForValue().get("user:200");
        User user1 = mapper.readValue(jsonUser, User.class);//获取String手动转回对象
        System.out.println(user1);
    }

在这里插入图片描述

变得纯粹了

3. redis优化查询

3.1 在常规查询中添加redis缓存(查)

在访问数据库前加一层,命中cache直接拿,没命中乖乖从数据库拿,并写到缓存中

3.2 修改数据库内容时,缓存怎么办(改)

讨论后的最终策略:先改数据库内容,然后删缓存(毫秒级别)。这两步用事务包裹起来

(1)为什么是删不是改:频繁改redis代价大,而且数据库变了不代表就会立即有人来查询,等人来查了再更新redis就行
(2)为啥先操作数据库后操作redis?能不能颠倒?
这种更安全,出现数据不一致问题的条件更苛刻

反序的问题:

在这里插入图片描述

3.3 缓存穿透

问题描述:查询一个数据库中不存在的对象时,问redis要没有,问数据库要没有,这个用户可能回接着一直问下去
在这里插入图片描述
在这里插入图片描述

3.4 缓存雪崩

无数key同时过期,大量的请求直接打在服务器上

在这里插入图片描述

3.5 缓存击穿

redis中一个被高频访问的key失效了(比如活动商品),在redis中重构他又要花费一定的时间,导致新来的大量请求打在数据库上

两种解决方案:互斥锁、逻辑过期(拿旧数据也行,凑合过)

在这里插入图片描述
在这里插入图片描述
查询商铺信息的新业务流程图:

(1)互斥锁法
在这里插入图片描述
通过redis中的setnx指令来模拟锁:(为什么不用synchronized:获取不到锁就会干呆着啥也不干,然而我们在获取不到锁时需要干一些额外的操作)

	private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    private void unLock(String key) {
        Boolean flag = stringRedisTemplate.delete(key);
    }

4. 优惠券秒杀

4.1 多线程超卖问题

场景:100个优惠券200个线程抢,理想状态是有100个线程抢到了,100个没抢到,库存变成0
但是运行发现库存变负数了,原因:
在这里插入图片描述

解决方案:乐观锁悲观锁

4.2 一人只能买一次优惠券

场景:消费记录表中,一个用户只能购买一次某个大额优惠券。
发现还是有一个用户超买的问题,因为同一个用户多线程访问,都是还没购买的状态

解决方案:乐观锁悲观锁

此处选用悲观锁,原因:
业务逻辑变化:

在这里插入图片描述
业务实现两个关键点:释放锁的时机+代理对象实现事务

		synchronized (userId.toString().intern()) {//锁扣在事务外面,String intern保证同一个用户id值获取同一个锁
            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
            //spring中事务的进行是让当前对象的代理对象做的,所以要获取当前对象的代理对象
            return proxy.createVoucherOrder(voucherId, userId);//否则toString还是底层new了新对象
            //基于接口进行调用
            //pom及启动类中添加aspect/proxy相关
        }
		//上面是主方法
		//下面是事务

		@Transactional
    public Result createVoucherOrder(Long voucherId, Long userId) {
        //一人一单
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("用户已经购买过一次");
        }
        // 5、秒杀券合法,则秒杀券抢购成功,秒杀券库存数量减一
        boolean flag = seckillVoucherService.update()
                .setSql("stock = stock-1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if (!flag) {
            return Result.fail("秒杀券扣减失败");
        }

        // 6、秒杀成功,创建对应的订单,并保存到数据库
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherOrder.getId());
        flag = this.save(voucherOrder);
        if (!flag) {
            return Result.fail("创建秒杀券订单失败");
        }
        return Result.ok(orderId);
    }

4.3 分布式锁

多个JVM的锁监视器如何同步

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
获取与释放锁:

public class SimpleRedisLock implements ILock{
    private String name;
    private StringRedisTemplate stringRedisTemplate;
    private static final String KEY_PREFIX = "lock";

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        long threadId = Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX+name, threadId+"", timeoutSec, TimeUnit.SECONDS);
//        return success; 自动拆箱Boolean若为null则拆箱时会出现空指针问题
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX+name);
    }
}

应用此锁:

		//UserHolder从threadlocal中获取当前线程用户
        Long userId = UserHolder.getUser().getId();
        
		//创建分布式锁
        //key拼接了用户id,锁定范围精确到了用户。若不拼接用户id,每来一个订单都会被锁定
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        //获取锁
        boolean isLock = lock.tryLock(1200);
        if(!isLock){//获取锁失败
            return Result.fail("不能重复下单");
        }
        try {
            //spring中事务的进行是让当前对象的代理对象做的,所以要获取当前对象的代理对象
            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, userId);
        } finally {
            lock.unlock();
        }

4.4 redis分布式锁的误删问题

进阶,考虑新的场景:
线程1特别长,长到锁ttl了任务还没结束,此时1失去锁
线程2重新获取锁,开始干活
线程1终于干完了,释放了之前自己持有的锁(此时正被线程2用着)
线程2:?你个老六

解决:新增红圈两处判断,让一个线程只能获取/释放自己一开始的锁:

在这里插入图片描述

再进阶:在上述过程中,先判断再释放,如果这俩操作间突然被阻塞,则还是会出现线程1释放了线程2的锁的问题
解决:将判断与释放两个redis操作设为原子性操作,借助Lua编写redis脚本:

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

@Override
    public void unlock() {
        stringRedisTemplate.execute(UNLOCK_SCRIPT,//脚本
                Collections.singletonList(KEY_PREFIX + name),//key集合
                ID_PREFIX + Thread.currentThread().getId()//value集合
                );
    }

总结:

在这里插入图片描述

4.5 Redisson

追加可重入等功能,用redisson这个第三方工具

4.5.1 可重入

加锁:每次锁个数+1并重置有效期

在这里插入图片描述

释放锁:每次锁个数-1并重置有效期

在这里插入图片描述

Redisson底层通过利用Lua脚本确保 判断锁是否存在、添加锁的有效期、添加线程标识这些的操作全部封装到了一个Lua脚本(确保了锁的原子性和可重入性)

加锁:
在这里插入图片描述
解锁:
在这里插入图片描述

pexpire用于重置有效期

4.5.2 可重试锁&超时续约

重试:没立即获取,那我等一会

如何解决可重试问题:利用信号量和PubSub功能(发布/订阅)实现等待、唤醒,获取锁失败的重试机制。

如何解决超时续约问题(锁超时释放了):利用watchDog,获取锁成功后,每隔一段时间(releaseTime / 3),重置超时时间。

4.5.3 主从一致性问题 mutiLock

如何解决主从一致性问题:利用Redisson的multiLock,多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功

缺陷:运维成本高、实现复杂

在这里插入图片描述
开启三个redis服务
在这里插入图片描述

5. 秒杀业务

现在用户购买优惠券的相关查询都是打在mysql数据库上的(redis目前只是起到分布式锁的作用),响应慢
因此考虑使用redis优化秒杀业务:

在这里插入图片描述

在这里插入图片描述

5.1 阻塞队列

private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);

队列为空则一直等着(阻塞),直到有人塞入才读取

5.2 异步下单

开启额外一个线程实现下单

业务类初始化的时候创建线程池

	private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

	@PostConstruct
    private void init(){//此类一旦初始化完事,就调用这个线程的run方法
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }
    
    private class VoucherOrderHandler implements Runnable{

        @Override
        public void run() {//处理阻塞队列中的任务
            while (true){
                try {
                    //获取订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    //创建订单
                    handleVoucherOrder(voucherOrder)
                } catch (InterruptedException e) {
                    log.error("处理订单异常",e);
                }
            }
        }
    }


5.3 主要业务逻辑

业务入口(主代码)

@Override
    public Result seckillVoucher(Long voucherId) {
        //执行lua,判断用户和券是否合法
        Long userId = UserHolder.getUser().getId();
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        //判断结果是否为0
        int r = result.intValue();
        if (r != 0) { //!=0,没有购买资格
            return Result.fail(r==1?"库存不足":"不能重复下单");
        }
        //0,有购买资格,把下单信息保存到阻塞队列
        long orderId = redisIdWorker.nextId("order");
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherOrder.getId());
        // 阻塞队列add
        orderTasks.add(voucherOrder);//主线程

        //获取(主线程的)代理对象
        proxy = (IVoucherOrderService)AopContext.currentProxy();
        //返回订单id
        return Result.ok(orderId);
    }

调用阻塞队列新增下订单业务,使用线程池完成阻塞队列中的任务。参考5.1&5.2,线程池中的线程调用如下代码完成创建订单

	private void handleVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        RLock lock = redissonClient.getLock("lock:order" + userId);//获取redis中的锁
        boolean isLock = lock.tryLock();
        if (!isLock) {
            log.error("一人只能下一单");
            return;
        }
        try {// 创建订单(使用代理对象调用,是为了确保事务生效)
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            lock.unlock();
        }
    }
	@Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) { //数据库层面创建订单
        //一人一单
        Long userId = voucherOrder.getUserId();
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        if (count > 0) {
            log.error("用户已经购买过一次");
            return ;
        }
        // 秒杀券合法,则秒杀券抢购成功,秒杀券库存数量减一
        boolean flag = seckillVoucherService.update()
                .setSql("stock = stock-1")
                .eq("voucher_id", voucherOrder.getVoucherId())
                .gt("stock", 0)
                .update();
        if (!flag) {
            log.error("秒杀券扣减失败");
            return ;
        }
        save(voucherOrder);
    }

6. 消息队列

6.1 基于List的

在这里插入图片描述

在这里插入图片描述
消息丢失:自己移除了个任务开始处理,然后自己挂了,别人也没办法再处理

6.2 基于PubSub的

在这里插入图片描述

在这里插入图片描述

消息丢失:消息发了可能没人接

这两种消息队列中,消息读完都会消失,非持久化

6.3 基于Stream

在这里插入图片描述

存储的所谓“消息”,就是一组键值对
在这里插入图片描述
在这里插入图片描述
漏读:处理得到的消息中,来了五条新消息,此时若读取最新的消息,则只会读到第五条消息,漏掉中间四条

在这里插入图片描述

6.3.1 Stream消息队列

在这里插入图片描述

6.3.2 消费者组

单消费方式,容易发生消息堆积导致消息丢失,因此改用消费者组的模式:

在这里插入图片描述

在这里插入图片描述

6.3.3 Stream+消费者组

在这里插入图片描述

最终的 异步处理 Redis消息队列【下单】中的任务:

(1)从消息队列中阻塞读
(2)有消息则解析、处理(下单)、ACK
(3)在(2)的处理过程中出现异常,没有ACK,则消息进入pending list(异常消息队列)
(4)从pending list中直接读消息,有消息则解析、处理(下单)、ACK
(5)从pending list中没读到消息,直接退出
(6)读pending list又出现异常,跳转到(4),重新尝试从pending list中读消息,直到pending list中没异常了【异常订单消息一定能得到处理】

	private class VoucherOrderHandler implements Runnable{
        String queueName="stream.orders";
        @Override
        public void run() {//处理阻塞队列中的任务
            while (true){
                try {
                    //获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000STREAMS streams.order >
                    // >表示最近一条未消费
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create(queueName, ReadOffset.lastConsumed())
                    );
                    //判断消息是否获取成功
                    if(list==null||list.isEmpty()){
                        continue;
                    }
                    //获取成功
                    //解析消息中的订单
                    MapRecord<String, Object, Object> record = list.get(0);
                    //其中,String是消息id 后面两个value是因为我们发消息的时候就是key-value的格式
                    Map<Object, Object> value = record.getValue(); //获取消息
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    //创建订单
                    handleVoucherOrder(voucherOrder);
                    //ACK确认 SACK streams.order g1 id(消息id)
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {//出现异常,进入pending list
                    log.error("处理订单异常",e);
                    handlePendingList();
                }
            }
        }
        private void handlePendingList(){
            while (true){
                try {
                    //获取pending list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 streams.order 0
                    // >表示最近一条未消费
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create(queueName, ReadOffset.from("0"))
                    );
                    //判断消息是否获取成功
                    if(list==null||list.isEmpty()){
                        //获取失败,说明pending list没有消息,结束循环
                        break;
                    }
                    //获取成功
                    //解析消息中的订单
                    MapRecord<String, Object, Object> record = list.get(0);
                    //其中,String是消息id 后面两个value是因为我们发消息的时候就是key-value的格式
                    Map<Object, Object> value = record.getValue(); //获取消息
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    //创建订单
                    handleVoucherOrder(voucherOrder);
                    //ACK确认 SACK streams.order g1 id(消息id)
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {//出现异常,进入pending list
                    log.error("处理pending list订单异常",e);
                    try {
                        Thread.sleep(20);
                    }catch (InterruptedException interruptedException){
                        interruptedException.printStackTrace();
                    }

                }
            }
        }
    }

7. 关注推送的分页查询

下拉刷新,注意消息顺序

在这里插入图片描述
核心功能实现:redis的sortedSet,时间戳作为优先级

为了防止新来的消息导致滚动消息队列变成乱序,每次从上一次读取的消息末尾继续读取,先不管新的消息

在这里插入图片描述

在这里插入图片描述

ZREVRANGEBYSCORE key Max Min LIMIT offset count 实现按照消息从新到旧排序
【参数意义】
key:获取某个用户被推送的(关注的博主发的)消息队列
min:时间戳最小为0
max:上一次读取的最早消息(最小时间戳),作为本次读取的最大值【计划接着往前读更早的消息】
offset:符合范围的结果集中,需要去除掉几个
count:读几条消息(一页默认显示多少消息)

	@Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        UserDTO userId = UserHolder.getUser();
        String key = FEED_KEY + userId;
        //获取用户的收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
        if (typedTuples == null || typedTuples.isEmpty()) {
            return Result.ok();
        }
        //解析数据blogId minTime(时间戳) offset(跟我上次查询的最小值一样的个数)
        List<Long> ids = new ArrayList<>(typedTuples.size());
        long minTime = 0;
        int os = 1;
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            ids.add(Long.valueOf(tuple.getValue()));
            long time = tuple.getScore().longValue();
            if (time == minTime) {
                os++;
            } else {
                minTime = time;
                os = 1;
            }
        }

        //根据id查询blog
        String idStr = StrUtil.join(",", ids);
        List<Blog> blogs = query()
                .in("id", ids)
                .last("ORDER BY FIELD(id," + idStr + ")").list();

        for (Blog blog : blogs) {
            queryBlogUser(blog);//查询blog有关用户
            isBlogLiked(blog);//查询blog是否被当前用户点赞
        }

        ScrollResult r = new ScrollResult();
        r.setList(blogs);
        r.setOffset(os);
        r.setMinTime(minTime);
        return Result.ok(r);
    }

8. 附近商铺(地理)

(1)按照离当前用户地理位置距离升序展示商铺,借助redis的GEO
(2)分页

8.1 商铺信息(地理)存进redis

将商铺的地理位置按照类别分组存储

	@Test
    void loadShopData() {
        //查询店铺
        List<Shop> list = shopService.list();
        //使用stream流实现按类别分组,key-long表示类别
        Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
        for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
            Long typeId = entry.getKey();
            String key = SHOP_GEO_KEY + typeId;
            List<Shop> value = entry.getValue();
            List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
            //写入redis: GEOADD key 经度 纬度 member
            for (Shop shop : value) {
                // stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
                locations.add(new RedisGeoCommands.GeoLocation<>(
                        shop.getId().toString(),
                        new Point(shop.getX(), shop.getY())
                ));
            }
            stringRedisTemplate.opsForGeo().add(key, locations);
        }
    }

8.2 分页

	@Override
    public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
        //不需要根据坐标查询(排序)
        if (x == null || y == null) {
            Page<Shop> page = query()
                    .eq("type_id", typeId)
                    .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
            return Result.ok(page.getRecords());
        }
        //按地理坐标查询
        int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;//定位分页要查询的数据范围
        int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
        String key = SHOP_GEO_KEY + typeId;//确定类别
        //查询语句
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
                .search(key,
                        GeoReference.fromCoordinate(x, y),
                        new Distance(5000),
                        RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
                );
        if(results==null){
            return Result.ok();
        }
        //截取from-to部分
        List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
        if(list.size()<=from){
            return Result.ok(Collections.emptyList());
        }
		//查询结束,转换数据为指定格式
        ArrayList<Long> ids = new ArrayList<>(list.size());
        Map<String, Distance> distanceMap = new HashMap<>(list.size());

        list.stream().skip(from).forEach(result ->{
            String shopIdStr = result.getContent().getName();
            ids.add(Long.valueOf(shopIdStr));
            Distance distance = result.getDistance();
            distanceMap.put(shopIdStr,distance);
        });
        //根据ids查找shop
        String idStr = StrUtil.join(",", ids);
        List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();

        for (Shop shop : shops) {
            shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
        }
        return Result.ok(shops);
    }

9. BitMap签到

签到:

在这里插入图片描述

以今天是14号为例:

在这里插入图片描述
其中,0是偏移量

10. UV统计

hyper log log:唯一性统计,有多少用户登录过次网站(去重)

redis实现:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
插入100万,统计有这些
误差小,内存占用小

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值