SpringBoot_redis使用实战(二)_缓存

前言

在上篇 SpringBoot_redis使用实战(一)_docker环境 记录了

  1. redis docker环境安装
  2. redis和springboot的集成基本使用

本文主要学习springboot中使用redis作为缓存的相关实战内容, 及各种缓存问题产生原因及解决方式,代码部分主要涉及

  1. 不同缓存类型设置不同设置过期时效
  2. null值缓存时间自定义(也能实现同类缓存+随机数)
  3. 应用启动缓存预热

一 springboot使用redis实现缓存

springboot和redis基础整合请参考: SpringBoot_redis使用实战(一)_docker环境

1. maven依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>

2. 开启缓存配置

添加spring-boot-starter-cache 并开启@EnableCaching 会自动注入CacheManager的实现

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {
}

3. 使用缓存

缓存–查询

	@Cacheable(value = "demo-app:business-cache", key = "'account-id' + #id")
	@Override
	public Account findAccountByPrimaryKey(long id) {
		return this.accountMapper.selectById(id);
	}

效果:查询结果自动缓存到redis,重复查询同一个key,不会发起数据查询请求
在这里插入图片描述

缓存–更新

	@CachePut(value = "demo-app:business-cache", key = "'account-id:' + #account.id")
	@Override
	public Account updateAccount(Account account) {
		this.accountMapper.updateById(account);
		return account;
	}

缓存–删除

	@CacheEvict(value = "demo-app:business-cache", key = "'account-id:' + #account.id")
	@Override
	public void deleteAccount(Account account) {
		this.accountMapper.deleteById(account.getId());
	}

简化写法@CacheConfig

@CacheConfig
作用在标注在类上,抽取缓存相关注解的公共配置,可抽取的公共配置有缓存名字、主键生成器等

@CacheConfig(cacheNames = RedisKeyConstant.CACHE_BUSINESS)
@Service("userService")
@Transactional(rollbackFor=Exception.class)
public class AccountServiceImpl implements AccountService {
	private static final Logger LOGGER = LoggerFactory.getLogger(AccountServiceImpl.class);
	
	@Autowired
	private AccountMapper accountMapper;
	
	@CachePut(key = "'account-id:' + #account.id")
	@Override
	public Account updateAccount(Account account) {
		this.accountMapper.updateById(account);
		return account;
	}

	@CacheEvict(key = "'account-id:' + #account.id")
	@Override
	public void deleteAccount(Account account) {
		this.accountMapper.deleteById(account.getId());
	}

	@Cacheable(key = "'account-id:' + #id")
	@Override
	public Account findAccountByPrimaryKey(long id) {
		return this.accountMapper.selectById(id);
	}
}

二 高级–缓存的问题及处理

缓存可视化(json序列化)

  • 场景:默认装配缓存虽然可以用,但是存在redis中的数据,无法直接阅读. 不利于排查问题
  • 分析: springboot 自动装配类org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration 默认使用jdk序列化
    在这里插入图片描述
  • 解决:配置RedisCacheConfiguration并制定json的序列化方式
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration
                .defaultCacheConfig()
                // 键序列化方式 redis字符串序列化
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
                // 值序列化方式 简单json序列化
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer));

        return defaultCacheConfiguration;
    }
  • 效果:
    在这里插入图片描述

缓存雪崩

  • 现象: 大量key在短时间内同时过期,导致缓存无法命中,大量请求直接到数据库, 压垮数据库

1. 有效期均匀分布

不同类型缓存时长错开过期, 同类缓存加随机数

  • 代码
    
    @Configuration
    @EnableCaching
    public class CacheConfig extends CachingConfigurerSupport {
    
        @Bean
        public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
            // 不同类型缓存---使用不同过期策略
            Map<String, RedisCacheConfiguration> cacheConfigMap = new LinkedHashMap();
            // 系统缓存过期时间(s):24 * 60 * 60
            cacheConfigMap.put(RedisKeyConstant.SYSTEM_CACHE, this.useJsonCacheConfiguration(Duration.ofSeconds(24 * 60 * 60)));
            // 业务缓存过期时间(s):20 * 60
            cacheConfigMap.put(RedisKeyConstant.BUSINESS_CACHE, this.useJsonCacheConfiguration(Duration.ofSeconds(20 * 60)));
    
            RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory)
                    .cacheDefaults(this.useJsonCacheConfiguration())
                    .withInitialCacheConfigurations(cacheConfigMap)
                    .build();
            return redisCacheManager;
        }
    
        private RedisCacheConfiguration useJsonCacheConfiguration(Duration ttl) {
            RedisCacheConfiguration defaultCacheConfiguration = this.useJsonCacheConfiguration();
            defaultCacheConfiguration.entryTtl(ttl);
            return defaultCacheConfiguration;
        }
        /**使用json序列化的缓存配置*/
        private RedisCacheConfiguration useJsonCacheConfiguration() {
            GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
            StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
    
            RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration
                    .defaultCacheConfig()
                    // 键序列化方式 redis字符串序列化
                    .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
                    // 值序列化方式 简单json序列化
                    .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer));
    
            return defaultCacheConfiguration;
        }
    
    
    }
    
    

