C++高并发场景下读多写少的优化方案

简介: C++高并发场景下读多写少的优化方案述一谈到高并发的优化方案,往往能想到模块水平拆分、数据库读写分离、分库分表,加缓存、加mq等,这些都是从系统架构上解决。单模块作为系统的组成单元,其性能好坏也能很大的影响整体性能,本文从单模块下读多写少的场景出发,探讨其解决方案,以其更好的实现高并发。不同的业务场景,读和写的频率各有侧重,有两种常见的业务场景:读多写少:典型场景如广告检索端、白名单更新维护、loadbalancer读少写多:典型场景如qps统计本文针对读多写少(也称一写多读)场景下遇到的问题进行分析,并探讨一种合适的解决方案。

C++高并发场景下读多写少的优化方案

cyouhuiquan.jpg

概述

一谈到高并发的优化方案,往往能想到模块水平拆分、数据库读写分离、分库分表,加缓存、加mq等,这些都是从系统架构上解决。单模块作为系统的组成单元,其性能好坏也能很大的影响整体性能,本文从单模块下读多写少的场景出发,探讨其解决方案,以其更好的实现高并发。

不同的业务场景,读和写的频率各有侧重,有两种常见的业务场景:

  1. 读多写少:典型场景如广告检索端、白名单更新维护、loadbalancer
  2. 读少写多:典型场景如qps统计

本文针对读多写少(也称一写多读)场景下遇到的问题进行分析,并探讨一种合适的解决方案。

分析

读多写少的场景,服务大部分情况下都是处于读,而且要求读的耗时不能太长,一般是毫秒或者更低的级别;更新的频率就不是那么频繁,如几秒钟更新一次。通过简单的加互斥锁,腾出一片临界区,往往能到达预期的效果,保证数据更新正确。

但是,只要加了锁,就会带来竞争,即使加的是读写锁,虽然读之间不互斥,但写一样会影响读,而且读写同时争夺锁的时候,锁优先分配给写(读写锁的特性)。例如,写的时候,要求所有的读请求阻塞住,等到写线程或协程释放锁之后才能读。如果写的临界区耗时比较大,则所有的读请求都会受影响,从监控图上看,这时候会有一根很尖的耗时毛刺,所有的读请求都在队列中等待处理,如果在下个更新周期来之前,服务能处理完这些读请求,可能情况没那么糟糕。但极端情况下,如果下个更新周期来了,读请求还没处理完,就会形成一个恶性循环,不断的有读请求在队列中等待,最终导致队列被挤满,服务出现假死,情况再恶劣一点的话,上游服务发现某个节点假死后,由于负载均衡策略,一般会重试请求其他节点,这时候其他节点的压力跟着增加了,最终导致整个系统出现雪崩。

因此,加锁在高并发场景下要尽量避免,如果避免不了,需要让锁的粒度尽量小,接近无锁(lock-free)更好,简单的对一大片临界区加锁,在高并发场景下不是一种合适的解决方案

双缓冲

有一种数据结构叫双缓冲,其这种数据结构很常见,例如显示屏的显示原理,显示屏显示的当前帧,下一帧已经在后台的buffer准备好,等时间周期一到,就直接替换前台帧,这样能做到无卡顿的刷新,其实现的指导思想是空间换时间,这种数据结构的工作原理如下:

  1. 数据分为前台和后台
  2. 所有读线程读前台数据,不用加锁,通过一个指针来指向当前读的前台数据
  3. 只有一个线程负责更新,更新的时候,先准备好后台数据,接着直接切指针,这之后所有新进来的读请求都看到了新的前台数据
  4. 有部分读还落在老的前台那里处理,因为更新还不算完成,也就不能退出写线程,写线程需要等待所有落在老前台的线程读完成后,才能退出,在退出之前,顺便再更新一遍老前台数据(也就当前的新后台),可以保证前后台数据一致,这点在做增量更新的时候有用

