UBC SDK 日志级别重复率优化实践
作者 | wunan
导读
日志中中台每日传输的日志 PV 量级可达千亿级,在上报过程中减少冗余日志数据,能够降低下游数据处理的难度和成本,提高数据的准确性和质量,更好地支持业务系统的运行和优化。本文介绍了 UBC SDK 对日志重复打包的优化实践,通过对数据库、进程和打点机制进行优化,有效地将日志级别重复率由千分之三降低至万分位。
全文 8525 字,预计阅读时间 22 分钟。
01 引言
文章首先对日志中台的去重背景和 UBC SDK 的打点机制进行了介绍,然后说明了重复问题的定位难点和手段,最后重点围绕重复问题的分析和解决实践进行说明。
1.1 日志中台去重背景
日志中台聚焦端日志全生命周期的能力建设,包括日志数据采集、传输、管理以及查询分析等功能,实现对打点数据的全生命周期管理,解决新老产品的日志规范与统一等问题,提升平台资源利用率,发挥日志的最大应用价值。
为保证数据下游高效且准确地收到数据,日志中台在端到端数据传输的时效、可靠性上进行了长期的优化并取得了显著效果,然而这些优化也使得数据重复问题逐渐凸现出来。
重复问题即同一条日志被 SDK 上报至服务端不止一次。为降低服务端 QPS,SDK 会将多条日志打包上报,因此重复问题可分为两类:
1.包重复:SDK 发送了数据但未收到结果,服务端已落盘,但端上包未被删除,包内日志整体重复上报。
2.日志重复:单条日志被打入多个包,分别随每个包上报至服务端。
日志中台服务端上线了流式去重方案,保证下游数据不存在包粒度重复。服务端通过包 md5 执行去重,但同一条日志被打入不同包会导致 md5 不同,这类情况服务端无法识别。因此服务端去重的前提,是由 SDK 保证上报的日志没有重复打包的情况。
1.2 UBC SDK 简介
UBC(User Behavior Collection) SDK,即用户行为收集 SDK,负责端打点日志的采集、封装、上报等功能,记录用户行为数据并上传至日志中台服务端,是日志中台数据传输链路的源头。
1.2.1 打点类型
根据打点所表达的内容,可分为事件打点和流式打点,分别记录单次行为和停留行为。
不同业务方对点位上报的时效有不同的需求,由慢到快可分为:非实时打点、实时打点、直发打点和 reallog 打点,不同类型的打点执行流程存在差异。
1.2.2 打点机制
UBC SDK 承载着厂内绝大部分数据的记录和传输,双端单日可上报千亿条日志,需要在不影响主线程执行的基础上,又快又准地上报打点。为了实现这个目标,UBC 采取了以下措施:
1.分阶段异步处理:为了保证打点上报的效率和稳定性,SDK 内部将整个流程拆分为多个阶段,交给任务线程和发送线程异步调度执行。
2.日志和包持久化存储:为了保证日志不丢失,SDK 会将日志持久化到 SQLite,发送时将日志持久化到文件,重复尝试发送直至成功。
3.多种上报触发时机:为了兼顾上报吞吐和时效,SDK 会在不同的时机触发日志上报,如实时打点立即触发、非实时打点达到上报周期触发、前后台切换或网络恢复时上报积压数据等。
UBC SDK 核心模块架构如下图所示:
UBC 日志管理、存储和发送的基本流程如下图所示:
02 定位手段
2.1 定位难点及方法论
排查端日志重复问题存在着以下难点:
1.使用 UBC 的业务方众多,不同点位触发场景不同,出现的问题也不同,无法了感知所有业务的调用方式。
2.日志重复多是小概率的底层异常事件导致,与用户本地环境、系统版本、厂商密切相关,问题难以复现。
3.UBC SDK 是数据的入口,牵一发而动全身,任何一个上报流程上的修改都可能造成已有数据的波动,影响业务统计。
因此,这类问题难以通过常用手段排查和优化,UBC 在处理过程中逐渐形成了一套方法论:
2.2 定位工具
2.2.1 线上日志标识
线上日志是监控包括重复率在内的指标最直接的途径,然而在千亿级的数据中寻找出重复打包的日志犹如大海捞针。
因此在定位重复问题时,各类标识是至关重要的,它们可以用于识别和关联不同的日志条目,从而帮助确定哪些问题是重复的。通过数据统计和监控报警,可以在 T+1 的时效发现问题,并直接通过参与统计的原始数据分析问题。
要发现存在重复的日志,首先需要唯一地确定一条日志。
日志 uuid:UUID(Universally Unique Identifier)是一个 128 位的字符串,可以确保在全局范围内唯一标识一个对象或事件,即实现在同一设备下唯一标识一条日志,用于日志记录和追踪。改都可能造成已有数据的波动,影响业务统计。
通过日志 uuid 可发现重复上报至 TC-Box 的日志,但要确定是包重复还是日志重复,需要结合打包信息判断,包的标识信息主要有两个:
1.包 md5:MD5(Message Digest Algorithm 5)是一种广泛使用的密码散列函数,可以接收任何长度的数据并生成一个固定长度的哈希值。UBC 通过包内容计算生成 md5 值,作为包的标识。
2.包 createtime:毫秒级打包时间戳。由于包内容相同时 md5 值也相同,若同一批数据一起被多次打包,则无法通过 md5 识别,因此需使用 md5+createtime 作为重复打包的识别标识。
除标识信息外,还需其他信息辅助进行判断,如:
1.timestamp:打点时的时间戳,可结合出现重复的打点前后日志推断用户操作。
2.appv:打点时的 app 版本号,评估优化效果时,需减少旧版本延迟上报的干扰。
3.process:执行打包的进程 ID 和进程名,多个进程同时存在 UBC 实例时,可能会由于进程不安全造成重复。
4.trigger:打包触发时机,正常情况下不同的上报时机有不同频次,上报时机出现频次异常也可辅助定位问题。
5.db_sync:SQLite 同步模式,可用于辅助验证用户数据库回写方式导致的异常。
2.2.2 异常监控
UBC 执行过程中可能会出现各类异常,为及时感知这些异常,UBC 需通过上报打点的方式进行监控。
不同类型的异常监控打点会携带打点特殊环节执行情况、异常堆栈等内容,辅助重复问题排查、异常量级统计和修复效果分析。
为避免异常情况导致 UBC 的正常流程无法执行,监控会绕开 UBC 存储,通过旁路进行存储和上报。
2.2.3 业务打点
UBC 作为服务层的 SDK,与业务解耦,因此只能对 SDK 自身行为进行监控,无法直接获取用户的操作路径和设备信息。
业务方的打点发生在用户操作的各种场景下,可以辅助还原用户的行为。例如,通过手百启动相关点位,判断用户前后台切换和冷启动行为;通过闪屏展现打点,获取用户电量以判断用户设备是否存在断电重启;通过其他打点记录的各类网络状态、磁盘等信息。
03 重复问题归因及解决
3.1 数据库相关问题
立足打点的执行流程,要排查单条日志重复打包的问题,首先想到的就是排查批量日志打包阶段。
该环节逻辑分支较多,且实时与非实时打点执行策略有所不同:
非实时打点触发后先写缓存、再写数据库,达到上报时机后批量查询数据库并触发上报;
实时打点先写入文件触发上报上报,写文件失败才写入数据库,写文件时会查询数据库追加一批非实时打点。
最初分析重复数据中,实时打点较少,多为需要入库的非实时打点和流式打点。因此在重复问题排查的第一阶段,重点对数据库相关问题进行了排查。
打包过程是"将日志从数据库批量转移至文件"的过程,蓝色框所示环节细化如下图所示。
正常情况下,若写文件后删除数据库中已打包日志执行失败,SDK 会终止上报流程并删除已生成的文件,中断发送流程避免重复打包。
该环节设计的初衷,即解决上报时直接读数据库导致重复发送的问题,但如果在此过程中存在 SDK 无感知的异常,则会直接导致当前文件成功发送,而日志未删除,这批日志在下次上报时会再次被查询打包,造成重复。
3.1.1 数据库损坏
按照 2.1 节提到的定位问题方法论,对出现重复的用户按照数据特征归类,对重复原因进行逐一归因,定位到占比较大的重复问题为数据库损坏。
(1)问题归因
数据特征
用户单日上报的 PV 量级巨大,严重的达百万级别(大盘上报 PV 单人单日平均约 2000 条左右)。
用户上报的大部分为重复日志,同一日志会被重复打包多次。
多次打包间隔时间很短,未经历冷启动。
大部分重复包的 md5 相同,但 createtime 不同,且触发条件为条数超限。
原因推测
删除已打包日志事务返回成功结果并执行上报,但实际删除结果为失败。
进一步分析
对删除已上报数据的代码进行 review,初步排除了一些影响因素:
整个逻辑加了同步锁,可排除其他线程的影响。
SQL 执行异常会被捕获并返回失败,删除当前文件且不上报。
监控了 delete 方法删除条数与预期不符的情况,但问题量级很小,对重复率影响不大。
进一步分析代码的两层 try-catch,发现了问题所在:
里层 try-catch:执行多条 SQL,成功后设置事务成功标识,并将结果标识设为 true。
外层 try-catch:提交事务并统计删除结果。
进入外层时,标识位已设置为 true,但提交事务的结果未被处理,推断为该环节出现未处理的异常导致了事务回滚。
佐证归因
为确定异常原因,对 SQL 异常监控进行了细化,记录 DatabaseHelper 类中所有捕获到的异常堆栈。
捞取重复用户的异常监控数据,发现存在大量 SQL 异常。堆栈如下,db.endTransaction 时出现了数据库损坏异常 SQLiteDatabaseCorruptException,佐证了代码分析结果。
崩溃堆栈
SQLite 官网对于数据库损坏问题也有说明:数据库读写过程中出现文件 fsync 失败、磁盘损坏、异常数据写入等问题,均有可能造成数据库损坏。
最终归因
数据库损坏可能导致部分日志可读但无法写。
事务提交前更新了删除数据的执行结果,按成功结果处理执行了上报,但由于数据库损坏导致事务回滚,数据未被成功删除。
每次上传成功后会检查数据库条数,超过阈值则会再次触发上传,单个包超限后停止写入,因此造成短时间内大量上报且包 md5 相同。
(2)优化方案
针对该问题,双端均需进行两步优化:
1.修正数据库事务执行结果,若事务提交发生异常则重置结果;
2.数据库损坏时进行重建,删除数据库文件及其附加的-journal,-shm,-wal 文件,数据库再次打开时会重新创建数据库文件。
风险评估
1.UBC 上报时效较高,大部分数据均能在 1min 内完成上报并从数据库清除,因此数据库中通常只会保留几条数据,直接删除损失的数据在可接受范围。
2.文件删除重建操作不宜频繁执行,需对数据库修复频次进行限制,并针对每次修复操作和结果进行统计。
3.数据库重建风险较大,因此采用 AB 实验定向下发的方式,确认有效且无其他影响后再逐步放量。
优化效果
1.实验初期,为存在大量重复上报的用户定向下发实验开关,均成功修复且修复后重复率归 0。
2.实验推全后,根据监控统计每日可成功修复这类数据库损坏 40+个,不再有大量重复问题出现。
3.优化数据库损坏问题后,大盘重复率下降至千分之二以下,下降约 0.07 个百分点,约占优化前的 35%。
3.1.2 WAL 文件写入失败
(1)问题归因
数据特征
出现重复的用户,一批打点均重复打包两次。
两次打包中有冷启动,且相隔时间较长。
后一次打包为冷启后首个包,打包触发条件为实时上报或网络恢复上报。
原因推测
用户设备出现系统级崩溃导致数据库操作执行成功,但回写失败。
进一步分析
从 Android 9.0 开始,默认开启了 SQLite 的 WAL(Write-Ahead Logging)模式。在 WAL 模式下,SQLite 会将修改操作记录在 WAL 文件中,在系统空闲时或事务提交时,执行 checkpoint 操作,再由 WAL 文件回写入磁盘的数据库文件中。SQLite 使用此机制来避免频繁的磁盘写入操作,提高数据库的写入性能和并发性。
修改操作写入 WAL 文件、WAL 文件回写入数据库文件时,都会经历先写入磁盘缓冲区、再执行 fsync 操作 flush 到磁盘的过程。而系统异常后事务是否能被恢复,关键在于 WAL 文件是否成功执行了 fsync。
常用的 WAL 同步模式主要有两种:PRAGMA schema.synchronous = NORMAL | FULL,二者区别在于 NORMAL 在写 WAL 文件时会在 fsync 执行前就返回提交结果,而 FULL 模式会在 WAL 文件真正写入磁盘后才返回结果。
SQLite 官方文档中提到:NORMAL 模式下,事务的提交在掉电或系统崩溃时可能会被回滚。
佐证归因
1.启动相关打点记录了用户电量,部分用户前一次打包时电量低于 10%,第二次打包时电量恢复,说明期间出现了掉电问题。
2.将数据库同步模式信息携带上报,经验证,发生此类重复问题的用户数据库模式均为 NORMAL。
最终归因
1.用户设备的 SQLite 使用了 WAL 的 NORMAL 模式,在删除事务写入 WAL 磁盘缓冲区后、缓冲区被同步到磁盘前,发生了掉电或系统崩溃。
2.事务被判断为成功而执行了上报,但未写入 WAL 文件,因此磁盘文件中数据未被删除,且系统恢复后也无法重新执行删除,已上报的数据仍存在。
(2)优化方案
根据重复原因,可推导出该问题的解决方案,即在数据库操作完成后尽可能快地保证操作被写入磁盘,有三种执行方式:(1) 关闭 WAL 模式;(2) 同步模式设为 FULL;(3) 手动执行 checkpoint。
Android 端通过多个方案对比和测试,最终得到折衷的方案:仅保证『删除已打包数据』这一数据库事务的可靠性,在同步模式为 NORMAL 时,删除数据后手动触发 checkpoint,触发 WAL 文件写入 DB 文件,保证 SQLite 至少完成了 WAL 文件的 fsync 操作。执行 checkpoint 耗时与执行事务耗时基本持平,性能劣化在可接受范围。
iOS 端在 WAL 模式下要保证事务完整写入,除设置同步模式为 NORMAL 外,还需要额外开启 fullfsync 选项,以严格保证写入顺序跟提交顺序一致。经测试评估,性能劣化大于重复率优化收益,最终选择不进行此优化。
优化效果
WAL 写入失败问题约占新版本长尾重复问题的 70%。
根据 Android 端实验数据,单小时内,实验组相比对照组减少 200+次重复打包。
3.2 进程相关
在优化数据库损坏造成的重复打包问题后,查询大盘数据,发现 Android 端仍存在日志重复打入两个包、且间隔时间很短的问题,且用户无数据库异常信息。
这种短时间内同时触发打包的问题,自然地会想到另一个问题:UBC 线程是否安全。然而,如 1.2.2 节所示,UBC 内部仅存在两个队列,读数据库和打包均为单线程操作,理论上不会出现线程并发问题。
因此,我们将目光转向了 Android 端的进程安全问题:
1.百度 APP 除主进程外,还存在小程序、媒体等多个子进程,不同进程均会调用打点 API。
2.为保证启动速度,UBC 采用了懒加载的初始化方式,当前进程无 UBC 实例时才会初始化一个 UBC 单例。
3.若业务方在非主进程调用 API,UBC 会 IPC 到主线程执行,以此来保证生命周期内只有主进程存在一个 UBC 单例。若该过程出现问题,可能会导致在子进程中初始化另一个 UBC 实例。
两个进程同时存在 UBC 单例时,就可能出现进程不安全的问题,如下图所示。
1.两个进程并发打包并操作数据库,一个进程在另一个进程删除数据前执行查询,则会导致同一批数据被写入两个文件。
2.删除操作执行的前后顺序会导致后一个执行删除的操作因数据不存在而返回删除条数为 0,但不会抛出异常而终止上报。
多进程问题导致初始化两个 UBC 单例的原因有两类:IPC 失败和进程判断失败,两种问题造成重复打包的表现和原因不同,下文一一进行介绍。
3.2.1 IPC 失败导致多个 UBC 实例
(1)问题归因
数据特征
出现重复的用户,一批打点重复打包两次。
两次打包相隔时间较短(不到 1s)。
打包触发条件中网络恢复、切后台出现次数较多。
重复的两条数据打包时进程 id 和进程名均不同。
原因推测
两个进程分别存在 UBC 实例,并发监听到系统事件,读取数据库打包。
进一步归因
经与多进程框架业务方沟通,梳理了出现两个 UBC 单例及重复打包的原因:
1.多进程框架从子进程 IPC 到主进程时如果发生错误,会在子进程触发 IPC 失败打点。
2.该打点调用了 UBC 已废弃的多进程打点旧接口,旧接口允许在子进程初始化一个 UBC 的单例,并添加对前后台切换及网络状况的监听。
3.前后台切换和网络恢复触发监听,造成两个 UBC 实例同时执行打包。
佐证归因
发生重复问题的两个包,其中一个均存在 IPC 失败点位,与分析结论一致。
(2)优化方案
针对该问题,经影响面评估,最终执行两步优化:
1.在子进程限制打包,前后台切换和网络恢复的触发时机,非主进程不执行打包流程;
2.优化多进程打点接口,非主进程下绕开 UBC 调度逻辑,避免初始化实例,直接将打点写入文件等待上报。
优化效果
优化该问题后,大盘重复率下降至千分之一以下,下降约 0.04 个百分点,约占优化前的 33%。
3.2.3 进程判断失败导致多个 UBC 实例
(1)问题归因
数据特征
出现重复的用户,一批打点重复打包两次。
两次打包相隔时间较短(不到 1s)。
打包触发条件中实时触发占多数,但存在两次均是非实时触发的情况,而正常情况下非实时打包至少应间隔 1 分钟以上。
两次打包其中的一个包存在子进程触发的打点(如小程序页面访问量),但无 IPC 失败打点。
原因推测
存在两个 UBC 实例,同时有打点 A 和 B 进入并触发打包,两个 UBC 实例并发,均查询到数据库中的打点 C 并打包上报。
进一步归因
结合数据表现和 IPC 失败问题优化结果,该问题并非 IPC 失败导致积压数据并发上报,而是由打点触发的多进程并发,因此主要的怀疑点为进程判断。
判断主进程时使用了多进程框架提供的方法,根据当前进程名是否等于包名判断是否为主进程,获取当前进程名的逻辑:
读取内存记录 → 从系统文件/proc/self/cmdline 中获取 → 从 ActivityManager 中获取
若前几种方式都获取失败,则使用包名作为进程名
包名通常与主进程一致,若在子进程以包名作为进程名,则会导致判断错误,从而在子进程初始化另一个 UBC 单例,造成重复打包问题。
佐证归因
用户两次打包进程名相同、进程 id 不同,与代码分析结论一致。
(2)优化方案
UBC 作为打点 SDK,不宜过多关注进程判断逻辑,因此需推动多进程框架升级,增加进程名获取的途径:
Android API 28 新增了获取进程名的方法 Application.getProcessName(),无需反射和 IPC,可作为进程名的冗余判断方式。
优化效果
进程判断失败造成的重复问题约占新版本重复打包长尾问题的 20%
优化后进程判断方式后,一秒内重复打包两次的情况减少约一半,新版本日志重复问题仅由不支持该 API 的低版本设备造成。
3.3 打点机制相关
3.3.1 reallog
(1)问题归因
reallog 是一种特殊的打点类型,在打点后不进行缓存,仅进行一次查询 I/O 操作,携带历史失败数据直接内存上报,上报失败后写入数据库。
这种机制是为保证数据极致的上报时效而设置的,但在异常情况下会造成 reallog 的重复打包:日志已上报成功并落盘,但服务端结果未及时返回,UBC 按失败逻辑将日志存入数据库,并随下一个 reallog 打点触发时被查询并上报。
如上图所示,reallog 类型的打点会独立打包上报,在 url 中携带 reallog=1 标识。利用此标识对重复数据进行验证,佐证了 reallog 造成的重复问题。
(2)优化方案
针对 reallog 问题的优化,可采用两种方案:日志重复转为包重复或 reallog 打点退化为实时打点。
方案评估:
reallog 作为一种特殊的打点类型,无法通过日志中台配置,仅有极个别业务方使用,属于不符合中台规范的打点。
经过多轮时效性优化,目前普通实时打点的时效 97 分位值约 0.5 分钟,足够满足业务方需求。
对两种方案的成本和收益进行评估,且与相关业务方进行确认后,最终决定采用通过实验逐步下线 reallog 的方式进行优化,同时实现 UBC 点位类型收敛。
优化效果
reallog 造成的新版本长尾重复问题约占 10%左右。
下线 reallog 后双端均不再有该类型的重复,且对相关业务方使用无影响。
04 总结与展望
准确且高效地上报日志,是 UBC SDK 作为日志中台数据源头的初衷和目标。经过长期的努力,UBC 在时效性、到达率上均有了极大的提升,本次针对重复上报的问题进行探索和优化,发现并解决了 SDK 打点过程中存在的各类问题和隐患,将日志重复率由千分之三降低至千分之一以下,为数据下游提供了更为可靠的数据。
同时,也为各类端打点问题排查积累了宝贵的经验,优化了本地日志、建设了更为细致的 SDK 执行流程监控,为将来快速发现和定位问题打下坚实的数据基础。
——END——
推荐阅读
通过Python脚本支持OC代码重构实践(二):数据项提供模块接入数据通路的代码生成
版权声明: 本文为 InfoQ 作者【百度Geek说】的原创文章。
原文链接:【http://xie.infoq.cn/article/4332de77456b61144d4653152】。文章转载请联系作者。
评论