2. 数据预热

对于即将来临的大量请求,我们可以提前走一遍系统,将数据提前缓存在Redis中,并设置不同的过期时间。

  • 场景:新系统第一次上线,此时在缓存里是没有数据的。

TODO 代码

3. 保证redis高可用

Redis的哨兵模式和集群模式,为防止Redis集群单节点故障,可以通过这两种模式实现高可用。
TODO 补充docker部署哨兵和集群模式

缓存击穿

  • 现象: 某一热点key缓存过期失效,瞬时大流量压垮数据库

1. 设置热点数据永不过期

对于某个需要频繁获取的信息,缓存在Redis中,并设置其永不过期。当然这种方式比较粗暴,对于某些业务场景是不适合的。

2. 定时更新(延长有效期)

比如这个热点数据的过期时间是1h,那么每到59minutes时,通过定时任务去更新这个热点key,并重新设置其过期时间。

3.互斥锁

这是解决缓存击穿比较常用的方法。

互斥锁简单来说就是在Redis中根据key获得的value值为空时,先锁上,然后从数据库加载,加载完毕,释放锁。若其他线程也在请求该key时,发现获取锁失败,则睡眠一段时间(比如100ms)后重试

缓存穿透

  • 现象:缓存和数据库中都没有的数据,可用户还是源源不断的发起请求,导致每次请求都会到数据库,从而压垮数据库

1. 业务层校验