工程实现上需要攻克的难点

  1. 写线程要怎么知道所有的读线程在老前台中的读完成了呢?
    一种做法是让各个读线程都维护一把锁,读的时候锁住,这时候不会影响其他线程的读,但会影响写,读完后释放锁(某些时候可能会有通知写线程的开销,但写本身很少),写线程只需要确认锁有没有释放了,确认完了后马上释放,确认这个动作非常快(小于25ns,1s=103ms=106us=10^9ns),读线程几乎不会感觉到锁的存在。
  2. 每个线程都有一把自己的锁,需要用全局的map来做线程id和锁的映射吗?
    不需要,而且这样做全局map就要加全局锁了,又回到了刚开始分析中遇到的问题了。其实,每个线程可以有私有存储(thread local storage,简称TLS),如果是协程,就对应这协程的TLS(但对于go语言,官方是不支持TLS的,想实现类似功能,要么就想办法获取到TLS,要么就不要基于协程锁,而是用全局锁,但尽量让锁粒度小,本文主要针对C++语言,暂时不深入讨论其他语言的实现)。这样每个读线程锁的是自己的锁,不会影响到其他的读线程,锁的目的仅仅是为了保证读优先
    对于线程私有存储,可以使用pthread_key_create, pthread_setspecific,pthread_getspecific系列函数

核心代码实现

template <typename T, typename TLS>
int DoublyBufferedData<T, TLS>::Read(
    typename DoublyBufferedData<T, TLS>::ScopedPtr* ptr) { // ScopedPtr析构的时候,会释放锁
    Wrapper* w = static_cast<Wrapper*>(pthread_getspecific(_wrapper_key)); //非首次读,获取pthread local lock
    if (BAIDU_LIKELY(w != NULL)) {
        w->BeginRead(); // 锁住
        ptr->_data = UnsafeRead();
        ptr->_w = w;
        return 0;
    }
    w = AddWrapper();
    if (BAIDU_LIKELY(w != NULL)) {
        const int rc = pthread_setspecific(_wrapper_key, w); // 首次读,设置pthread local lock
        if (rc == 0) {
            w->BeginRead();
            ptr->_data = UnsafeRead();
            ptr->_w = w;
            return 0;
        }
    }
    return -1;
}

template <typename T, typename TLS>
template <typename Fn>
size_t DoublyBufferedData<T, TLS>::Modify(Fn& fn) {
    BAIDU_SCOPED_LOCK(_modify_mutex); // 加锁,保证只有一个写
    int bg_index = !_index.load(butil::memory_order_relaxed); // 指向后台buffer
    const size_t ret = fn(_data[bg_index]); // 修改后台buffer
    if (!ret) {
        return 0;
    }
    // 切指针
    _index.store(bg_index, butil::memory_order_release);  
    bg_index = !bg_index;
    // 等所有读老前台的线程读结束
    {
        BAIDU_SCOPED_LOCK(_wrappers_mutex);
        for (size_t i = 0; i < _wrappers.size(); ++i) {
            _wrappers[i]->WaitReadDone();
        }
    }
    // 确认没有读了,直接修改新后台数据,对其新前台
    const size_t ret2 = fn(_data[bg_index]);
    return ret2;
}

完整实现请参考brpc的DoublyBufferData

简单说说golang中双缓冲的实现

普通的双缓冲加载实现

基于计数器,用atomic,保证原子性,读进入临界区,计数器+1,退出-1,写判断计数器为0则切换,但计数器是全局锁。这种方案C++也可以采取,只是计数器毕竟也是全局锁,性能会差那么一丢丢。即使用智能指针shared_ptr,也会面临智能指针引用计数互斥的问题。之所以用计数器,而不用TLS,是因为go不支持TLS,对比TLS版本和计数器版本,TLS性能更优,因为没有抢计数器的互斥问题,但抢计数器本身很快,性能没测试过,可以试试。

sync.Map的实现

也是基于计数器,只是计数器是为了让读前台缓存失效的概率不要太高,有抑制和收敛的作用,实现了读的无锁,少部分情况下,前台缓存读不到数据的时候,会去读后台缓存,这时候也要加锁,同时计数器+1。计数器数值达到一定程度(超过后台缓存的元素个数),就执行切换

是否适用于读少写多的场景

不合适,双缓冲优先保证读的性能,写多读少的场景需要优先保证写的性能。

转载地址https://www.cnblogs.com/longbozhan/p/15780194.html

