专栏首页Golang语言社区Go程序GC优化经验分享

Go程序GC优化经验分享

来源:http://1234n.com/?post/yzsrwa

最近一段时间对《仙侠道》的服务端进行了一系列针对GC的调优,这里跟各位分享一下调优的经验。

游戏第一次上线的时候,大部分精力都投入在做cpuprof和memprof找性能瓶颈和内存泄漏上,没有关注过Go的GC运行情况。

有一次cpuprof里的scanblock调用所占的比例让我注意到Go的GC所带来的性能消耗,记得那份cpuprof里,scanblock调用占到49%。也就是说有一半的CPU时间浪费在了GC上。

于是我开始研究如何进行优化,过程中免不了要分析数据,经过一番搜索,我好到了GOGCTRACE这个环境变量。

用法类似这样:

1GOGCTRACE=1 ./my_go_program 2> log_file

通过这个环境变量可以让Go程序在每次GC时都输出信息,信息是输出到标准错误的,所以需要用 2> 把输出重定向到文件里。

输出的内容像这样:

1gc16(8): 34+6+5 ms, 367 -> 365 MB 817253 -> 782045 (18216892-17434847) objects, 64(2182) handoff, 72(22022) steal, 553/244/51 yields

其中gc16表示第16次进行GC,后面的(8)表示由8个线程执行,这个线程数对应GOMAXPROCS环境变量,34+6+5 ms分别代表一系列GC动作消耗的时间,这三个时间加起来45ms,就是这个程序在这次GC过程中暂停的时间。

后面接着的是内存、对象数量等,在GC前后的变化,其中最关键的是对象数量,这边可以看到GC后还有782045个对象存在。

我在实际游戏服和内网开发测试服都开启了GOGCTRACE,发现GC暂停时间相差甚大,当时(还未做第一次优化前)外网GC暂停达到400多ms,而内网才20ms。

显然跟内存中数据多少有关系,于是我推测跟内存中对象数量关系最大,原因很简单,假设我是GC开发者,不可能让一个对象占用100M内存跟一万个对象占用100M内存同样消耗性能,显然那一个占用100M内存的对象,当我发现它不需要回收的话,我就不需要做什么事情了,而那一万个对象,我需要逐个检查是否还有被引用,所以内存大小不是关键,对象数量才是关键。

于是我按这个推测进行了第一次性能优化,我把存储游戏内存数据的链表结构改为slice,当初设计成链表是因为数据有插入和删除,slice可以扩容但是要收缩就比较麻烦了,于是想到了链表,链表要删除单个节点的时候,只需要把节点从链表上断开,不需要复制数据,效率高于数组结构。这里直观的表示一下两种数据结构的区别:

 1type MyData1 struct {
 2        next *MyData
 3        Id   int
 4        Name string
 5    }
 6    var mydata1 *MyData1
 7    type MyData2 struct {
 8        Id   int
 9        Name string
10    }
11    var mydata2 []MyData2

上面示例代码的mydata1用的是链表结构,每个节点都有一个指向下一个节点的指针,想像下存储1万个对象到mydata1,是不是需要创建1万个MyData1类型的对象。

示例中的mydata2用的是slice结构,一个slice就是一个对象,其中的元素都是这一块内存中的值,而不是对象,需要注意 []MyData2 和 []*MyData2 是不一样的,如果换用第二种写法,那么每个元素一样都是一个对象,因为这时候slice存的不是值而是指向对象的指针,而这些指针每一个都分别指到一个对象。

我做了一组不同数据结构跟对象数量关系的实验,可以直观的感受区别:github链接

经过这番改造,对象数量少了一个数量级,具体对少对象我已经记不得了,但是可以自己估计一下,一个mydata1这样的内存表,假设平均20条记录,假设有50个这样的表,就是1000个对象,换成mydata2这样的内存表,就只要50个对象。

当然这样一换,内存占用肯定就上去了,但是实际观测下来,内存占用在可接收范围,甚至还是远小于之前我用erlang开发的游戏,而GC扫描时间从300多ms降到几十ms,降了一个数量级。

本来优化到此我就打算告一段落了,但是随着游戏的持续运行,数据的持续增加,我发现slice自身占用的对象数量也还是值得动动脑筋消除掉的,线上GC暂停时间最高的服务器,达到了100ms,如果再涨上去,一样还是可能达到200ms设置300ms。