请求参数校验,明显错误直接拦截返回(比如ID的长度, 自增ID有效范围,明显不符合直接返回错误

2. 不存在数据设置短过期时间

对于某个查询为空的数据,可以将这个空结果进行Redis缓存,但是设置很短的过期时间,比如30s,可以根据实际业务设定。注意一定不要影响正常业务

  • 代码分析
    springboot的缓存配置类RedisCacheConfiguration.defaultCacheConfig()方法,默认已经开启null值缓存
    在这里插入图片描述
    可以用,但是nullValue默认缓存时间是和正常值一样的(RedisCacheConfiguration定义).那么如果短时间请求大量key,就会占用redis内存.所以我们只要实现nullValue值情况,走特殊ttl时间即可. 缓存时间一般都是CacheManage.put(缓存, ttl)操作设置,我们从自动装配类RedisCacheConfiguration找redis的缓存管理器实现入口
    在这里插入图片描述
    进入RedisCacheManager
    在这里插入图片描述
    进入RedisCacheManager的静态内部类RedisCacheManagerBuilder
    在这里插入图片描述
    查看DefaultRedisCacheWriter,找到设置缓存的地方.
    在这里插入图片描述
    下面就开始具体的改造任务.
自定义CustomRedisCacheWriter(覆盖DefaultRedisCacheWriter.put逻辑)

CustomRedisCacheWriter必须实现RedisCacheWriter, 所有方法实现和DefaultRedisCacheWriter保持一致. put方法增加ObjectUtils.nullSafeEquals(value, RedisSerializer.java().serialize(NullValue.INSTANCE)空值判断. 为空设置ttl=30



/**
 * 重写DefaultRedisCacheWriter.put方法-- 修改null数据缓存时间为20秒
 * @see org.springframework.data.redis.cache.DefaultRedisCacheWriter
 */
public class CustomRedisCacheWriter implements RedisCacheWriter {
    private final RedisConnectionFactory connectionFactory;
    private final Duration sleepTime;

    public CustomRedisCacheWriter(RedisConnectionFactory connectionFactory) {
        this(connectionFactory, Duration.ZERO);
    }

    CustomRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime) {
        Assert.notNull(connectionFactory, "ConnectionFactory must not be null!");
        Assert.notNull(sleepTime, "SleepTime must not be null!");
        this.connectionFactory = connectionFactory;
        this.sleepTime = sleepTime;
    }

    public void put(String name, byte[] key, byte[] value, @Nullable Duration ttl) {
        // 缓存穿透--如果缓存为空值,使用快速过期时间
        if (ObjectUtils.nullSafeEquals(value, RedisSerializer.java().serialize(NullValue.INSTANCE))) {
            ttl = Duration.ofSeconds(30);
        }
        final Duration durationTtl = ttl;
        Assert.notNull(name, "Name must not be null!");
        Assert.notNull(key, "Key must not be null!");
        Assert.notNull(value, "Value must not be null!");
        this.execute(name, (connection) -> {
            if (shouldExpireWithin(durationTtl)) {
                connection.set(key, value, Expiration.from(durationTtl.toMillis(), TimeUnit.MILLISECONDS), RedisStringCommands.SetOption.upsert());
            } else {
                connection.set(key, value);
            }

            return "OK";
        });
    }

   // 其他方法省略,和DefaultRedisCacheWriter保持一致
}


生产环境建议:CustomRedisCacheWriter 其实会注册成单例bean, 可以定义nullValueTtl属性,由application.yaml传入, 或者使用nacos,可以达到动态设置效果.有兴趣可以自己试试. 另外这里ttl可以加上随机数,解决缓存雪崩的问题.

使用CustomRedisCacheWriter构造RedisCacheManager

使用RedisCacheManager.builder(cacheWriter) 传入自定义CustomRedisCacheWriter的实现

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

    @Bean
    public RedisCacheManager cacheManager(CustomRedisCacheWriter cacheWriter) {
        // 不同类型缓存---使用不同过期策略
        Map<String, RedisCacheConfiguration> cacheConfigMap = new LinkedHashMap();
        // 系统缓存过期时间(s):24 * 60 * 60
        cacheConfigMap.put(RedisKeyConstant.SYSTEM_CACHE, this.useJsonCacheConfiguration(Duration.ofSeconds(24 * 60 * 60)));
        // 业务缓存过期时间(s):20 * 60
        cacheConfigMap.put(RedisKeyConstant.BUSINESS_CACHE, this.useJsonCacheConfiguration(Duration.ofSeconds(20 * 60)));

        RedisCacheManager redisCacheManager = RedisCacheManager.builder(cacheWriter)
                .cacheDefaults(this.useJsonCacheConfiguration())
                .withInitialCacheConfigurations(cacheConfigMap)
                .build();
        return redisCacheManager;
    }

    @Bean
    public CustomRedisCacheWriter cacheWriter(RedisConnectionFactory redisConnectionFactory) {
        return new CustomRedisCacheWriter(redisConnectionFactory);
    }

    private RedisCacheConfiguration useJsonCacheConfiguration(Duration ttl) {
        RedisCacheConfiguration defaultCacheConfiguration = this.useJsonCacheConfiguration();
        defaultCacheConfiguration.entryTtl(ttl);
        return defaultCacheConfiguration;
    }
    /**使用json序列化的缓存配置*/
    private RedisCacheConfiguration useJsonCacheConfiguration() {
        GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration
                .defaultCacheConfig()
                // 键序列化方式 redis字符串序列化
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
                // 值序列化方式 简单json序列化
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer));

        return defaultCacheConfiguration;
    }


}

3. 布隆过滤器

布隆过滤器是一种数据结构,利用极小的内存,可以判断大量的数据“一定不存在或者可能存在”。

对于缓存穿透,我们可以将查询的数据条件都哈希到一个足够大的布隆过滤器中,用户发送的请求会先被布隆过滤器拦截,一定不存在的数据就直接拦截返回了,从而避免下一步对数据库的压力

  • TODO代码实现(redission布隆过滤器)

4. 加锁/限流

缓存一致性

TODO

缓存降级

缓存降级是指缓存失效或者缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或者访问服务的内存数据。降级一般是有损的操作,所以尽量减少降级对业务的影响程度。

  • 场景: 一般业务系统参数因为在业务处理上会频繁读取,所以一般也会使用缓存存储一份减少数据库查询,但是由于意外情况下缓存服务不可用(比如redis挂了,连接redis的网络故障),就会因为缓存系统的不可用,导致业务系统主流程都不可用。这时候系统的业务系统系统参数表其实能够读取的, 但是因为加了缓存注解, 反而造成很大的影响。

