go 进阶 go-zero相关: 十. redis与缓存高级

一. 基础

  1. go-zero内部提供了redis操作相关的包,基础操作示例
import (
	"fmt"
	"github.com/zeromicro/go-zero/core/stores/redis"
)

func test() {
	//1.获取redis连接
	redisObj := redis.New("addr")
	//2.设置数据
	key := "abc"
	err := redisObj.Set(key, "123erf")
	if err != nil {
		fmt.Printf("set fail:%v\n", err)
	}
	//3.获取数据
	res, err := redisObj.Get(key)
	if err != nil {
		fmt.Printf("get fail:%v\n", err)
	}
	fmt.Printf("result:%s", res)
}
  1. 并且通过在上一章"go 进阶 go-zero相关: 八. goctl 生成数据库与缓存逻辑" 我们了解到go-zero默认提供了缓存model数据功能, 在执行goctl命令生成数据库crud mudel时增加"-c"指令即可,go-zero中提供了cache.CacheConf,在底层通过该结构连接缓存组件,进行缓存的增删改查操作
goctl model mysql datasource -url="root:root@tcp(127.0.0.1:3306)/demo" -table="t_user" -dir="./model" -c -style goZero
  1. 接下来我们了解一下go-zero中缓存相关的高级用法

二. 进程内缓存组件 collection.Cache

  1. 参考博客
  2. go-zero 提供的简单的缓存封装 collection.Cache,通过该组件可以实现(注意这里说的缓存不是redis):
  1. 缓存增删改,自动失效,可以指定过期时间
  2. 缓存大小限制,可以指定缓存个数,有LRU算法
  3. 缓存命中率统计
  4. 并发安全,解决缓存击穿问题
  1. 基础使用示例
import (
	"github.com/zeromicro/go-zero/core/collection"
	"time"
)

func test2() {
	// 初始化 cache,其中 WithLimit 可以指定最大缓存的数量
	c, err := collection.NewCache(time.Minute, collection.WithLimit(10000))
	if err != nil {
		panic(err)
	}
	// 设置缓存
	c.Set("key", "aaaa")
	// 获取缓存,ok:是否存在
	v, ok := c.Get("key")
	// 删除缓存
	c.Del("key")
	// 获取缓存,如果 key 不存在的,则会调用 func 去生成缓存
	v, err := c.Take("key", func() (interface{}, error) {
		return "vvvv", nil
	})
}

原理相关

1. 初始化collection.Cache

  1. 调用NewCache()初始化方法,会返回一个Cache数据结构
func NewCache(expire time.Duration, opts ...CacheOption) (*Cache, error) {
	cache := &Cache{//声明缓存结构,初始化内容
		data:           make(map[string]interface{}),
		expire:         expire,
		lruCache:       emptyLruCache, //默认是一个空的LRU结构,可以通过opts来控制
		barrier:        syncx.NewSingleFlight(),//解决缓存击穿的核心方法
		unstableExpiry: mathx.NewUnstable(expiryDeviation),//框架自己做的一个并发安全的随机数
	}

	for _, opt := range opts {
		opt(cache) //执行预加载函数
	}

 ...
	cache.stats = newCacheStat(cache.name, cache.size)//缓存命中统计模块,初始化

	timingWheel, err := NewTimingWheel(time.Second, slots, func(k, v interface{}) {
		key, ok := k.(string)
		if !ok {
			return
		}

		cache.Del(key)
	})//定时器模块,初始化
	...
	return cache, nil
}
  1. 在Cache 结构中有三块比较重要:
  1. lruCache: Cache有大小限制, 通过LRU淘汰策略管理大小的
  2. barrier: 用来解决缓存击穿的核心方法
  3. unstableExpiry: 框架自己做的一个并发安全的随机数
type (
	CacheOption func(cache *Cache) //用来操作缓存的函数,用在实例化缓存时

	Cache struct {
		name           string //缓存名称
		lock           sync.Mutex //并发锁
		data           map[string]interface{} //缓存内容
		expire         time.Duration //过期时间
		timingWheel    *TimingWheel //框架封装的定时器
		lruCache       lru //LRU组件
		barrier        syncx.SingleFlight //缓存并发安全组件,可以解决缓存击穿的问题
		unstableExpiry mathx.Unstable //生成随机数的插件
		stats          *cacheStat //统计命中率模块
	}
)