所以又继续懂了一些脑筋,比如把玩家数据压缩起来,等需要用的时候再解开来用,尝试过json序列化等等,目的都是把多个对象归并成一个。

但是这些方案都是牺牲数据访问的效率为代价的,需要访问数据时就要反序列化展开数据。

其实在第一次优化时,我大部分时间花在尝试cgo上面,而不是尝试slice上,我第一个思路是用cgo申请内存,伪造成go的对象,这些对象就不受Go的GC管理里,也就不会对GC有负担。但是尝试下来,总是遇到各种指针异常,我可以确信不是我的指针运算问题,但是为什么自己申请的内存会影响到Go的执行,我一直弄不明白,时间不等人,不可能一直研究下去,所以我才想了slice的这个方案,不是最优解但至少暂时解决问题。

而这一次,因为使用了slice,原先的内存数据库的数据结构就变得很单一,而优化的目的也明显,减少slice的内存消耗。正好那阵子我在尝试将SpiderMonkey嵌入到Go,接触到了cgo操作slice的一些技巧,比如将C的数组映射成Go的slice,或者利用reflect.SliceHeader取得slice所指向的内存块地址,然后用cgo复制数据。

于是我就想到用C来申请slice所需内存块,然后自己构造SliceHeader的办法。

这里需要说明下SliceHader和slice之间的关系。

Go提供了一个很有用的数据结构slice,slice比起C时代的数值有很明显的优势,有边界判断、可以反复切割、没有牺牲运行效率,如何做到的呢?官方这片文章有很清楚的说明:

url--https://blog.golang.org/go-slices-usage-and-internals

简单说来,Go的slice其实是一个三个字段的结构体,三个字段分别存放着slice的当前长度、内存块的大小和实际内存块的地址,每次len(slice)的时候是不需要循环计算长度的,只是到结构体里去一下长度,而重新切割的过程,只是重新构造一个指向同一个内存块或块中某一位置的过程,所以不会有内存拷贝和循环等消耗性能的操作。

这个三个字段的结构体,在Go的反射包里面使用SliceHeader类型表示,这让我们的程序有机会构造自己的SliceHeader。

cgo的wiki文档里有这样一段示例代码,演示如何把C的数组包装成Go的slice:

 1import "C"
 2import "unsafe"
 3...
 4        var theCArray *TheCType := C.getTheArray()
 5        length := C.getTheArrayLength()
 6        var theGoSlice []TheCType
 7        sliceHeader := (*reflect.SliceHeader)((unsafe.Pointer(&theGoSlice)))
 8        sliceHeader.Cap = length
 9        sliceHeader.Len = length
10        sliceHeader.Data = uintptr(unsafe.Pointer(&theCArray[0]))
11        // now theGoSlice is a normal Go slice backed by the C array

这边用到了unsafe.Pointer,通过Pointer类型,我们可以在Go的程序里实现指针运算,之前我有写过相关文章,这里就不重复介绍了:

Go语言中的指针运算

Go语言的语法上是不支持指针运算的,所有指针都在可控的一个范围内使用,没有C语言的*void然后随意转换指针类型这样的东西。最近在思考Go如何操作共享内存,共享内存就需要把指针转成不同类型或者对指针进行运算再获取数据。

晚上对Go语言内置的unsafe模块做了一个实验,发现通过unsafe模块,Go语言一样可以做指针运算,只是比C的方式繁琐一些,但是理解上是一样的。

下面是实验代码:

 1package main
 2import "fmt"
 3import "unsafe"
 4type Data struct {
 5    Col1 byte
 6    Col2 int
 7    Col3 string
 8    Col4 int
 9}