在这里插入图片描述

  • 分析: 因为缓存注解是写死的, 线上缓存故障时候没法改代码去关闭缓存。同时缓存恢复的时候也应该自动切回去。

可选方案:

方案说明评价
方案一:异常捕获获取缓存的地方增加异常捕获逻辑,查询不到缓存中的配置, 查询数据库需要业务中到处硬编码很不友好。
方案二: 多级缓存管理器检测主缓存管理器不可用(redis连接超时),自动切换成另一款管理器(读内存/读默认/甚至无缓存)对业务入侵较小, 可以实现自动切换

1. 多级缓存方案实现

  • 概要设计
    • 缓存降级管理器: 使用装饰器模式集成多个缓存管理,对外提供缓存降级和恢复操作接口
    • 缓存降级监听器: 监听缓存redis连接事件, 连接中断通知缓存降级,连接恢复,重新上线
    • 缓存降级配置器: 配置主缓存和失败缓存的具体实现
1.1 CacheDegradeManager【缓存降级管理器】
/**
 * 可降级的缓存管理器
 */
public class CacheDegradeManager implements CacheManager {
    private static final Logger LOGGER = LoggerFactory.getLogger(CacheDegradeManager.class);

    /**主缓存管理器*/
    private final LinkedHashMap<String, CacheManager> mainCacheManagers;
    /**主缓存不可用后管理器*/
    private final LinkedHashMap<String, CacheManager> failCacheManagers;
    private final AtomicBoolean activeStatus = new AtomicBoolean(false);

    protected LinkedHashMap<String, CacheManager> cacheManagers;

    public CacheDegradeManager(LinkedHashMap<String, CacheManager> mainCacheManagers, LinkedHashMap<String, CacheManager> failCacheManagers) {
        this.mainCacheManagers = mainCacheManagers;
        this.failCacheManagers = failCacheManagers;
        // 启动使用主缓存管理器
        this.cacheManagers = mainCacheManagers;
    }


    @Override
    public Cache getCache(String name) {
        for (CacheManager cacheManager : this.cacheManagers.values()) {
            Cache cache = cacheManager.getCache(name);
            if (cache != null) {
                return cache;
            }
        }
        return null;
    }

    @Override
    public Collection<String> getCacheNames() {
        Set<String> names = new LinkedHashSet<>();
        for (CacheManager manager : this.cacheManagers.values()) {
            names.addAll(manager.getCacheNames());
        }
        return Collections.unmodifiableSet(names);
    }

    public void up() {
        if (!this.activeStatus.get()) {
            LOGGER.info("【主缓存】连接正常,启用缓存[{}]", StringUtils.join(this.mainCacheManagers.keySet(), ","));
            this.cacheManagers = this.mainCacheManagers;
            this.activeStatus.set(true);

        }
    }

    public void down() {
        if (this.activeStatus.get()) {
            LOGGER.warn("【主缓存】连接断开,缓存降级[{}]", StringUtils.join(this.failCacheManagers.keySet(), ","));
            this.cacheManagers = this.failCacheManagers;
            this.activeStatus.set(false);
        }
    }

    public boolean isActive() {
        return this.activeStatus.get();
    }

    public LinkedHashMap<String, CacheManager> getCacheManagers() {
        return cacheManagers;
    }

    public void setCacheManagers(LinkedHashMap<String, CacheManager> cacheManagers) {
        this.cacheManagers = cacheManagers;
    }
}
1.2 CacheDegradeListener【缓存降级监听器】
/**
 * 降级缓存监听器
 */
public class CacheDegradeListener implements InitializingBean {

    private final LettuceConnectionFactory lettuceConnectionFactory;
    private final CacheManager cacheManager;

