1. 问题场景
某公司后端服务模块到下游微服务的访问成功率是影响运维的关键指标,所以需要实现对微服务的全链路的访问成功率实时追踪,且追踪方案不能影响现有链路性能。这里存在一个并发问题:想要对下游微服务访问的成功率进行实时统计,逻辑上必须同一时间点访问总次数和访问成功次数。整个微服务使用了线程池模型,同一时间会处理大量请求,QPS非常之高。所以需要设计一种高效的并发数据结构完成对成功率metrics打点,以实时的统计访问成功率。 (该场景提出者来自一位曾在Baidu做广告系统的大神@xunsi,致敬🫡)
2. 问题要点
- QPS高会不断并发写数据到成功次数和总访问次数中,访问这两个变量如果有先后之分会出现成功次数>总访问总次数或者成功率偏低的非精确现象。
- 这个需求只要分钟级别间隔统计成功率,读两个变量频率比写两个变量的频率低很多。
- 后端服务对耗时敏感,方案需要尽可能的减少加锁的频率。
3. 解决方案
3.1 方案1 事务性内存
事务性内存需要特定的软硬件平台作支撑:软件事务性内存(STM),硬件事务性内存(HTM),混合事务性内存(Hybrid TM)。如果单靠STM可能效率会很低不满足要求,硬件的效率高,但是依赖于特定的CPU架构例如Intel 的 TSX(Transactional Synchronization Extensions)和 IBM POWER 架构中的 HTM。
3.2 方案2 更大的原子内存
问题场景涉及两个变量,一般都是64位整型,如果把两个变量内存放在一起,可以使用128位的原子内存同时读出两个变量的值。但依赖于硬件平台是否支持
3.3 方案3 原子读写+互斥锁try_lock+拆分64位变量+进位
线程并发一般会使用锁,但是锁会降低并发性能,这个方案着眼于减少锁的使用次数。
使用uint64_t的全局变量M(使用原子操作进行保护),高32位记录访问次数,低32位记录成功次数,高低32位各自拿出最高位作为进位flag。使用另一个uint64_t的全局变量N(使用互斥锁进行保护)用于记录进位后的数据。使用原子读写的方法读写M和用普通读写方法读写N即可同时更新和读取同一时间节点的成功次数和访问次数,因为N的更新要线程至少执行2^31次才会被更新,读写M和N的间隔时间内不可能出现N被更新的情况。使用一个互斥锁用于控制所有线程的进位操作,具体地:
线程更新访问次数Ax(1)和成功次数Sx(0或1)时,可以视作给M增加的数值add_val=Ax << 32 + Sx,然后使用new_val = atomic_fetch_and_add(M, add_val);将访问次数和成功次数同时写入M。写入后,线程使用low_flag = new_val & (1 << 31), high_flag = new_val & (1 << 63)判断是否进位。如果进位,使用互斥锁的try_lock接口尝试加锁:
没有加锁成功的线程什么都不需要做。加锁成功的线程,重新判定是否进位,val = atomic_load(M);low_flag = val & (1 << 31), high_flag = val & (1 << 63);判断是否进位,如果有进位,则atomic_sub(M, dec_val);再把进位数据直接写入N。然后释放锁。
在需要统计成功率的时候,直接原子读M变量,使用try_lock上锁后普通读N变量即可。统计成功率上锁成功后未解锁前如果有线程需要执行上锁进位操作,这个线程会无法进位,但是可以在下次某个线程更新M变量再做进位操作,毕竟这个进位失败到进位成功所需的一定在少2^30次方请求时间之内能完成。
备注:在 C++ 中,try_lock 是一种尝试获取锁(一般是互斥锁)的操作,它不会阻塞调用线程。如果锁已经被另一个线程持有,try_lock 会立即返回,而不是等待锁变为可用。这与使用 lock 方法不同,后者会阻塞调用线程直到能够获取锁为止。线程加锁成功后之所以还要再次判定是否需要加锁,是因为可能出现第一个线程try_lock成功再释放lock时,第二个线程才被调度起来做try_lock,这样第二个线程也可能会做进位操作。
这种方案里,只有M变量用原子读写,N作为进位变量只有至少2^31次请求后会出现变动,所以不需要原子读写。且只有2^31次请求间隔(对于10万QPS的请求, 2^31/100000/3600=5.96H才会锁一次)才会出现线程抢锁的情况,对性能影响极低。
3.4 方案4 原子读写+反用读写锁
该问题场景因为是按照分钟级别间隔进行统计,说明是写数据次数远远高于读数据次数。在写数据的时候,先上读锁,再使用原子写写入数据到两个变量。在读数据的时候,上先写锁,再直接读取两个变量的值。 因为读锁不互斥,所以写数据的时候不会被锁给限制性能。因为写锁优先,所以需要读数据的时候可以快速上锁读区,且读数据频率是分钟级别,不会对整体性能造成很大影响。
这种方法在并发线程数量不是很高的时候比较适合。
3.5 方案5 锁方案+多个变量分离热点
前面几种方案的核心思想是避免加锁或者降低加锁的频率,有的依赖于硬件平台,有的工程实现复杂维护成本高,在成熟的大型工程中往往采用多个变量多把锁的方式降低数据写入的锁冲突带来的性能影响,这样性能可以得到保证,同时方便实现和维护。
该方案的基础方案4。在此基础上设置多组全局的访问次数变量M和成功次数变量N,每组都各自有各自的锁进行方案4的保护。线程更新M和N时,先用Hash得方法确定自己需要更新哪组(M,N),这样可以分离热点。统计的时候从第一组到最后一组逐个加锁,最后一组加好锁之后就统计所有(M,N)的数据得到成功率。