10func main() {
11    var v Data
12    fmt.Println(unsafe.Sizeof(v))
13    fmt.Println("----")
14    fmt.Println(unsafe.Alignof(v.Col1))
15    fmt.Println(unsafe.Alignof(v.Col2))
16    fmt.Println(unsafe.Alignof(v.Col3))
17    fmt.Println(unsafe.Alignof(v.Col4))
18    fmt.Println("----")
19    fmt.Println(unsafe.Offsetof(v.Col1))
20    fmt.Println(unsafe.Offsetof(v.Col2))
21    fmt.Println(unsafe.Offsetof(v.Col3))
22    fmt.Println(unsafe.Offsetof(v.Col4))
23    fmt.Println("----")
24    v.Col1 = 98
25    v.Col2 = 77
26    v.Col3 = "1234567890abcdef"
27    v.Col4 = 23
28    fmt.Println(unsafe.Sizeof(v))
29    fmt.Println("----")
30    x := unsafe.Pointer(&v)
31    fmt.Println(*(*byte)(x))
32    fmt.Println(*(*int)(unsafe.Pointer(uintptr(x) + unsafe.Offsetof(v.Col2))))
33    fmt.Println(*(*string)(unsafe.Pointer(uintptr(x) + unsafe.Offsetof(v.Col3))))
34    fmt.Println(*(*int)(unsafe.Pointer(uintptr(x) + unsafe.Offsetof(v.Col4))))
35}

以上代码在我机器上的执行结果如下(结果会因机器和系统的不同而不太一样):

 132
 2----
 31
 44
 58
 64
 7----
 80
 94
108
1124
12----
1332
14----
1598
1677
171234567890abcdef
1823

unsafe模块的文档中提到几条转换规则,理解了以后就很容易做指针运算了:

  1. A pointer value of any type can be converted to a Pointer.
  2. A Pointer can be converted to a pointer value of any type.
  3. A uintptr can be converted to a Pointer.
  4. A Pointer can be converted to a uintptr.

于是我将内存数据库用到的slice类型全部换成自己用C伪造的slice,还好当初内存数据库用的是代码生成器,否则代码就要改死掉了 :)

全部替换完后,我拿外网同样数据对比,优化前的程序GC扫描时间100多ms,对象数量140万,优化后的程序GC扫描时间18ms,对象数量16万。

本来可以就这样打完收功了,但是生活总是充满戏剧性,内网测试的时候发现好友列表里面的名字全乱码了,肯定跟优化有关系,但为什么会乱码呢?

我的推测是go构造的字符串对象被C构造的对象引用,这样的引用导致go把字符串对象当成没人使用,于是就被回收利用了。

我只好把所有字符串字段也全部改为C伪造的对象,原理给伪造slice是一样的,不同的是字符串用StringHeader表示。

经过改造,字符串再也不会乱码了,不过需要很小心的释放内存。

优化过程中Go提供的pprof模块起到了很重要的作用,所有的优化都是以数据为依据的,如果不能看到数据就没有办法定位问题。

程序中可以用 pprof.Lookup("heap") 来获得堆信息,其中包含了对象数量和GC执行时间等有用的数据。

上次群里有人问 map[int]XXX 这样的数据结构是否会有GC问题,正好这个数据结构我之前也考虑过,也在上面的数据结构实验里体现了,map[int]XXX 和 map[int]XXX是一样的,一条数据就是一个对象,对GC是否有影响取决于对象的数量。

从上面的观测数值来看百来万的对象数量所造成的暂停应该还不足以影响程序,除非应用场景对实时性要求非常高。

但是对于游戏这样的常驻内存程序来说,对象的增长速度和对象数量上限也需要留意,比如刚开始对象数量只有几万,随着日子增长,玩家数据增多,对象数量达到百万千万,那时候可能就会有影响了。

之前第一次优化过后正好有人在知乎问Go的GC情况,我回了一帖,里面有比较详细的第一次优化的数据,大家可以参考一下:

Go 的垃圾回收机制在实践中有哪些需要注意的地方?

作者:达达 链接:https://www.zhihu.com/question/21615032/answer/18781477 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

之前回答问题的时候Go还处在1.1版本,到了1.2和1.3,Go的GC跟踪命令和GC内部实现已经有一些变化,并且根据评论中的反馈,这边一并做补充说明。

Go 1.2之后的GC跟踪环境变量已经改为GODEBUG="gctrace=1",具体参数说明可以参考runtime包的文档。

Go 1.3对GC做了优化,回收机制也改变了,从我的实验观测来看,用做内存存储时候产生的持久性的大量对象,一样是明显拖慢GC暂停时间的,但是函数内创建的局部对象一旦没被引用,是会被立即回收的,可以用runtime.SetFinalizer()观测到这个现象,我利用这个现象在v8.go项目做了一个engine实例销毁的单元测试。