2. 增删改查方法

这一块代码大同小异,都是加锁,操作,再解锁的方式来进行并发管理的。通过这一块代码的阅读,基本可以明确LRU模块和命中率统计模块是如何工作的


func (c *Cache) Del(key string) {
	c.lock.Lock() //上锁
	delete(c.data, key) // 删除元素
	c.lruCache.remove(key) //移除LRU
	c.lock.Unlock() // 解锁
	c.timingWheel.RemoveTimer(key) //移除定时器, 注意先解锁,后移除。
}

func (c *Cache) Get(key string) (interface{}, bool) {
	...
	if ok { //统计命中率
		c.stats.IncrementHit()
	} else {
		c.stats.IncrementMiss()
	}
  ...
}


func (c *Cache) Set(key string, value interface{}) {
	c.lock.Lock() //上锁
	_, ok := c.data[key] //判断KEY是否存在
	c.data[key] = value //赋值
	c.lruCache.add(key) //添加到LRU
	c.lock.Unlock() //解锁

	expiry := c.unstableExpiry.AroundDuration(c.expire) //设置过期值
	if ok {
		c.timingWheel.MoveTimer(key, expiry)
	} else {
		c.timingWheel.SetTimer(key, value, expiry)
	}
}	

func (c *Cache) doGet(key string) (interface{}, bool) {
 	...
	value, ok := c.data[key]
	if ok {
		c.lruCache.add(key) //添加到LRU中
	}
 ...
}

3. 获取指定key值时解决缓存击穿

  1. 在通过Take()方法获取指定KEY值,如果不存在,执行fetch方法,拿到返回值设置到缓存里,并返回。当出现并发情况时,barrier方法会保证并发安全
func (c *Cache) Take(key string, fetch func() (interface{}, error)) (interface{}, error) {
	if val, ok := c.doGet(key); ok {//直接获取KEY的值
		c.stats.IncrementHit() //记录命中
		return val, nil
	}

	var fresh bool
	//核心方法,主要是用了syncx.NewSharedCalls实现的功能
	val, err := c.barrier.Do(key, func() (interface{}, error) {
		//这里进行了一次dobble check。解决并发时,有些协程可能已经把数据查出来并加载到缓存了。
		if val, ok := c.doGet(key); ok {
			return val, nil
		}

		v, e := fetch()//执行方法,获取CACHE。这个方法应该尽量的保证效率
		if e != nil {
			return nil, e
		}

		fresh = true
		c.Set(key, v) //设置缓存
		return v, nil
	})
 	...

	if fresh {
		//fetch 获取到数据为空,记录miss次数
		c.stats.IncrementMiss()
		return val, nil
	}

	// 直接把之前查到的数据返回,并记录命中次数
	c.stats.IncrementHit()
	return val, nil
}
  1. 这里实现缓存击穿安全的方法主要是依赖了syncx.NewSharedCalls, 使用SharedCalls可以使得同时多个请求只需要发起一次拿结果的调用,其它请求等待,有效减少了资源服务的并发压力,可以有效防止缓存击穿(下面会专门介绍NewSharedCalls)

LRU的淘汰算法

  1. 通过collection.Cache引出 go-zero中提供的LRU淘汰算法, LRU的核心思想:
  1. 新数据插入到链表头部;
  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
  3. 当链表满的时候,将链表尾部的数据丢弃
type (
	lru interface { //声明一个接口
		add(key string)
		remove(key string)
	}

	emptyLru struct{} //默认的空结构,newCache的时候默认用的就这个

	keyLru struct {//带有限制的结构,ewCache的时候通过调用withLimit方法设置
		limit    int //总长度
		evicts   *list.List //元素链
		//存放的是元素在链表中的地址。利用MAP操作O1的特性,不用遍历链表即可找到需要的元素的地址。
		elements map[string]*list.Element
		onEvict  func(key string) //删除的后置操作
	}
)

//使用时调用该方法初始化keyLru 
func newKeyLru(limit int, onEvict func(key string)) *keyLru {
...
}