    public CacheDegradeListener(LettuceConnectionFactory lettuceConnectionFactory, CacheManager cacheManager) {
        this.lettuceConnectionFactory = lettuceConnectionFactory;
        this.cacheManager = cacheManager;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        if (!(this.cacheManager instanceof CacheDegradeManager)) {
            return;
        }
        CacheDegradeManager onelinkCacheManager = (CacheDegradeManager) this.cacheManager;

        this.lettuceConnectionFactory.getClientResources().eventBus().get().subscribe(event -> {

            if (event instanceof ConnectionDeactivatedEvent || event instanceof ConnectionActivatedEvent) {
                synchronized (CacheDegradeListener.class) {
                    if (event instanceof ConnectionDeactivatedEvent) {
                        onelinkCacheManager.down();
                    } else {
                        onelinkCacheManager.up();
                    }
                }
            }
        // 开启监听后立马测试连接test connection。
        this.lettuceConnectionFactory.getConnection().keyCommands().keys("test".getBytes(StandardCharsets.UTF_8));
        });
    }
}
1.3 CacheDegradeConfig【缓存降级配置器】
/**
 * 测试可降级缓存管理
 */
@Configuration
@EnableCaching
public class CacheDegradeConfig {

    /**
     *  可降级缓存监听
     */
    @Bean
    public CacheDegradeListener degradeCacheListener(LettuceConnectionFactory lettuceConnectionFactory, CacheManager cacheManager) {
        return new CacheDegradeListener(lettuceConnectionFactory, cacheManager);
    }
    @Primary
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        //主缓存管理器
        LinkedHashMap<String, CacheManager> mainCacheManagers = new LinkedHashMap<>();
        mainCacheManagers.put("RedisCache", this.createRedisCacheManager(redisConnectionFactory));

        //失败缓存管理器
        LinkedHashMap<String, CacheManager> failCacheManager = new LinkedHashMap<>();
        failCacheManager.put("NoOpCache", new NoOpCacheManager());
        return new CacheDegradeManager(mainCacheManagers, failCacheManager);
    }

    /**
     *  缓存管理器
     */
    @Bean
    public RedisCacheManager createRedisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration defaultCacheConfiguration = this.useJsonCacheConfiguration();
        RedisCacheConfiguration businessCacheConfiguration = this.useJsonCacheConfiguration(Duration.ofSeconds(20 * 60));

        Map<String, RedisCacheConfiguration> cacheConfigMap = new LinkedHashMap();
        cacheConfigMap.put("demo-app:business-cache", businessCacheConfiguration);

        RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(defaultCacheConfiguration)
                .withInitialCacheConfigurations(cacheConfigMap)
                .build();
        return redisCacheManager;
    }

    @Bean
    public CustomRedisCacheWriter cacheWriter(RedisConnectionFactory redisConnectionFactory) {
        return new CustomRedisCacheWriter(redisConnectionFactory);
    }


    private RedisCacheConfiguration useJsonCacheConfiguration(Duration ttl) {
        return this.useJsonCacheConfiguration().entryTtl(ttl);
    }

    /**使用json序列化的缓存配置*/
    private RedisCacheConfiguration useJsonCacheConfiguration() {
        GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration
                .defaultCacheConfig()
                // 键序列化方式 redis字符串序列化
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
                // 值序列化方式 简单json序列化
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer));

        return defaultCacheConfiguration;
    }


}
1.4 使用效果

在这里插入图片描述

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Spring Boot使用Redis可以实现各种功能,例如缓存、消息队列等。首先,你需要在项目的配置文件中添加Redis的相关配置。根据引用和引用,你可以配置Redis的主机和端口,例如"192.168.56.10:6379"。接下来,在你的项目中引入Redis的依赖,例如通过Maven添加"spring-boot-starter-data-redis"的依赖。然后,你可以通过在代码中注入RedisTemplate或者使用@Cacheable注解来使用Redis进行缓存操作。你可以使用Redis的各种数据结构,例如String、Hash、List等。如果你需要实现消息队列,你可以使用Redis的发布/订阅功能。总的来说,Spring Boot使用Redis可以实现很多实用的功能,具体的实战应用可以根据你的需求进行设计和开发。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [毕设项目:基于SpringBoot+MyBatis+mysql的飞机订票系统.zip](https://download.csdn.net/download/qq_35831906/88222799)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 33.333333333333336%"] - *2* [【Redis】之 SpringBoot 项目整合 Redis 实战](https://blog.csdn.net/aiwangtingyun/article/details/109170525)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 33.333333333333336%"] - *3* [SpringBoot 整合Redis 实战篇](https://blog.csdn.net/2301_77444674/article/details/131536253)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 33.333333333333336%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值