这里需要提醒大家,在平时开发或学习的时候gc是透明的,好像不存在一样,gc只在影响到业务的时候才会让人想起来有这样一个东西存在。

gc什么时候才会影响到业务呢?举个例子,比如业务需求是延迟不得大于100ms,当gc暂停超过100ms时,就明显影响到业务了。

而这篇回答针对的是gc影响的业务时的问题排查和优化方案,以及出问题前的提前自检。

请不要因为这篇帖子就误以为gc是很恐怖的。

接着补充一下我对技术分享的看法,有读者反馈一些描述比较容易误导新手,这当然不是我想看到的,技术分享本是好意,如果误导了新人就不好了。

为避免误会,这里说明一下,这个帖子的问题是“Go 的垃圾回收机制在实践中有哪些需要注意的地方?”,所以你正在阅读的这个答案是针对Go语言回答的,其中的一些经验和思路可以用在其他语言,但肯定是不能照搬的。

另外,语言表达的东西总是不那么严谨的,不同人可能产生不同理解,特别是对感受的描述,比如“多”、“少”、“大”、“小”、“长”、“短,这种没给出具体数值的描述,不同人可能有不同的理解,所以参考价值比较低。

所以,对于分享的内容中,比较模糊,比较难以界定,没给出具体数据的部分,希望能抛砖引玉,大家也来实验一下,补充更多数据。对于已经给定数据的部分,也希望大家不要看一下就过了,最好也能实验一下证明数据给的是对的,自己也才有直观感受,万一数据给错了,也才能通过众人之力修订正确。

我尽量在分享时提供方法,而不是纯感受或纯数据,希望可以众人拾柴火焰高,让后来者可以有更高的一个起点,不需要重新填坑,最后整个技术社区的水平能一起提升。

============= 原文 =============

不想看长篇大论的,这里先给个结论,go的gc还不完善但也不算不靠谱,关键看怎么用,尽量不要创建大量对象,也尽量不要频繁创建对象,这个道理其实在所有带gc的编程语言也都通用。

想知道如何提前预防和解决问题的,请耐心看下去。

先介绍下我的情况,我们团队的项目《仙侠道》在7月15号第一次接受玩家测试,这个项目的服务端完全用Go语言开发的,游戏数据都放在内存中由go 管理。

在上线测试后我对程序做了很多调优工作,最初是稳定性优先,所以先解决的是内存泄漏问题,主要靠memprof来定位问题,接着是进一步提高性能,主要靠cpuprof和自己做的一些统计信息来定位问题。

调优性能的过程中我从cpuprof的结果发现发现gc的scanblock调用占用的cpu竟然有40%多,于是我开始搞各种对象重用和尽量避免不必要的对象创建,效果显著,CPU占用降到了10%多。

但我还是挺不甘心的,想继续优化看看。网上找资料时看到GOGCTRACE这个环境变量可以开启gc调试信息的打印,于是我就在内网测试服开启了,每当go执行gc时就会打印一行信息,内容是gc执行时间和回收前后的对象数量变化。

我惊奇的发现一次gc要20多毫秒,我们服务器请求处理时间平均才33微秒,差了一个量级别呢。

于是我开始关心起gc执行时间这个数值,它到底是一个恒定值呢?还是更数据多少有关呢?

我带着疑问在外网玩家测试的服务器也开启了gc追踪,结果更让我冒冷汗了,gc执行时间竟然达到300多毫秒。go的gc是固定每两分钟执行一次,每次执行都是暂停整个程序的,300多毫秒应该足以导致可感受到的响应延迟。

所以缩短gc执行时间就变得非常必要。从哪里入手呢?首先,可以推断gc执行时间跟数据量是相关的,内网数据少外网数据多。其次,gc追踪信息把对象数量当成重点数据来输出,估计扫描是按对象扫描的,所以对象多扫描时间长,对象少扫描时间短。

于是我便开始着手降低对象数量,一开始我尝试用cgo来解决问题,由c申请和释放内存,这部分c创建的对象就不会被gc扫描了。

