你的程序不是单线程的

阅读其他语言: English Español Français Deutsch 日本語 한국어 Português

编写代码时,我们倾向于同步思考。 我们一条接一条地编写语句,运行时按顺序执行它们。 这种心智模型对于脚本和命令行工具来说非常有效, 但在使用框架和服务器应用程序时可能会产生危险的误导。

即使你从未显式创建线程或使用并发原语, 你的Web应用程序几乎肯定正在并行运行代码。 框架为你处理这些,这很方便,只要你记住这一点, 并正确使用框架提供的线程模型和构建块。

在本文中,我们将看看当有人在看似同步的代码中错误地遵循单线程心智模型时 可能发生的错误类型。 我们将找到原因,使用调试器重现它,并查看几种可能的解决方案。

问题

假设你有一个Spring Boot应用程序,在开发和生产环境中运行良好。 很长一段时间以来,一切都运行顺利,但后来财务团队报告了一个严重问题: 多张发票具有相同的编号。这很糟糕。出于法律和会计原因,发票编号必须是唯一的。 重复只出现过一次,你无法在本地重现该问题。

Tip icon

为了让这个练习更有趣,你可以在继续阅读之前尝试自己找出问题。 项目可在GitHub上获取。

你检查数据库,发现有不同的记录具有相同的发票编号。 配置看起来没问题,但显然有些地方出了问题。 通过追踪发票创建流程,你找到了负责生成发票编号的服务。

代码如下所示:

@Service
public class InvoiceService {
    private InvoiceNumberGenerator generator;

    public Invoice createInvoice(Order order) {
        if (generator == null) {
            generator = createGenerator();
        }
        String invoiceNumber = generator.nextNumber();
        return new Invoice(invoiceNumber, order);
    }

    private InvoiceNumberGenerator createGenerator() {
        // Reads last used number from database, initializes counter
        int lastNumber = invoiceRepository.findMaxInvoiceNumber();
        return new InvoiceNumberGenerator(lastNumber);
    }
}

这不是你会写的代码。 你能发现问题吗?

哪里出了问题?

作者的心智模型可能是:“Spring管理我的bean,所以我是安全的。” 这是对的:Spring确实处理bean生命周期,并确保 InvoiceService 只创建一次并正确共享。 但Spring不知道你自己初始化的字段。 generator 字段是你的责任,问题就潜伏在这里。

事件序列

Spring bean默认是单例的。 这意味着所有请求共享同一个 InvoiceService 实例。 当多个HTTP请求同时进入时, 它们由不同的线程处理,都访问同一个服务实例。

现在,让我们追踪一下如果生成器尚未初始化, 两个线程几乎同时调用 createInvoice() 会发生什么:

  1. 线程A检查 generator == null ,结果为 true
  2. 线程A进入 if 块并开始创建生成器
  3. 线程B检查 generator == null ,仍然是 true (A还没有创建生成器)
  4. 线程B也进入 if
  5. 两个线程都查询数据库获取 findMaxInvoiceNumber() 并得到相同的值,例如 1000
  6. 两个线程都创建了从 1000 开始的 InvoiceNumberGenerator
  7. 两者都调用 nextNumber() 并得到 1001
  8. 创建了两张具有相同编号的发票

延迟初始化不是线程安全的, 在并发访问下,两个线程可能从数据库读取相同的”最后发票编号” 并生成相同的下一个编号。

使用调试器重现

我们刚刚发现的问题代码很简单,所以你可能只看一眼就能理解事件序列。 在更复杂的情况下,你可能想要逐步重现bug以准确理解发生了什么。 其他时候,你可能只想模拟不幸的时机并测试程序在负载下是否会崩溃。

我们将使用IntelliJ IDEA的调试器。它可以控制单个线程——正是我们需要的。

首先,在 generator = createGenerator(); 行设置断点, 右键单击它,将挂起 (Suspend) 设置为线程 (Thread)。 这告诉调试器只挂起命中断点的线程,而不是所有线程。

挂起策略设置为线程的断点设置对话框 挂起策略设置为线程的断点设置对话框

以调试模式运行应用程序,并使用以下curl命令 从不同的终端发送两个请求:

curl -X POST http://localhost:8080/api/diagnostics/create-order

其中一个请求会命中断点:这是预期的。 当第二个请求也命中断点时,你就证明了多个线程 可以同时进入临界区——这正是这个bug的定义。

你可以检查线程 (Threads)选项卡 查看两个线程在同一位置等待:

线程选项卡显示两个线程在同一断点位置停止 线程选项卡显示两个线程在同一断点位置停止

两个线程都已通过 null 检查, 当你释放它们时,它们将创建各自的生成器实例。

或者,你可以使用带有小延迟的非挂起日志断点 来模拟较慢的执行:

包含Thread.sleep以模拟慢速执行的求值和日志表达式断点 包含Thread.sleep以模拟慢速执行的求值和日志表达式断点

这种类型的断点是为调试日志设计的, 但正如我们已经知道的, 它几乎可以用于引入任何副作用。 有了这个人为的延迟,我们不必挂起应用程序,因为请求很可能会发生冲突。

修复问题

根据你的需求,有几种方法可以修复这个问题:

选项1:使用数据库

对于发票编号,正确的解决方案是让数据库处理唯一性:

@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;

    public InvoiceService(InvoiceRepository invoiceRepository) {
        this.invoiceRepository = invoiceRepository;
    }

    public Invoice createInvoice(Order order) {
        // Database generates the number via sequence or auto-increment
        return invoiceRepository.save(new Invoice(order));
    }
}

这完全消除了内存计数器。 数据库保证唯一性,即使跨多个应用程序实例也是如此。

选项2:立即初始化

如果你确实需要内存生成器,将其作为Spring bean:

InvoiceNumberGenerator.java:
@Component
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

这是Spring的标准做法。 单例bean在启动时创建,并在所有请求之间安全共享。

选项3:使用@Lazy

如果你真的需要延迟初始化 (例如,当bean的创建成本较高,因为它需要加载数据或建立连接时), 你可以使用@Lazy让Spring安全地处理:

InvoiceNumberGenerator.java:
@Component @Lazy
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          @Lazy InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

根据经验,如果你发现自己在手动实现延迟初始化、缓存、同步 或类似模式,请检查框架是否已经提供了。 自己做往往意味着与框架作斗争,并引入维护开销和微妙的bug。

总结

即使我们没有显式创建线程,我们的应用程序也经常并发运行。 这种模式并非Spring或Web开发所独有。 任何有隐式线程模型的地方,你都面临风险: Web框架、UI工具包,甚至标准库API都在幕后管理线程。

下次当你在应用程序中看到间歇性的、无法解释的行为时, 问问自己:这可能是线程问题吗?大多数情况下,答案是肯定的。

调试愉快!

all posts ->