func (klru *keyLru) add(key string) {
	if elem, ok := klru.elements[key]; ok {
		klru.evicts.MoveToFront(elem)//如果新增元素已存在,就直接移到最前面
		return
	}

	elem := klru.evicts.PushFront(key)//在链表最前面增加一个元素
	klru.elements[key] = elem //记录这个元素的地址

	if klru.evicts.Len() > klru.limit {
		klru.removeOldest()//如果链表的最大长度超过配置,移除最老的元素
	}
}

func (klru *keyLru) remove(key string) {
...
}

func (klru *keyLru) removeOldest() {
	elem := klru.evicts.Back()//取链表最后一个元素
	if elem != nil {
		klru.removeElement(elem) //移除元素
	}
}

func (klru *keyLru) removeElement(e *list.Element) {
	klru.evicts.Remove(e)//移除链表中的元素
	key := e.Value.(string)//获取Key
	delete(klru.elements, key)//移除MAP中的元素
	klru.onEvict(key) //执行删除的后置操作
}

命中统计模块

  1. 在上方初始化cache时,会调用newCacheStat()初始化命中统计模块,源码如下:
type cacheStat struct {
	name         string //名称,最后打印日志记录是要用到
	hit          uint64 //命中缓存次数
	miss         uint64 //未命中次数
	sizeCallback func() int //自定义回调函数,会在打印结果的时候用到
}

func newCacheStat(name string, sizeCallback func() int) *cacheStat {...}

func (cs *cacheStat) IncrementHit() {
	atomic.AddUint64(&cs.hit, 1) //记录命中次数
}

func (cs *cacheStat) IncrementMiss() {
	atomic.AddUint64(&cs.miss, 1)
}

func (cs *cacheStat) statLoop() {
...
}

三. 防止缓存击穿之进程内共享调用

  1. 参考博客
  2. 并发场景下,可能会有多个线程(协程)同时请求同一份资源,如果每个请求都要走一遍资源的请求过程,除了比较低效之外,还会对资源服务造成并发的压力,比如缓存失效
  3. go-zero中提供了syncx.NewSharedCalls()解决这个问题, 在上方cache中也有调用这个方法
  4. 使用示例:(github.com/tal-tech/go-zero/core/syncx 版本的)
func main() {
  const round = 5
  var wg sync.WaitGroup
  barrier := syncx.NewSharedCalls()
  wg.Add(round)
  for i := 0; i < round; i++ {
    // 多个线程同时执行
    go func() {
      defer wg.Done()
      // 可以看到,多个线程在同一个key上去请求资源,获取资源的实际函数只会被调用一次
      val, err := barrier.Do("once", func() (interface{}, error) {
        // sleep 1秒,为了让多个线程同时取once这个key上的数据
        time.Sleep(time.Second)
        // 生成了一个随机的id
        return stringx.RandId(), nil
      })
      if err != nil {
        fmt.Println(err)
      } else {
        fmt.Println(val)
      }
    }()
  }
  wg.Wait()
}
  1. "github.com/zeromicro/go-zero/core/syncx"版本使用,参考博客
import (
	"fmt"
	"github.com/zeromicro/go-zero/core/stringx"
	"github.com/zeromicro/go-zero/core/syncx"
	"sync"
	"time"
)

syncx.NewSingleFlight()

以NewSharedCalls()为例 解释底层

  1. NewSharedCalls()是老版本的, go-zero中针对该功能提供了一个SharedCalls接口,内部有Do和DoEx两种方法的抽象
// SharedCalls接口提供了Do和DoEx两种方法
type SharedCalls interface {
  Do(key string, fn func() (interface{}, error)) (interface{}, error)
  DoEx(key string, fn func() (interface{}, error)) (interface{}, bool, error)
}
  1. 在调用NewSharedCalls()时会返回sharedGroup实现
// call代表对指定资源的一次请求
type call struct {
  wg  sync.WaitGroup  // 用于协调各个请求goroutine之间的资源共享
  val interface{}     // 用于保存请求的返回值
  err error           // 用于保存请求过程中发生的错误
}
type sharedGroup struct {
  calls map[string]*call
  lock  sync.Mutex
}

1. sharedGroup 下的 Do() 方法

  1. 通过Do()方法获取数据, 该方法需要两个参数:

key参数:可以理解为资源的唯一标识。
fn参数:真正获取资源的方法

// 当多个请求同时使用Do方法请求资源时
func (g *sharedGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
  // 先申请加锁
  g.lock.Lock()
  // 根据key,获取对应的call结果,并用变量c保存
  if c, ok := g.calls[key]; ok {
    // 拿到call以后,释放锁,此处call可能还没有实际数据,只是一个空的内存占位
    g.lock.Unlock()
    // 调用wg.Wait,判断是否有其他goroutine正在申请资源,如果阻塞,说明有其他goroutine正在获取资源
    c.wg.Wait()
    // 当wg.Wait不再阻塞,表示资源获取已经结束,可以直接返回结果
    return c.val, c.err
  }
  // 没有拿到结果,则调用makeCall方法去获取资源,注意此处仍然是锁住的,可以保证只有一个goroutine可以调用makecall
  c := g.makeCall(key, fn)
  // 返回调用结果
  return c.val, c.err
}

2. sharedGroup 下的 DoEx() 方法

  1. 和Do方法类似,只是返回值中增加了布尔值表示值是调用makeCall方法直接获取的,还是取的共享成果
func (g *sharedGroup) DoEx(key string, fn func() (interface{}, error)) (val interface{}, fresh bool, err error) {
  g.lock.Lock()
  if c, ok := g.calls[key]; ok {
    g.lock.Unlock()
    c.wg.Wait()
    return c.val, false, c.err
  }
  c := g.makeCall(key, fn)
  return c.val, true, c.err
}

3. sharedGroup 下的 makeCall() 方法

  1. 该方法由Do和DoEx方法调用,是真正发起资源请求的方法
// 进入makeCall的一定只有一个goroutine,因为要拿锁锁住的
func (g *sharedGroup) makeCall(key string, fn func() (interface{}, error)) *call {
  // 创建call结构,用于保存本次请求的结果
  c := new(call)
  // wg加1,用于通知其他请求资源的goroutine等待本次资源获取的结束
  c.wg.Add(1)
  // 将用于保存结果的call放入map中,以供其他goroutine获取
  g.calls[key] = c
  // 释放锁,这样其他请求的goroutine才能获取call的内存占位
  g.lock.Unlock()
  defer func() {
    // delete key first, done later. can't reverse the order, because if reverse,
    // another Do call might wg.Wait() without get notified with wg.Done()
    g.lock.Lock()
    delete(g.calls, key)
    g.lock.Unlock()
    // 调用wg.Done,通知其他goroutine可以返回结果,这样本批次所有请求完成结果的共享
    c.wg.Done()
  }()
  // 调用fn方法,将结果填入变量c中
  c.val, c.err = fn()
  return c
}

四. redis锁

  1. 参考博客
  2. 基础使用示例
import (
	"fmt"
	"github.com/zeromicro/go-zero/core/stores/redis"
)

func test3() {
	//1.获取redis连接
	store := redis.New("")
	//2.拼接一个key
	redisLockKey := fmt.Sprintf("%v%v", "key", "")
	//3.初始化redislock
	redisLock := redis.NewRedisLock(store, redisLockKey)
	//4.可选操作,设置 redislock 过期时间
	redisLock.SetExpire(10)
	if ok, err := redisLock.Acquire(); !ok || err != nil {
		fmt.Println("为获取到锁")
		return
	}
	defer func() {
		recover()
		// 3. 释放锁
		redisLock.Release()
	}()
}

原理相关

  1. 先看几个redis实现锁功能时底层的几个命令
  1. ex seconds :设置key过期时间,单位s
  2. px milliseconds :设置key过期时间,单位毫秒
  3. nx:key不存在时,设置key的值
  4. xx:key存在时,才会去设置key的值
  1. 首先会调用NewRedisLock()函数初始化redisLock
func NewRedisLock(store *Redis, key string) *RedisLock {
	return &RedisLock{
		store: store,
		key:   key,
		id:    stringx.Randn(randomLen),
	}
}

type RedisLock struct {
	store   *Redis
	seconds uint32
	key     string
	id      string
}