但是实践下来发现cgo会导致原有的内存数据操作出些诡异问题,例如一个对象明明初始化了,但还是读到非预期的数据。另外还会引起go运行时报申请内存死锁的错误,我反复读了go申请内存的代码,跟我直接用c的malloc完全都没关联,实在是很诡异。

我只好暂时放弃cgo的方案,另外想了个法子。一个玩家有很多数据,如果把非活跃玩家的数据序列化成一个字节数组,就等于把多个对象压缩成了一个,这样就可以大量减少对象数量。

我按这个思路用快速改了一版代码,放到外网实际测试,对象数量从几百万降至几十万,gc扫描时间降至二十几微秒。

效果不错,但是要用玩家数据时要反序列化,这个消耗太大,还需要再想办法。

于是我索性把内存数据都改为结构体和切片存放,之前用的是对象和单向链表,所以一条数据就会有一个对象对应,改为结构体和结构体切片,就等于把多个对象数据缩减下来。

结果如预期的一样,内存多消耗了一些,但是对象数量少了一个量级。

其实项目之初我就担心过这样的情况,那时候到处问人,对象多了会不会增加gc负担,导致gc时间过长,结果没得到答案。

现在我填过这个坑了,可以确定的说,会。大家就不要再往这个坑跳了。

如果go的gc聪明一点,把老对象和新对象区别处理,至少在我这个应用场景可以减少不必要的扫描,如果gc可以异步进行不暂停程序,我才不在乎那几百毫秒的执行时间呢。

但是也不能完全怪go不完善,如果一开始我早点知道用GOGCTRACE来观测,就可以比较早点发现问题从而比较根本的解决问题。但是既然用了,项目也上了,没办法大改,只能见招拆招了。

总结以下几点给打算用go开发项目或已经在用go开发项目的朋友: 1、尽早的用memprof、cpuprof、GCTRACE来观察程序。 2、关注请求处理时间,特别是开发新功能的时候,有助于发现设计上的问题。 3、尽量避免频繁创建对象(&abc{}、new(abc{})、make()),在频繁调用的地方可以做对象重用。 4、尽量不要用go管理大量对象,内存数据库可以完全用c实现好通过cgo来调用。

手机回复打字好累,先写到这里,后面再来补充案例的数据。

数据补充:

图1,7月22日的一次cpuprof观测,采样3000多次调用,数据显示scanblock吃了43.3%的cpu。

图2,7月23日,对修改后的程序做cpuprof,采样1万多次调用,数据显示cpu占用降至9.8%

数据1,外网服务器的第一次gc trace结果,数据显示gc执行时间有400多ms,回收后对象数量1659922个:

1gc13(1): 308+92+1 ms , 156 -> 107 MB 3339834 -> 1659922 (12850245-11190323) objects, 0(0) handoff, 0(0) steal, 0/0/0 yields

数据2,程序做了优化后的外网服务器gc trace结果,数据显示gc执行时间30多ms,回收后对象数量126097个:

1gc14(6): 16+15+1 ms, 75 -> 37 MB 1409074 -> 126097 (10335326-10209229) objects, 45(1913) handoff, 34(4823) steal, 455/283/52 yields

示例1,数据结构的重构过程:

最初的数据结构类似这样

 1// 玩家数据表的集合
 2type tables struct {
 3        tableA *tableA
 4        tableB *tableB
 5        tableC *tableC
 6        // ...... 此处省略一大堆表
 7}
 8// 每个玩家只会有一条tableA记录
 9type tableA struct {
10        fieldA int
11        fieldB string
12}
13// 每个玩家有多条tableB记录
14type tableB struct {
15        xxoo int
16        ooxx int
17        next *tableB  // 指向下一条记录
18}
19// 每个玩家只有一条tableC记录
20type tableC struct {
21        id int
22        value int64
23}

最初的设计会导致每个玩家有一个tables对象,每个tables对象里面有一堆类似tableA和tableC这样的一对一的数据,也有一堆类似tableB这样的一对多的数据。

假设有1万个玩家,每个玩家都有一条tableA和一条tableC的数据,又各有10条tableB的数据,那么将总的产生1w (tables) + 1w (tableA) + 1w (tableC) + 10w (tableB)的对象。

而实际项目中,表数量会有大几十,一对多和一对一的表参半,对象数量随玩家数量的增长倍数显而易见。