相关文章
|
5月前
|
数据采集 存储 弹性计算
高并发Java爬虫的瓶颈分析与动态线程优化方案
高并发Java爬虫的瓶颈分析与动态线程优化方案
|
11月前
|
存储 算法 数据处理
公司局域网管理中的哈希表查找优化 C++ 算法探究
在数字化办公环境中,公司局域网管理至关重要。哈希表作为一种高效的数据结构,通过哈希函数将关键值(如IP地址、账号)映射到数组索引,实现快速的插入、删除与查找操作。例如,在员工登录验证和设备信息管理中,哈希表能显著提升效率,避免传统线性查找的低效问题。本文以C++为例,展示了哈希表在局域网管理中的具体应用,包括设备MAC地址与IP分配的存储与查询,并探讨了优化哈希函数和扩容策略,确保网络管理高效准确。
|
6月前
|
运维 监控 Kubernetes
高并发来了,运维别慌:如何优化运维流程,才能稳住阵脚?
高并发来了,运维别慌:如何优化运维流程,才能稳住阵脚?
198 4
|
5月前
|
数据采集 网络协议 API
协程+连接池:高并发Python爬虫的底层优化逻辑
协程+连接池:高并发Python爬虫的底层优化逻辑
|
8月前
|
缓存 监控 Cloud Native
Java Solon v3.2.0 高并发与低内存实战指南之解决方案优化
本文深入解析了Java Solon v3.2.0框架的实战应用,聚焦高并发与低内存消耗场景。通过响应式编程、云原生支持、内存优化等特性,结合API网关、数据库操作及分布式缓存实例,展示其在秒杀系统中的性能优势。文章还提供了Docker部署、监控方案及实际效果数据,助力开发者构建高效稳定的应用系统。代码示例详尽,适合希望提升系统性能的Java开发者参考。
432 4
Java Solon v3.2.0 高并发与低内存实战指南之解决方案优化
|
8月前
|
关系型数据库 MySQL 分布式数据库
Super MySQL|揭秘PolarDB全异步执行架构,高并发场景性能利器
阿里云瑶池旗下的云原生数据库PolarDB MySQL版设计了基于协程的全异步执行架构,实现鉴权、事务提交、锁等待等核心逻辑的异步化执行,这是业界首个真正意义上实现全异步执行架构的MySQL数据库产品,显著提升了PolarDB MySQL的高并发处理能力,其中通用写入性能提升超过70%,长尾延迟降低60%以上。
|
8月前
|
缓存 NoSQL 算法
高并发秒杀系统实战(Redis+Lua分布式锁防超卖与库存扣减优化)
秒杀系统面临瞬时高并发、资源竞争和数据一致性挑战。传统方案如数据库锁或应用层锁存在性能瓶颈或分布式问题,而基于Redis的分布式锁与Lua脚本原子操作成为高效解决方案。通过Redis的`SETNX`实现分布式锁,结合Lua脚本完成库存扣减,确保操作原子性并大幅提升性能(QPS从120提升至8,200)。此外,分段库存策略、多级限流及服务降级机制进一步优化系统稳定性。最佳实践包括分层防控、黄金扣减法则与容灾设计,强调根据业务特性灵活组合技术手段以应对高并发场景。
2226 7
|
9月前
|
数据采集 Web App开发 监控
如何用Pyppeteer打造高并发无头浏览器采集方案
本文从电商行业数据采集痛点出发,结合 Pyppeteer 高并发无头浏览器技术,打造可配置代理的高效采集方案。通过爬虫代理突破 IP 限制,模拟真实用户行为,实现 Amazon 特价商品数据的稳定抓取与分析。代码示例详细展示了代理集成、并发控制及数据处理流程,实验验证效率提升超 4 倍。该方案助力商业决策、竞品分析,并支持技术扩展与创新应用。
332 13
如何用Pyppeteer打造高并发无头浏览器采集方案
|
9月前
|
NoSQL 算法 安全
redis分布式锁在高并发场景下的方案设计与性能提升
本文探讨了Redis分布式锁在主从架构下失效的问题及其解决方案。首先通过CAP理论分析,Redis遵循AP原则,导致锁可能失效。针对此问题,提出两种解决方案:Zookeeper分布式锁(追求CP一致性)和Redlock算法(基于多个Redis实例提升可靠性)。文章还讨论了可能遇到的“坑”,如加从节点引发超卖问题、建议Redis节点数为奇数以及持久化策略对锁的影响。最后,从性能优化角度出发,介绍了减少锁粒度和分段锁的策略,并结合实际场景(如下单重复提交、支付与取消订单冲突)展示了分布式锁的应用方法。
702 3
|
缓存 NoSQL 架构师
Redis批量查询的四种技巧,应对高并发场景的利器!
在高并发场景下,巧妙地利用缓存批量查询技巧能够显著提高系统性能。 在笔者看来,熟练掌握细粒度的缓存使用是每位架构师必备的技能。因此,在本文中,我们将深入探讨 Redis 中批量查询的一些技巧,希望能够给你带来一些启发。
Redis批量查询的四种技巧,应对高并发场景的利器!