一、项目整体与模块认知
你这个日志系统支持哪些核心功能?和常见的日志库(如 spdlog)相比,有哪些亮点或不足?
核心功能就是向指定目标方向写入日志数据,
1.支持多日志级别输出 DEBUD INFO WARN ERROR ,通过设置日志器的默认输出等级,只输出大于等于日志器等级的日志
2.用户可以自定义格式 格式化字符串
3.有两种落地方式同步和异步,同步直接输出到指定方向,异步先输出到内存中,再有异步工作线程输出到指定方向。
4.落地方向有三种1.控制台 2.指定文件 3.滚动文件(以大小为分割)。
5.多日志器支持,不同模块/业务 可以通过日志器管理器注册多个logger 互不影响
6.提供默认的全局日志器rootlogger,LOGD() LOGI()封装宏直接调用
对比spdlog,我本身的日志器系统也是仿照的spdlog进行设计的,实现的更加简单。我的日志系统不需要依赖第三方库,而spdlog引入了fmt库 来处理格式化字符串,相对的 自己手动实现的fmt类 1.只能支持基础的格式符 2.不支持类型检测 3.性能低 stringstream手动拼接字符串。
还有就是,异步输出实现的不同吧。我自己是用双缓冲区+条件变量通知异步线程 写入时/交互缓冲区时需要加锁 条件变量唤醒异步线程,而spdlog无锁环形缓冲队列+原子操作,主线程使用原子操作写入,无需加锁,异步线程持续轮询,有数据就处理,整个过程几乎无锁,性能极高
同步与异步两种日志落地方式在使用场景上如何选择?异步一定比同步快吗?
我们一般在 高并发、低延迟、对主线程性能要求高的场景,比如游戏服务器、微服务网关等下使用异步,而同步日志则适合调试期、小型工具、或者需要立即落盘保障的数据场景。
异步日志的核心优势是通过内存缓冲 + 异步线程落地,避免主线程被磁盘 I/O 阻塞,从而提升整体吞吐。但引入了线程调度、条件变量等待、双缓冲切换锁开销等成本
我们先比较异步同步这两种方式,1.同步 直接向落地方向写入,如果是多线程 需要加锁来保证线程安全 防止输出/读取错误。2.异步先输出到内存中,再有异步工作线程输出到指定方向,这里我们是采用的双缓冲区的策略+条件变量唤醒异步线程。所以对比同步 多了缓冲区交互锁冲突 条件变量等待 线程调度开销,但带来的好处是高并发场景下 多线程写入间的锁冲突减少,因为只需要写入内存,需要加锁的时间短 后续写入到目标方向的操作交给异步线程。所以之所以高并发选择异步,因为降低的多线程写入间的冲突(同步多线程刷新磁盘阻塞过程也需要加锁)+主线程不需要阻塞等待 > 新增的开销。
再来回到这个问题 异步一定比同步快吗?不一定,异步主要是降低了主程序线程间的锁冲突,如果是同步单线程,没有线程间的冲突,况且现代的I/O 在用户态都会有缓冲区 也是直接写入内存 write()速度并不慢,只有在缓冲区满了才会涉及到阻塞写磁盘操作。这样异步单线程的优势就发挥不出来,对比同步只有不会刷新磁盘阻塞的优势 但带来的线程切换开销 锁冲突 条件变量开销 降低的效率更高。
你是如何将多个模块(等级、格式化、输出、日志器等)进行解耦管理的?
像日志器的组成部分,等级 格式化 落地方向 每个模块都是一个类,不会互相包含。最后由日志器模块 导入各个模块,调用各个模块完成日志器的build组装,也就是建造者模式 具体创建方式在build实现,通过继承+多态 还能实现不同建造者build 全局的创建者/局部的创建者。
我这个日志系统是模块化设计的,每个功能都独立出来,互相不耦合,方便扩展和维护:
日志等级模块
用枚举来表示日志等级,比如 DEBUG、INFO 等,并支持转成字符串。格式器模块(Formatter)
把日志信息变成字符串格式,可以自定义格式,比如输出时间、线程ID、消息内容等。日志落地模块(Sink)
控制日志往哪写,比如控制台、文件、滚动日志文件,用工厂模式来创建不同的 Sink。日志器(Logger)模块
组合上面的等级、格式器和输出目标,用户只和 Logger 打交道。日志器管理器(LoggerManager)
是一个单例,负责统一管理多个 Logger,方便全局获取。这些模块之间是组合关系,不是硬编码耦合。比如 Logger 用的是 Formatter 的接口,而不是直接引入某个具体类对象 ,这样以后扩展更方便,符合开闭原则
二、实现细节与关键模块
异步日志的双缓冲队列是怎么设计的?为什么要用双缓冲区而不是循环队列?它具体如何减少锁竞争?
主线程写入作为生产者,异步线程作为消费者 各自有一个缓冲区。生产者向生产缓冲区中写入数据,消费者从消费缓冲区中消费数据。缓冲区中有两个指针(偏移量),分别指向生产者可写位置,消费者可读取位置。消费缓冲区的读指针和写指针重合,说明数据读取完毕。交互缓冲区(交换vector<buffer>,_read,_writer),异步线程继续 读取。(读取直接提供读取的起始地址 直接copy() 不需要中间空间存储)
+-------------------------------+ |....已读....|....待读....|....可写....| 0 _reader _writer _buffer.size()
我们使用双缓冲区,主要是为了减少生产者和消费者的锁冲突。如果选择生产者和消费者在同一个循环队列 写入读取,不仅有生产者和生产者的锁冲突 还有生产者和消费者的锁冲突。像这样选择双缓冲区的策略,生产者 消费者各自一个缓冲区,就减少了生产者和消费者的锁冲突(缓冲区交换时 需要加锁 生产者和消费者会冲突)
Formatter 模块是如何解析
%d{%H:%M:%S}
这样的格式串的?中间有没有考虑转义符或异常格式?
formatter解析的思路是:我们把格式化子串的字符分为两类,1.原始字符 2.格式化字符
判断的标准就是该字符前面有没有属于它的%,像asd{}[]就是原始字符 ,%d %t就是格式字符。但有个例外就是转移字符,子串中的%%就代表原始字符 直接输出 %
1.格式化字符串,是构造并调用对应的formatter格式化子项子类从LogMsg中获取对应字段输出到流中。
其中要注意的是像%d{%H%M%S}这种带子项的 格式化字符,要和子项一块处理。eg.格式化字符d当作key值,子项内容%H%M%S当作val 构造对应子类对象根据子项内容的格式输出到流中。
2.遇到原始字符,不用动直接输出到流中(为了统一处理 即使是原始字符也会通过formatter子类输出到流中)。
当然对于异常格式也有对对应的处理,比如转义字符的子格式一定要有配对的{},没有配对的 } 会输出'子格式匹配错误 '并返回false
模板:
Formatter 通过扫描格式串,将
%
开头的格式符(如%d
,%p
)与普通字符区分开,构造成FormatItem
格式化子项子类组成的列表。
对于带子项的格式如%d{%H:%M:%S}
,会将d
作为 key,%H:%M:%S
作为 val 传给TimeFormatItem
等子类做特殊格式化。
原始字符(如abc
或[]
)会生成OtherFormatItem
。
异常处理方面,如果%d{}
中格式不匹配,会输出错误提示或跳过处理,%%
被视为转义为%
。
日志等级是如何控制日志输出的?例如当前等级为WARN,那DEBUG和INFO消息是如何被过滤掉的?
首先日志等级是一个枚举类,从上到下分别是DBUG INFOR WARN ERROR FATAL OFF,DBUG默认0 其中OFF最大。
每个日志器都有自己的日志等级,如果当前日志器的等级为WARN,调用日志输出函数 如debug info都会先在函数中进行判断 函数输出的日志等级和调用该函数日志器的输出等级,只有函数的日志等级>=日志器的输出等级才会输出,小于就会被过滤掉。
所以,因为DEBUG/INFO < 日志器的等级WARN,所以不能输出。
请简述异步日志中 AsyncLooper 的运行原理。如何做到任务推送与消费线程的并发协作?
首先主线程会向生产者缓冲区中写入数据(如果是安全模式 要先判断能否写入 不能就阻塞 交换完缓冲区 被唤醒继续写入) push完 _con条件变量唤醒异步线程,此时阻塞的异步线程被唤醒并判断是否满足条件(停止||生产缓冲区有数据) ,满足就继续向下运行 1.交换缓冲区 2.(如果是安全策略 缓冲区固定 _pro唤醒阻塞的生产者线程) 3.调用设置的回调函数cb处理 4.处理完 初始化消费者缓冲区。
直到停止运行并且缓冲区数据为空才退出。注意如果线程运行的需要停止,异步线程处理完自己缓冲区的数据后不能直接退出,还要看生产者缓冲区数据是否还有数据。
因此条件变量里面的谓词 不能只判断是否停止,还有判断生产者缓冲区是否有数据。只有当停止+生产者缓冲区为空 才能退出。
三、设计模式理解
本项目使用了哪些设计模式?请各举一例说明其作用与使用位置(如:工厂、建造者、单例、代理)
1.简单工厂模式 2.建造者模式 3.单例模式 4.代理者模式
1.在sink落地方向模块中,我实现了一个工厂类,里面是一个模板函数,根据传入的子类类型通过可变参数+完美转发 创建对应的子类对象,简单同时符合开闭原则。
在formatter模块中,生成对应的格式化子项子类中也是用的简单工厂模式,但是用的if判断选择生成对应的子类 不符合开闭原则。
2.在logger模块中,一个日志器由多个部分组成,1.formatter格式化方式 将LogMsg中内容按照指定格式 格式化为字符串。2.sink 指定的落地方向 向文件/控制台/滚动文件 输出 3.level日志等级 4.日志器名称 5.日志器类型(同步/异步)
这么多组成部分 1.让用户一个一个创建 再组装太麻烦,为了简便用户操作在logger日志器构造中来进行组装。 2.是每个组装模块中可能会有联系,A模块创建可能需要B模块的创建,所以模块间从创建顺序也可能需要固定。因此由构建者loggerbuild来统一操作最合适。
3.在logger模块中,会根据需要创建的logger日志器 是局部的还是全局的,生成两个子类建造者。其中全局的建造者,其实就是创建一个局部的建造者 然后获取单例对象加入到单例对象中,延长该日志器的生命周期。通过获取单例对象 就可以在其它模块/线程中获取指定的日志器进行输出。(单例对象 虽然是在函数内构造是局部对象 但用static修饰是静态的 它拥有静态存储期,那它的生命周期就是全局的)
4.在mylog模块,为了简化用户操作我们会用宏函数来封装日志函数。比如我们调用日志函数debug(__FIEL__,__LINE__,"条数:%d 内容:%s",count,msg); 用户必须自己输入__FIEL__ __LINE__ ,太麻烦。
但可以用宏进行封装
#define debug(fmt,...) debug(__FIEL__,__LINE__,fmt,##__VA__ARGS__)
debug("条数:%d 内容:%s", count, msg);这样就简便用户的使用。
不直接使用A,而是通过B间接使用A 这就是代理者模式,B 可以在调用前后附加额外操作
为什么异步与同步日志器要用 Builder 模式来构建?直接构造对象不可以吗?
1.由build构造者 统一构造logger日志器,不仅方便还不会出错,因为不同模块间可能会存在依赖关系 需要固定构造顺序。
2.构造者我们是实现了两种,1.全局的构造者 2.局部的构造者。如果用户自己构造全局的日志器 必须要操作单例对象,增加了用户的使用负担。
Sink 落地模块用了简单工厂模式,那如果要增加一个 RedisSink,你怎么扩展它?
这个sink的简单工厂类,我是通过模板函数+完美转发实现的 符合开闭区间。直接继承sink父类,实现RedisSink子类就可以了。不需要改工厂类就能生成新增的子类对象。
RedisSink是向数据库作为落地方向,那就在子类中连接数据库,访问里面的表 输入SQL语句 把日志消息写入数据库。
四、性能与扩展性
你做过哪些性能测试?在什么情况下异步反而比同步慢?你是怎么分析的?
单线程同步异步比较,多线程同步异步比较 同步模式下单线程和多线程
单线程模式下 异步会比同步慢。
简单说一下,为什么说异步快?新增异步线程 带来的效率降低 1.线程切换 2.条件变量唤醒开销3.缓冲区交换的锁冲突
对应带来好处 1.高并发 主线程只写入内存就可以 让锁冲突降低 (锁的持有时间降低) 2.向磁盘刷新的过程 主线程不需要阻塞等待。 这两点带来的好处 > 异步线程带来的开销所以说高并发情况下 异步快。
但如果是单线程情况下呢?写线程只有一个 根本没有锁冲突,况且在现代IO场景下 用户态会有缓冲区,write()直接写入内容 也不会很慢 。因此单线程异步的优势只有向刷新磁盘的过程不需要阻塞,但带来的 线程切换 条件变量开销 缓冲区交换锁冲突降低的效率更多。所以单线程情况下 异步会比同步慢。
我还做了同步模式下单线程和多线程的性能测试。
在我的预期情况下,多线程因为会有锁的冲突 效率应该会比单线程低。可实际情况是 多线程的更快。然后我就在想为什么?
简单来说:多线程充分利用CPU带来的提升 > 多线程锁冲突带来的消耗
当时测试的环境是,我的云服务器是2核4G 多线程也只是2个线程,再加上当时测试的数据量小,就导致说同步线程间的锁冲突小 1.数据量小 向磁盘刷新的次数少,锁持有的时间减少。2.线程数正好适中 在4个线程数内2核完全没问题,线程等待锁的时间也少。
而多线程能并发处理日志消息格式化过程,充分利用CPU效率,这是效率提升的主要原因。
后面增加线程数到16,增加单次写入以及总写入量。增加线程切换成本 降低CPU利用效率, 并增加加锁时间 锁等待时间 导致锁冲突增加,可以看到同步模式下多线程不如单线程。
如果让你给这个日志系统增加一个“按小时滚动日志文件”的功能,你会改哪里?需要注意哪些边界问题?
这个功能属于 sink落地方向模块,sink模块中具体的落地实现都是继承父类的子类中实现的,像按文件大小滚动的文件功能的子类。按小时滚动日志文件的功能完全可以仿照按按大小滚动子类实现,只不过切换新文件的判断条件改一下。
1.继承父类sink,在子类中实现实际的落地函数,大致逻辑先获取一共时间戳,然后根据需要划分的时间为单位,比如说按一小时 划分单位就是1h,时间戳/60*60 算出当前处于哪个时间段 并记录,每次写入时 都获取一次 如果和上次的不同就切换新文件进行写入 并更新当前时间段。这样不会改原本代码逻辑,符合开闭原则。
2.边界条件 没想过 可以考虑一下获取当前时间段=时间戳/划分单位 存储时会不会越界。实际用户输出的时间 和 获取时间戳的时间 可能会一些偏差 可能会导致实际59分输出的日志 写入新文件。
⚠ 边界注意点:
系统时间被改(如手动调表)时可能导致滚动失效;
日志流量低时可能“长时间无写入”导致文件未及时创建;(按小时扫描文件目录,若找不到
log_20250619_10.txt
,会误判系统掉线或服务异常。)多线程竞争写入时必须加锁 保证滚动逻辑同步,防止同一小时生成多个文件。
多线程环境下,如果多个 Logger 同时写入同一个文件,你是如何保证线程安全的?
这里我是用互斥锁来保证 线程安全的,线程想写入必须先要申请锁,申请不动就会阻塞。只有申请到锁的 才能进行写入,写完释放锁。在Logger模块中日志器先加锁 再进入sink模块写入
五、使用与接口设计
你设计了很多宏,比如
LOGD
,LOGF
等,这些宏有何优势?是否有潜在风险?(比如可移植性或线程局限)
我们用宏封装的目标就是为了简便用户操作,调用日志函数时 不需要用户自己传入__FILE__ __LINE__,以及可以快速通过全局默认日志器进行输出,不需要用户自己操作单例对象 获取默认日志器。
⚠ 潜在风险:
宏无作用域检查,可能污染命名空间;这行代码一旦写入全局头文件,全工程所有代码都会看到 LOGD,哪怕在其它库、命名空间中也会生效。
宏展开难以调试,出错行号不直观;出错行是宏定义处,不是调用处(调试栈信息可能不准确);
宏不具备类型安全,易因格式参数不匹配而崩溃;宏不像函数模板那样能进行参数类型检查。LOGD("age: %d", std::string("18")); ❌ 错误:传了个 std::string,%d 期望 int,编译器可能不会报错,程序运行时却崩溃或打印乱码。
无法跨语言调用;我的宏本身使用了 C 语言支持的预处理语法,但由于宏内部调用了 C++ 对象方法,因此整个宏只能在 C++ 项目中使用
你这个日志库如何集成到其他项目中?有哪些最小步骤可以快速使用一个异步日志器?
我这个日志器不依赖第三方库,在其它项目中包含mylog.h头文件就可以。
1.先new创建一个 build构建者,调用内部函数设置日志器的属性 1.日志器名称 2.指定的格式化格式 3.异步还是同步 (4.如果是异步 选择安全策略/非安全策略 缓冲区是否固定) 5.增加落地方向
2.最后调用构建者的build函数 创建日志器并返回指针。
或者你以及创建过异步日志器并添加到单例对象中,可以通过全局的接口根据日志器名称获取指定异步日志器(内部是通过单例对象进行查找)。