为什么一开始这样设计?

1、因为有的表可能没有记录,用对象的形式可以用 == nil 来判断是否有记录 2、一对多的表可以动态增加和删除记录,所以设计成链表 3、省内存,没数据就是没数据,有数据才有对象

改造后的设计:

 1// 玩家数据表的集合
 2type tables struct {
 3        tableA tableA
 4        tableB []tableB
 5        tableC tableC
 6        // ...... 此处省略一大堆表
 7}
 8// 每个玩家只会有一条tableA记录
 9type tableA struct {
10        _is_nil bool
11        fieldA int
12        fieldB string
13}
14// 每个玩家有多条tableB记录
15type tableB struct {
16        _is_nil bool
17        xxoo int
18        ooxx int
19}
20// 每个玩家只有一条tableC记录
21type tableC struct {
22        _is_nil bool
23        id int
24        value int64
25}

一对一表用结构体,一对多表用slice,每个表都加一个_is_nil的字段,用来表示当前的数据是否是有用的数据。

这样修改的结果就是,一万个玩家,产生的对象总量是 1w (tables) + 1w ([]tablesB),跟之前的设计差别很明显。

但是slice不会收缩,而结构体则是一开始就占了内存,所以修改后会导致内存消耗增大。

参考链接:

go的gc代码,scanblock等函数都在里面: http://golang.org/src/pkg/runtime/mgc0.c

go的runtime包文档有对GOGCTRACE等关键的几个环境变量做说明: http://golang.org/pkg/runtime/

如何使用cpuprof和memprof,请看《Profiling Go Programs》: http://blog.golang.org/profiling-go-programs

我做的一些小试验代码,优化都是基于这些试验的数据的,可以参考下: go-labs/src at master · idada/go-labs · GitHub

本文分享自微信公众号 - Golang语言社区(Golangweb)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-06-07

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Go语言内存分配器的实现

    前面断断续续的写了3篇关于Go语言内存分配器的文章,分别是Go语言内存分配器设计、Go语言内存分配器-FixAlloc、Go语言内存分配器-MSpan,这3篇主...

    李海彬
  • Go 的垃圾回收机制在实践中有哪些需要注意的地方?

    之前回答问题的时候Go还处在1.1版本,到了1.2和1.3,Go的GC跟踪命令和GC内部实现已经有一些变化,并且根据评论中的反馈,这边一并做补充说明。 Go ...

    李海彬
  • Go 语言内存管理(二):Go 内存管理

    了解操作系统对内存的管理机制后,现在可以去看下 Go 语言是如何利用底层的这些特性来优化内存的。Go 的内存管理基本上参考 tcmalloc 来实现的,只是细节...

    李海彬
  • Go GC 20 问

    本文作者欧长坤,德国慕尼黑大学在读博士,Go/etcd/Tensorflow contributor,开源书籍《Go 语言原本》作者,《Go 夜读》SIG 成员...

    梦醒人间
  • Java垃圾回收器与内存分配策略

    上一篇JVM内存模型讲述了Java虚拟机在运行时所管理的内存划分下的每个数据区域的各自用途,以及创建和销毁时间。当需要排查各种内存泄漏、内存溢出问题时,当来及收...

    静默加载
  • JVM之GC与对象的引用(三)

    GC,从当下的企业级应用开发来看,是大多数攻城狮必须具备和get的技能。但又有很多人不愿意去面对它(比如我

    23号杂货铺
  • JVM-4. 垃圾收集算法

    给对象添加一个引用器,有一个地方引用就加1,引用失效就减1;任何时刻计时器为0的对象就不被使用。

    悠扬前奏
  • LT【深入Java虚拟机】之八:Java垃圾收集机制

    转载请注明出处:http://blog.csdn.net/ns_code/article/details/18076173

    bear_fish
  • [JVM] 垃圾收集器与内存分配策略

    经过发展,内存动态分配和回收技术已经成熟,为什么还要了解GC和内存分配呢? 当需要排查各种内存溢出,内存泄露问题时,当垃圾手机成为系统达到更高并发量的瓶颈时,就...

    wOw
  • 给JDBC换个装——连接解耦

    泰斗贤若如

扫码关注云+社区

领取腾讯云代金券