日志系统项目问题回答

发布于:2025-06-22 ⋅ 阅读:(22) ⋅ 点赞:(0)

一、项目整体与模块认知

  1. 你这个日志系统支持哪些核心功能?和常见的日志库(如 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无锁环形缓冲队列+原子操作,主线程使用原子操作写入,无需加锁,异步线程持续轮询,有数据就处理,整个过程几乎无锁,性能极高

  1. 同步与异步两种日志落地方式在使用场景上如何选择?异步一定比同步快吗?

我们一般在 高并发、低延迟、对主线程性能要求高的场景,比如游戏服务器、微服务网关等下使用异步而同步日志则适合调试期、小型工具、或者需要立即落盘保障的数据场景。

异步日志的核心优势是通过内存缓冲 + 异步线程落地,避免主线程被磁盘 I/O 阻塞,从而提升整体吞吐。但引入了线程调度、条件变量等待、双缓冲切换锁开销等成本

我们先比较异步同步这两种方式,1.同步 直接向落地方向写入,如果是多线程 需要加锁来保证线程安全 防止输出/读取错误。2.异步先输出到内存中,再有异步工作线程输出到指定方向,这里我们是采用的双缓冲区的策略+条件变量唤醒异步线程。所以对比同步 多了缓冲区交互锁冲突 条件变量等待 线程调度开销,但带来的好处是高并发场景下 多线程写入间的锁冲突减少,因为只需要写入内存,需要加锁的时间短  后续写入到目标方向的操作交给异步线程。所以之所以高并发选择异步,因为降低的多线程写入间的冲突(同步多线程刷新磁盘阻塞过程也需要加锁)+主线程不需要阻塞等待 > 新增的开销。

再来回到这个问题 异步一定比同步快吗?不一定异步主要是降低了主程序线程间的锁冲突,如果是同步单线程,没有线程间的冲突,况且现代的I/O 在用户态都会有缓冲区 也是直接写入内存 write()速度并不慢,只有在缓冲区满了才会涉及到阻塞写磁盘操作。这样异步单线程的优势就发挥不出来,对比同步只有不会刷新磁盘阻塞的优势 但带来的线程切换开销 锁冲突 条件变量开销 降低的效率更高。

  1. 你是如何将多个模块(等级、格式化、输出、日志器等)进行解耦管理的?

像日志器的组成部分,等级 格式化 落地方向 每个模块都是一个类,不会互相包含。最后由日志器模块 导入各个模块,调用各个模块完成日志器的build组装,也就是建造者模式 具体创建方式在build实现,通过继承+多态 还能实现不同建造者build 全局的创建者/局部的创建者。

我这个日志系统是模块化设计的,每个功能都独立出来,互相不耦合,方便扩展和维护:

  1. 日志等级模块
    用枚举来表示日志等级,比如 DEBUG、INFO 等,并支持转成字符串。

  2. 格式器模块(Formatter)
    把日志信息变成字符串格式,可以自定义格式,比如输出时间、线程ID、消息内容等。

  3. 日志落地模块(Sink)
    控制日志往哪写,比如控制台、文件、滚动日志文件,用工厂模式来创建不同的 Sink。

  4. 日志器(Logger)模块
    组合上面的等级、格式器和输出目标,用户只和 Logger 打交道。

  5. 日志器管理器(LoggerManager)
    是一个单例,负责统一管理多个 Logger,方便全局获取。

这些模块之间是组合关系,不是硬编码耦合。比如 Logger 用的是 Formatter 的接口,而不是直接引入某个具体类对象 ,这样以后扩展更方便,符合开闭原则


二、实现细节与关键模块

  1. 异步日志的双缓冲队列是怎么设计的?为什么要用双缓冲区而不是循环队列?它具体如何减少锁竞争?

主线程写入作为生产者,异步线程作为消费者 各自有一个缓冲区。生产者向生产缓冲区中写入数据,消费者从消费缓冲区中消费数据。缓冲区中有两个指针(偏移量),分别指向生产者可写位置,消费者可读取位置。消费缓冲区的读指针和写指针重合,说明数据读取完毕。交互缓冲区(交换vector<buffer>,_read,_writer),异步线程继续 读取。(读取直接提供读取的起始地址 直接copy() 不需要中间空间存储)

+-------------------------------+
|....已读....|....待读....|....可写....|
0          _reader      _writer      _buffer.size()

我们使用双缓冲区,主要是为了减少生产者和消费者的锁冲突。如果选择生产者和消费者在同一个循环队列 写入读取,不仅有生产者和生产者的锁冲突 还有生产者和消费者的锁冲突。像这样选择双缓冲区的策略,生产者 消费者各自一个缓冲区,就减少了生产者和消费者的锁冲突(缓冲区交换时 需要加锁 生产者和消费者会冲突)

  1. 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{} 中格式不匹配,会输出错误提示或跳过处理,%% 被视为转义为 %

  1. 日志等级是如何控制日志输出的?例如当前等级为WARN,那DEBUG和INFO消息是如何被过滤掉的?

首先日志等级是一个枚举类,从上到下分别是DBUG INFOR WARN ERROR FATAL OFF,DBUG默认0 其中OFF最大。

每个日志器都有自己的日志等级,如果当前日志器的等级为WARN,调用日志输出函数 如debug info都会先在函数中进行判断 函数输出的日志等级和调用该函数日志器的输出等级,只有函数的日志等级>=日志器的输出等级才会输出,小于就会被过滤掉。

所以,因为DEBUG/INFO < 日志器的等级WARN,所以不能输出。

  1. 请简述异步日志中 AsyncLooper 的运行原理。如何做到任务推送与消费线程的并发协作?

首先主线程会向生产者缓冲区中写入数据(如果是安全模式 要先判断能否写入 不能就阻塞 交换完缓冲区 被唤醒继续写入) push完 _con条件变量唤醒异步线程,此时阻塞的异步线程被唤醒并判断是否满足条件(停止||生产缓冲区有数据) ,满足就继续向下运行 1.交换缓冲区 2.(如果是安全策略 缓冲区固定 _pro唤醒阻塞的生产者线程) 3.调用设置的回调函数cb处理 4.处理完 初始化消费者缓冲区。

直到停止运行并且缓冲区数据为空才退出。注意如果线程运行的需要停止,异步线程处理完自己缓冲区的数据后不能直接退出,还要看生产者缓冲区数据是否还有数据。

因此条件变量里面的谓词 不能只判断是否停止,还有判断生产者缓冲区是否有数据。只有当停止+生产者缓冲区为空 才能退出。


三、设计模式理解

  1. 本项目使用了哪些设计模式?请各举一例说明其作用与使用位置(如:工厂、建造者、单例、代理)

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 可以在调用前后附加额外操作 

  1. 为什么异步与同步日志器要用 Builder 模式来构建?直接构造对象不可以吗?

1.由build构造者 统一构造logger日志器,不仅方便还不会出错,因为不同模块间可能会存在依赖关系 需要固定构造顺序。

2.构造者我们是实现了两种,1.全局的构造者 2.局部的构造者。如果用户自己构造全局的日志器 必须要操作单例对象,增加了用户的使用负担。 

  1. Sink 落地模块用了简单工厂模式,那如果要增加一个 RedisSink,你怎么扩展它?

这个sink的简单工厂类,我是通过模板函数+完美转发实现的 符合开闭区间。直接继承sink父类,实现RedisSink子类就可以了。不需要改工厂类就能生成新增的子类对象。

RedisSink是向数据库作为落地方向,那就在子类中连接数据库,访问里面的表 输入SQL语句 把日志消息写入数据库。


四、性能与扩展性

  1. 你做过哪些性能测试?在什么情况下异步反而比同步慢?你是怎么分析的?

单线程同步异步比较,多线程同步异步比较 同步模式下单线程和多线程

单线程模式下 异步会比同步慢。

简单说一下,为什么说异步快?新增异步线程 带来的效率降低 1.线程切换 2.条件变量唤醒开销3.缓冲区交换的锁冲突 

对应带来好处 1.高并发 主线程只写入内存就可以 让锁冲突降低 (锁的持有时间降低) 2.向磁盘刷新的过程  主线程不需要阻塞等待。 这两点带来的好处 > 异步线程带来的开销所以说高并发情况下 异步快。

但如果是单线程情况下呢?写线程只有一个 根本没有锁冲突,况且在现代IO场景下 用户态会有缓冲区,write()直接写入内容 也不会很慢 。因此单线程异步的优势只有向刷新磁盘的过程不需要阻塞,但带来的 线程切换 条件变量开销 缓冲区交换锁冲突降低的效率更多。所以单线程情况下 异步会比同步慢。

我还做了同步模式下单线程和多线程的性能测试。

在我的预期情况下,多线程因为会有锁的冲突 效率应该会比单线程低。可实际情况是 多线程的更快。然后我就在想为什么?

简单来说:多线程充分利用CPU带来的提升 > 多线程锁冲突带来的消耗

当时测试的环境是,我的云服务器是2核4G 多线程也只是2个线程,再加上当时测试的数据量小,就导致说同步线程间的锁冲突小 1.数据量小 向磁盘刷新的次数少,锁持有的时间减少。2.线程数正好适中 在4个线程数内2核完全没问题,线程等待锁的时间也少。

而多线程能并发处理日志消息格式化过程,充分利用CPU效率,这是效率提升的主要原因。

后面增加线程数到16,增加单次写入以及总写入量。增加线程切换成本 降低CPU利用效率, 并增加加锁时间 锁等待时间 导致锁冲突增加,可以看到同步模式下多线程不如单线程。

  1. 如果让你给这个日志系统增加一个“按小时滚动日志文件”的功能,你会改哪里?需要注意哪些边界问题?

这个功能属于 sink落地方向模块,sink模块中具体的落地实现都是继承父类的子类中实现的,像按文件大小滚动的文件功能的子类。按小时滚动日志文件的功能完全可以仿照按按大小滚动子类实现,只不过切换新文件的判断条件改一下。

1.继承父类sink,在子类中实现实际的落地函数,大致逻辑先获取一共时间戳,然后根据需要划分的时间为单位,比如说按一小时 划分单位就是1h,时间戳/60*60 算出当前处于哪个时间段 并记录,每次写入时 都获取一次 如果和上次的不同就切换新文件进行写入 并更新当前时间段。这样不会改原本代码逻辑,符合开闭原则。

2.边界条件 没想过 可以考虑一下获取当前时间段=时间戳/划分单位 存储时会不会越界。实际用户输出的时间 和 获取时间戳的时间 可能会一些偏差 可能会导致实际59分输出的日志 写入新文件。

边界注意点:

  • 系统时间被改(如手动调表)时可能导致滚动失效;

  • 日志流量低时可能“长时间无写入”导致文件未及时创建;(按小时扫描文件目录,若找不到 log_20250619_10.txt,会误判系统掉线或服务异常。)

  • 多线程竞争写入时必须加锁 保证滚动逻辑同步,防止同一小时生成多个文件。

  1. 多线程环境下,如果多个 Logger 同时写入同一个文件,你是如何保证线程安全的?

这里我是用互斥锁来保证 线程安全的,线程想写入必须先要申请锁,申请不动就会阻塞。只有申请到锁的 才能进行写入,写完释放锁。在Logger模块中日志器先加锁 再进入sink模块写入


五、使用与接口设计

  1. 你设计了很多宏,比如 LOGD, LOGF 等,这些宏有何优势?是否有潜在风险?(比如可移植性或线程局限)

我们用宏封装的目标就是为了简便用户操作,调用日志函数时 不需要用户自己传入__FILE__ __LINE__,以及可以快速通过全局默认日志器进行输出,不需要用户自己操作单例对象 获取默认日志器。

潜在风险:

  • 无作用域检查,可能污染命名空间;这行代码一旦写入全局头文件,全工程所有代码都会看到 LOGD,哪怕在其它库、命名空间中也会生效。

  • 宏展开难以调试,出错行号不直观;出错行是宏定义处,不是调用处(调试栈信息可能不准确);

  • 宏不具备类型安全,易因格式参数不匹配而崩溃;宏不像函数模板那样能进行参数类型检查。LOGD("age: %d", std::string("18")); ❌ 错误:传了个 std::string,%d 期望 int,编译器可能不会报错,程序运行时却崩溃或打印乱码。

  • 无法跨语言调用;我的宏本身使用了 C 语言支持的预处理语法,但由于宏内部调用了 C++ 对象方法,因此整个宏只能在 C++ 项目中使用

  1. 你这个日志库如何集成到其他项目中?有哪些最小步骤可以快速使用一个异步日志器?

我这个日志器不依赖第三方库,在其它项目中包含mylog.h头文件就可以。

1.先new创建一个 build构建者,调用内部函数设置日志器的属性 1.日志器名称 2.指定的格式化格式 3.异步还是同步 (4.如果是异步 选择安全策略/非安全策略 缓冲区是否固定) 5.增加落地方向 

2.最后调用构建者的build函数 创建日志器并返回指针。

或者你以及创建过异步日志器并添加到单例对象中,可以通过全局的接口根据日志器名称获取指定异步日志器(内部是通过单例对象进行查找)。


网站公告

今日签到

点亮在社区的每一天
去签到