-
Notifications
You must be signed in to change notification settings - Fork 82
性能优化
ghost_sa自2018年诞生,一直利用TiDB的乐观事务支持实现数据接收和写入的无状态,以便可以方便的进行横向拓展。毕竟对小微企业来说,升配的成本远低于雇人维护有状态的程序或改进性能。不过这也限制了ghost_sa对TiDB的依赖,在非TiDB的环境下,ghost_sa的性能表现并不出色,这一直是一个计划改善的点。加上2024年5月,TiDB从8.0版本开始,停用了乐观事务,整体转向悲观事务,虽然这不会造成ghost_sa因此报错,但降低了ghost_sa的入库速度。所以在2024年7月14日,ghost_sa发布了不依赖乐观事务的版本,在基本不牺牲可靠性,也不需要对原有数据模型做修改的前提下,实现了入库速度的大幅提高,远超此前的版本。
1.入库速度提升5-20倍
2.device快查表,支持根据不同的部署时机调整更新策略。
3.上报端可以选择app优先还是js优先的上报方式,可以提高优先场景的接收速度。
4.device表更新的逻辑完全重写,替换掉了之前全局变量的危险用法,现在所有的代码文件打开。IDE不会再有报错,降低理解成本。
5.增加了数据库操作时的错误判断,如果不是重试能解决的问题,就不再重试了。提高数据库操作的效率。
ghost_sa之前为了保证ghost_sa在任何使用过程中都是无状态的,采用的是每收到一条,都插入event表(必选),device表(必选)和properties表(可选)的模式。其中除了event表是每条都必须写入的埋点数据外,device表和properites表都会跟着频繁的更新。前者是单纯的插入,后者是根据主键更新。这就产生了两个问题:
1.并发写入时,由于非实时系统的时间戳精度和网络延迟,tidb的分布式结构根据主键更新的操作会产生大量的冲突,在无冲状态下,该语句执行在20ms左右,在并发时,最糟糕的情况下等锁释放需要300ms左右。造成插入一条数据,需要1000/(20+300)*并发数的入库性能限制,这还是在使用tidb乐观事务的情况下。所以之前建议并发不要太多,9个左右就可以了(因为更大的并发数,会造成锁冲突的时间延长),理想的情况下,入库能力在1秒1500左右,取决于tidb的部署方式和性能,糟糕的时候,理论入库能力会下降到1秒25条左右。
2.因为tidb的分布式特性,不存在真正的数据更新这个动作,更新时都是写入新数据删除旧数据。与插入动作同频的主键更新,会造成大量的磁盘操作以及同步开销,也会有大量的log文件产生。这部分文件在早期tidb版本中,不总是能正确的触发文件GC,造成大量的空间浪费。会出现tidb缩扩容后,数据容量总整体2T下降到1T的离奇现象(因为缩扩容的rebalance任务正确的触发了之前无法触发的GC)。
问题2是问题1的衍生问题,不需要单独处理,问题1的解决方法如下。
在写入event的时候,不再同步写入device表。而是先把device表需要的信息,写到内存里,并在内存里进行整理,定期将内存里的数据更新到device表。实现device表操作数量的减少。正常情况下,根据业务的不同,每个页面变化带来的埋点数量在4-200个左右。而这一个页面内,需要更新到device表的内容几乎是完全一样的。这样合并后只写最新状态的操作,可以大幅减少device表的操作数量。更新操作数,下降到更新间隔之间能写入的event数量所涉及到的活跃用户数。
比如写event表能做到5000的qps,device表的写入间隔是2分钟。那device表就只需要更新5000602=120000条数据所涉及到的distinct_id了,以上面讨论的4-200取中间值100算,就只需要update1200次。因为每个update是操作不同的distinct_id,不存在锁冲突,一次操作只需要30ms左右。哪怕单线程更新,也只需要301200=36000ms 1分钟不到就可以写入了。而原本300120000=36000000ms的数据库时间,10个线程更新,也要差不多1小时左右。
这个提升在越大的tidb规模上体现的越明显,规模越大,锁冲突等待的时间越长。
由于不是同步写device表了,增加了缓存,就存在程序意外关闭导致的数据丢失的可能性。针对这个问题,在写device表的过程中,会遵循如下逻辑:
处理每条埋点信息时,先查询内存里是否有该设备的信息,如果没有,则从数据库里查询。如果有,则直接更新内存里的数据,等下次写入的时候,再更新数据库。
如果数据库里找不到该设备的信息,则立即插入一份到数据库里。这样能保证,哪怕程序崩溃,device表跟event表的distinct_id量级上保持一致,最多就是device表的数据旧了几分钟。
数据从内存里写入device表之后,会检查内存里的数据是否超期,超期会删除掉。如果还比较新鲜,就先留在内存里,以此来减少上一个步骤时对数据库查询的压力。这里仍然会有风险,就是如果用户量非常大,且非常活跃。可清理的数据就比较少。在清理完之后,会检查缓存的内存占用,如果仍然超大。就会彻底清理掉所有数据,防止OOM。这里没做逐级过期,是因为没有意义,逐级还是会出现超大的问题,正确的操作应该是调小写device的间隔和过期时间。
1.升级到2024年7月14日及以后的版本,在configs.admin.use_kafka开关打开的情况下,默认开启加速模式。
2.如果你的tidb已经升级到8.0以上,修改configs.admin.database_type 为 'mysql' 减少出错的可能性。如果tidb仍然是7.6以下,保持模式为 'tidb' 可以获得更好的性能。
3.新版本修改了device表里值的更新逻辑,如果需要与旧版逻辑持一致,仅提高性能,请参考下文修改device_source_update_mode = 'first_sight' 为 'latest_sight' 。具体逻辑差异及适用场景请参考下文。
所有的参数都在configs.admin中设置
默认是'fast',
是否使用快速模式,这个设置必须在use_kakfa开关打开的时候才有效。如果不使用Kafka,则默认使用原始模式写入。
设定为'original'时,就是每条埋点都基于主键更新device表的模式。跟之前的操作逻辑完全一致。
设定为'fast'时,就是本次更新的新功能,合并到内存后,再定时更新device表。
未来还会支持一个'boost'模式,使用连接池和event批量写入更新,目前没支持,计划在2025年后支持。之前由于同步写入的锁等待,这些操作都没意义。现在使用缓存之后,这么做会进一步提高性能,但在程序崩溃时,也会带来更大的风险。
默认是False,
True时,会插入properties表,这个表不是必须的,只是方便提取数据时快速找到埋点里包含的变量,目前有比较严重的性能问题,不建议开启。这个表计划在2025年左右进行优化,优化后会具备完整的元数据能力,且不再有性能问题。
默认是'first_sight', 用来设定device表用户首次信息的更新模式,这个设定主要取决于ghost_sa的部署时机和使用目的。
1.用ghost_sa部署后的第一次来访信息作为这部分信息,那这时可以选择'restrict'。restrict模式是严格按照用户到访的第一次信息记录,如果用户第一次来访时没有携带对应的信息,如用户之前收藏过,从收藏夹进入可能就没有referrer和utm了,使用restrict模式,就会严格记录为空,哪怕后续有信息补充了,也会维持空。
2.使用第一次能获取到信息的来访信息作为这部分信息,那么这时可以选择'first_sight'。比如用户收藏了,每次来的时候信息都是空,这不会更新device表,会一致维持为空。但是有一次他换了新设备,重新从其他渠道登录了,如来自社交网络的转发,而那个渠道标记了utm,那么登录后,他的referrer会更新为社交网络的地址,utm会更新为对应的utm标记。因为用户的习惯相对比较有惯性,这样可以从用户的重复操作中,间接的获得用户的首次接触信息。但这也会造成数据理解上的一些问题,比如first_visit_time会比用户的最早活跃时间晚。使用'first_sight'模式,只会更新属于用户的首次来访信息里的空数据,如果用户的来访信息有值,那么就不会更新。来保证记录到的,就是能见到的第一次。
这个模式是旧版本默认的操作方式,即会把数据更新为最后一次获取到的值。老版本之所以这样定义,是因为默认用户不会清空cookie和重装app,如果发生重装,那就以重装后的第一次为准。这是符合推广部门的利益的。因为推广部门通常依赖用户的首次触达来考核不同渠道的ROI。埋点这套东西,主要还是服务推广运营的部门,这是一个为了方便项目实施,讨好推广部门利益的模式。
这次更新中修改了默认设置为'first_sight',是为了数据更中立。
默认是'latest_sight',
最近一个来访的信息是那些随着用户每次使用环境不同会发生变化的信息,比如用户的ip地址,以及最近一次来源。包含两种模式:
'restrict' 模式会严格的把用户的最后一次信息更新到表里,如果用户访问来自收藏夹,那为空就是为空,会更新为空。
'latest_sight' 模式跟前述的首次来访信息相似,会更新到最后一次看到的用户的来访信息。如倒数第二次来自微博,微博做了推广有utm,最后一次来自收藏夹,收藏夹没有做utm。那会保留最后一次来自微博的信息。可以理解为最后一次用户触达的广告信息。这个模式会比restrict模式更为有效的帮助运营人员判断哪些渠道是有用的,所以默认选取了这个模式。
缓存允许占用的内存,byte。默认是 is 50000000(500M) 超过设定之后,参照上文安全性部分所介绍,会在缓存写入数据库后清空。
内存检查的间隔,单位秒。默认是 30 秒。
因为频繁检查内存性能很差,所以超过一定设定后,才会检测。不超过会返回上次检查的值。目前这个功能没启用,在fast模式下,检查内存都是带参数强制检查的。
fast模式的入库间隔,单位秒,默认是300秒(5分钟),必须大于10。
多久从内存写一次到数据库,这个完全依赖对速度和可靠性的权衡。
更高的写入频率,丢数据的可能性就更小,数据更新的也更快,使用device表的时候,就有更高的实时性。但是每次开始写入,会先强制等待2秒让内存里的数据更新完毕,然后才开始写入,这样可以避免在内存里拷贝数据造成额外的内存占用,所以太小的间隔很影响写入的性能(但仍比原来快不知道哪里去了)。
调大间隔,可以缓存时间更久,按照用户平均使用产品的时间来算,能覆盖几个周期,就可以减少多少次device表的写入。如果用户平均使用时间22分钟,设定入库间隔为5分钟时,在用户这个session里会dump5次数据,更新device表5次。如果设定入库间隔是30分钟(疯了,一般10分钟足够了,30分钟怕内存不够),那么只会更新device表一次。但过长的间隔,就存在更大的OOM的风险。因为并发写入,想实现长间隔中间的内存检测,对性能的影响就会更大。合理设置就能解决的问题,就没在做个写入过程中检测内存超了就强制先入库的功能,设定间隔短一点就行了,因为是kakfa的消费者,这个消费速度是稳定的。这里也不存在DOS攻击时瞬间涌入大流量造成OOM的风险,因为这个fastmode是部署在消费端的,因为kakfa消费速度是稳定的。
是否优先尝试gzip解压数据,默认是False。
这个是生产端的开关,无论是否启用kafka都生效。
神策从2015年开始做sdk,迭代了很多次,关于上报数据是否压缩的标记,做不到所有sdk的所有版本都保持一致。注意这里是所有sdk的所有版本,有的版本有bug,如果不幸遇到了,那就是标记的不对。所以ghost_sa就没信任压缩标记,无论标记了压缩还是不压缩,都试试。按照python的程序结构,try不影响性能,except对性能影响较大。所以先try哪个,就直接决定了你的使用场景下,ghost_sa服务端的qps。
如果是js为主的sdk,无论是单条上报还是批量上报,通常都是不压缩的数据。如果是app的sdk,一般只有批量上报压缩数据的场景。所以看你的业务哪边埋点报的多了。