在Java应用的高并发场景中,多个线程同时执行日志写入操作时,很容易出现日志内容错乱、写入性能低下甚至系统资源耗尽的问题,因此设计一套合理的并发日志系统十分必要,核心要解决线程安全和写入效率两个关键问题。

并发日志系统的核心需求
一个合格的并发日志系统需要满足以下几个基础要求:
- 线程安全:多个线程同时写入日志时,不会出现内容交叉、丢失的情况
- 高性能:日志写入不能成为系统的性能瓶颈,尽量减少对业务线程的阻塞
- 可靠性:日志内容不会因系统异常而丢失,支持按规则持久化到磁盘
- 可扩展:支持自定义日志级别、输出路径、缓冲大小等配置
多线程日志写入的同步控制
多线程同时操作日志写入资源时,首先要解决线程同步问题,常见的实现方式有两种:
1. 使用synchronized关键字同步
这种方式实现简单,适合并发量不高的场景,通过对写入方法加锁,保证同一时间只有一个线程能执行写入操作。
import java.io.FileWriter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
public class SyncLogWriter {
// 日志文件路径
private String logPath;
// 日期格式化工具
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public SyncLogWriter(String logPath) {
this.logPath = logPath;
}
// 同步写入日志方法
public synchronized void writeLog(String level, String content) {
String logLine = sdf.format(new Date()) + " [" + level + "] " + content + "n";
try (FileWriter writer = new FileWriter(logPath, true)) {
writer.write(logLine);
} catch (IOException e) {
e.printStackTrace();
}
}
}
2. 使用ReentrantLock实现更灵活的同步
如果需要更细粒度的锁控制,比如支持尝试获取锁、超时获取锁等场景,可以使用ReentrantLock替代synchronized。
import java.io.FileWriter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.locks.ReentrantLock;
public class LockLogWriter {
private String logPath;
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 可重入锁
private ReentrantLock lock = new ReentrantLock();
public LockLogWriter(String logPath) {
this.logPath = logPath;
}
public void writeLog(String level, String content) {
// 尝试获取锁,最多等待1秒
boolean locked = false;
try {
locked = lock.tryLock(1, java.util.concurrent.TimeUnit.SECONDS);
if (locked) {
String logLine = sdf.format(new Date()) + " [" + level + "] " + content + "n";
try (FileWriter writer = new FileWriter(logPath, true)) {
writer.write(logLine);
} catch (IOException e) {
e.printStackTrace();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (locked) {
lock.unlock();
}
}
}
}
日志缓冲策略设计
如果每次日志写入都直接操作磁盘,会频繁触发IO操作,严重影响系统性能,因此需要引入缓冲策略,减少磁盘IO次数。
1. 内存缓冲+批量刷盘
核心思路是在内存中维护一个日志缓冲区,当缓冲区满或者达到定时时间时,再批量将日志写入磁盘,这样可以将多次IO合并为一次,大幅提升性能。
实现时需要注意缓冲区的线程安全问题,通常使用线程安全的队列作为缓冲区,比如ArrayBlockingQueue。
import java.io.FileWriter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BufferLogWriter {
// 日志队列,作为内存缓冲区
private BlockingQueue<String> logQueue;
// 批量刷盘阈值
private int batchSize;
// 日志文件路径
private String logPath;
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 刷盘线程
private Thread flushThread;
// 控制刷盘线程运行状态
private volatile boolean running = true;
public BufferLogWriter(String logPath, int queueSize, int batchSize) {
this.logPath = logPath;
this.batchSize = batchSize;
this.logQueue = new ArrayBlockingQueue<>(queueSize);
// 启动刷盘线程
startFlushThread();
}
// 写入日志到缓冲区
public void writeLog(String level, String content) {
String logLine = sdf.format(new Date()) + " [" + level + "] " + content;
// 如果队列满了,这里可以选择阻塞或者丢弃日志,根据业务需求调整
try {
logQueue.put(logLine);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 启动刷盘线程
private void startFlushThread() {
flushThread = new Thread(() -> {
List<String> batchList = new ArrayList<>(batchSize);
while (running || !logQueue.isEmpty()) {
try {
// 从队列中取日志,最多等待1秒
String log = logQueue.poll(1, java.util.concurrent.TimeUnit.SECONDS);
if (log != null) {
batchList.add(log);
}
// 达到批量阈值或者队列为空且还在运行,执行刷盘
if (batchList.size() >= batchSize || (!running && !logQueue.isEmpty())) {
flushToDisk(batchList);
batchList.clear();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
flushThread.setDaemon(true);
flushThread.start();
}
// 批量刷盘到文件
private void flushToDisk(List<String> logList) {
if (logList.isEmpty()) {
return;
}
try (FileWriter writer = new FileWriter(logPath, true)) {
for (String log : logList) {
writer.write(log + "n");
}
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
// 关闭日志系统,等待剩余日志刷盘
public void shutdown() {
running = false;
flushThread.interrupt();
}
}
2. 双缓冲策略
双缓冲策略是使用两个缓冲区,一个缓冲区用于业务线程写入,另一个缓冲区用于刷盘线程读取,两个缓冲区定时交换,这样可以进一步减少业务线程的阻塞时间。
实现时需要注意两个缓冲区的切换同步,避免切换过程中出现数据不一致的问题。
import java.io.FileWriter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
public class DoubleBufferLogWriter {
// 当前写入缓冲区
private List<String> currentBuffer;
// 待刷盘缓冲区
private List<String> flushBuffer;
// 缓冲区大小阈值
private int bufferSize;
private String logPath;
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 切换锁
private final Object bufferLock = new Object();
// 刷盘线程
private Thread flushThread;
private volatile boolean running = true;
public DoubleBufferLogWriter(String logPath, int bufferSize) {
this.logPath = logPath;
this.bufferSize = bufferSize;
this.currentBuffer = new ArrayList<>(bufferSize);
this.flushBuffer = new ArrayList<>(bufferSize);
startFlushThread();
}
// 写入日志
public void writeLog(String level, String content) {
String logLine = sdf.format(new Date()) + " [" + level + "] " + content;
synchronized (bufferLock) {
currentBuffer.add(logLine);
// 当前缓冲区满了,触发切换
if (currentBuffer.size() >= bufferSize) {
swapBuffer();
bufferLock.notify();
}
}
}
// 交换两个缓冲区
private void swapBuffer() {
List<String> temp = currentBuffer;
currentBuffer = flushBuffer;
flushBuffer = temp;
currentBuffer.clear();
}
// 启动刷盘线程
private void startFlushThread() {
flushThread = new Thread(() -> {
while (running) {
List<String> toFlush = null;
synchronized (bufferLock) {
// 如果待刷盘缓冲区为空,等待唤醒
if (flushBuffer.isEmpty()) {
try {
bufferLock.wait(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 再次检查,避免空指针
if (!flushBuffer.isEmpty()) {
toFlush = new ArrayList<>(flushBuffer);
flushBuffer.clear();
}
}
// 在锁外执行刷盘,减少锁占用时间
if (toFlush != null && !toFlush.isEmpty()) {
flushToDisk(toFlush);
}
}
// 关闭时处理剩余日志
if (!currentBuffer.isEmpty()) {
flushToDisk(new ArrayList<>(currentBuffer));
}
if (!flushBuffer.isEmpty()) {
flushToDisk(new ArrayList<>(flushBuffer));
}
});
flushThread.setDaemon(true);
flushThread.start();
}
// 刷盘到文件
private void flushToDisk(List<String> logList) {
try (FileWriter writer = new FileWriter(logPath, true)) {
for (String log : logList) {
writer.write(log + "n");
}
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
// 关闭日志系统
public void shutdown() {
running = false;
flushThread.interrupt();
}
}
策略对比与选择
不同的缓冲策略适用不同的场景,以下是两种常见策略的对比:
| 策略类型 | 实现复杂度 | 性能表现 | 适用场景 |
|---|---|---|---|
| 内存缓冲+批量刷盘 | 较低 | 较好 | 并发量中等,对实现复杂度要求低的场景 |
| 双缓冲策略 | 较高 | 优秀 | 高并发场景,对写入延迟要求低的场景 |
注意事项
在实际实现并发日志系统时,还需要注意以下几点:
- 缓冲区大小需要根据业务日志量合理设置,过小会导致频繁刷盘,过大可能导致系统异常时丢失更多日志
- 刷盘线程需要设置为守护线程,避免影响主线程退出
- 需要处理日志文件滚动,比如按天或者按文件大小分割日志文件,避免单个日志文件过大
- 异常日志需要单独处理,避免日志写入异常影响业务线程正常运行
设计并发日志系统时,没有绝对最优的方案,需要结合业务的实际并发量、性能要求、可靠性要求来选择合适的实现方式,核心是在线程安全、性能、可靠性之间找到平衡。