1. Acquire()获取锁

  1. 在获取锁时会执行一段lua脚本lockCommand
  1. Lua 脚本保证原子性「当然,把多个操作在 Redis 中实现成一个操作,也就是单命令操作」
  2. 使用了 set key value px milliseconds nx
  3. value 具有唯一性
  4. 加锁时首先判断 key 的 value 是否和之前设置的一致,一致则修改过期时间
const lockCommand     = `if redis.call("GET", KEYS[1]) == ARGV[1] then
    redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
    return "OK"
else
    return redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])
end`

func (rl *RedisLock) Acquire() (bool, error) {
	return rl.AcquireCtx(context.Background())
}

func (rl *RedisLock) AcquireCtx(ctx context.Context) (bool, error) {
	seconds := atomic.LoadUint32(&rl.seconds)
	resp, err := rl.store.EvalCtx(ctx, lockCommand, []string{rl.key}, []string{
		rl.id, strconv.Itoa(int(seconds)*millisPerSecond + tolerance),
	})
	if err == red.Nil {
		return false, nil
	} else if err != nil {
		logx.Errorf("Error on acquiring lock for %s, %s", rl.key, err.Error())
		return false, err
	} else if resp == nil {
		return false, nil
	}

	reply, ok := resp.(string)
	if ok && reply == "OK" {
		return true, nil
	}

	logx.Errorf("Unknown reply when acquiring lock for %s: %v", rl.key, resp)
	return false, nil
}

2. 释放锁

  1. 会执行一段lua脚本delCommand
const delCommand = `if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end`

func (rl *RedisLock) Release() (bool, error) {
	return rl.ReleaseCtx(context.Background())
}

func (rl *RedisLock) ReleaseCtx(ctx context.Context) (bool, error) {
	resp, err := rl.store.EvalCtx(ctx, delCommand, []string{rl.key}, []string{rl.id})
	if err != nil {
		return false, err
	}

	reply, ok := resp.(int64)
	if !ok {
		return false, nil
	}

	return reply == 1, nil
}
  1. 注意: 不能释放别人的锁,需要先 get(key) == value「key」,为 true 才会去 delete

五. go-zero 布隆过滤实现原理

复习布隆过滤器的相关问题

  1. 什么是布隆过滤器:
  1. 布隆过滤器可以看成一个很长的初始值都为0的,二进制数组+一系列随机hash算法映射函,类似于set的数据结构,统计结果不太准确(在统计存在时)
  2. 在添加key时: 使用多个hash函数,对key进行hash运算得到一个整数索引,然后与位数组长度进行取模运算拿到位置,每个hash函数都会得到不同的位置,将这几个位置设置为1,完成了一次add操作
  3. 查询key时,只要通过key进行hash运算拿到整数索引后与位数组长度进行取模运算,拿到位数组的位置,只要一次位数组中指定的位置值为0,就说明key不存在
  1. 解释为什么判断存在的可能不存在,假设在判断一个key是经过hash运算,与位数组长度取模运算获取到位置后判断该位置上是1,这个1可能是对别的key进行标记的
  2. 解释为什么不能删除,删除会造成误判率增加的原因: 看上图,添加obj1与obj2两个数据,假设需要通过三次hash运算获取到了三个位置obj1对应1,3,12, obj2对应3, 8, 13,会发现obj1与obj2同时都用到了3位置,假设我要删除obj1,将索引为1,3,12位置上的1设置为0,当查询obj2是否存在时走到3位置上会发现在删除obj1时已经设置为0了,实际存在的却返回不存在,发生误判
  3. 简单提一下 google在针对java时提供了一个工具包Guava,内部有对布隆过滤器的实现,BloomFilter对象,底层默认指定使用了5个hash函数,默认的误判率为0.03

go-zero 对布隆过滤器的实现

  1. 在go-zero中基于redis,基于bitmap数据类型,实现了布隆过滤器, 将数据多次hash后,插入到多个特定位并设置为1, 数据检测时经过相同hash后检测所有位,只要其中一位为0表数据不存在,否则数据可能存在, core/bloom/bloom.go
  2. 封装了一个Filter结构体
//表示经过多少散列函数计算
//固定14次
maps = 14

type (
    // 定义布隆过滤器结构体
    Filter struct {
        bits   uint
        bitSet bitSetProvider
    }
    //位数组操作接口定义
    bitSetProvider interface {
        check([]uint) (bool, error)
        set([]uint) error
    }
)
  1. 初始化方法,内部会调用newRedisBitSet()函数,返回一个redisBitSet 结构体redis位数组
func New(store *redis.Redis, key string, bits uint) *Filter {
	return &Filter{
		bits:   bits,
		bitSet: newRedisBitSet(store, key, bits),
	}
}
  1. 添加
func (f *Filter) Add(data []byte) error {
    // 获取数据多次hash后的各key
	locations := f.getLocations(data)
    // 插入数据
	return f.bitSet.set(locations)
}
  1. 判断数据是否存在
func (f *Filter) Exists(data []byte) (bool, error) {
    // 同数据set一致,获取数据多次hash后,偏移量切片
	locations := f.getLocations(data)
    // 调用check方法进行检测
	isSet, err := f.bitSet.check(locations)
	if err != nil {
		return false, err
	}
 
	return isSet, nil
}

底层

  1. 查看初始化时执行的New()函数, 内部会调用newRedisBitSet(),创建一个redisBitSet 结构体,是一个redis位数组
func newRedisBitSet(store *redis.Redis, key string, bits uint) *redisBitSet {
	return &redisBitSet{
		store: store,
		key:   key,
		bits:  bits,
	}
}

//redis位数组
type redisBitSet struct {
    store *redis.Client
    key   string
    bits  uint
}
  1. 查看添加数据与检查数据是否存在时Filter上的Add()方法与Exists方法内部首先会执行一个getLocations()方法
//k次散列计算出k个offset
func (f *Filter) getLocations(data []byte) []uint {
    //创建指定容量的切片
    locations := make([]uint, maps)
    //maps表示k值,作者定义为了常量:14
    for i := uint(0); i < maps; i++ {
        //哈希计算,使用的是"MurmurHash3"算法,并每次追加一个固定的i字节进行计算
        hashValue := hash.Hash(append(data, byte(i)))
        //取下标offset
        locations[i] = uint(hashValue % uint64(f.bits))
    }
  
    return locations
}
  1. 继续查看Filter上的Add()方法与Exists()方法,都会先获取到底层的redisBitSet变量,调用该结构体上的set()方法添加数据,调用用该结构体上的check()方法检查数据是否存在,查看redisBitSet 结构体上绑定实现的方法
//检查偏移量offset数组是否全部为1
//是:元素可能存在
//否:元素一定不存在
func (r *redisBitSet) check(offsets []uint) (bool, error) {
    args, err := r.buildOffsetArgs(offsets)
    if err != nil {
        return false, err
    }
    //执行脚本
    resp, err := r.store.Eval(testScript, []string{r.key}, args)
    //这里需要注意一下,底层使用的go-redis
    //redis.Nil表示key不存在的情况需特殊判断
    if err == redis.Nil {
        return false, nil
    } else if err != nil {
        return false, err
    }

    exists, ok := resp.(int64)
    if !ok {
        return false, nil
    }

    return exists == 1, nil
}

//将k位点全部设置为1
func (r *redisBitSet) set(offsets []uint) error {
    args, err := r.buildOffsetArgs(offsets)
    if err != nil {
        return err
    }
    _, err = r.store.Eval(setScript, []string{r.key}, args)
    //底层使用的是go-redis,redis.Nil表示操作的key不存在
    //需要针对key不存在的情况特殊判断
    if err == redis.Nil {
        return nil
    } else if err != nil {
        return err
    }
    return nil
}

//构建偏移量offset字符串数组,因为go-redis执行lua脚本时参数定义为[]stringy
//因此需要转换一下
func (r *redisBitSet) buildOffsetArgs(offsets []uint) ([]string, error) {
    var args []string
    for _, offset := range offsets {
        if offset >= r.bits {
            return nil, ErrTooLargeOffset
        }
        args = append(args, strconv.FormatUint(uint64(offset), 10))
    }
    return args, nil
}

//删除
func (r *redisBitSet) del() error {
    _, err := r.store.Del(r.key)
    return err
}

//自动过期
func (r *redisBitSet) expire(seconds int) error {
    return r.store.Expire(r.key, seconds)
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值