背景

在现代软件工程中,我们构建的系统规模越来越庞大,架构也从简单的单体演变为复杂的分布式系统。然而许多系统并没有因为架构升级而变得更加健壮,反而陷入了维护成本变高、故障定位困难、代码质量恶化的泥潭。

要真正打造具有高健壮性与可扩展性的系统,我们需要仔细审视那些让系统变得不稳定或性能下降的根因,而不是急于用防御代码或更细的服务拆分临时性地解决(掩盖)问题,只有找到病因才能对症下药。以下几点是笔者工作中的一些总结和思考,力求抛砖引玉。

锁是原罪

并发编程的核心难题在于共享状态的管理。为了解决多线程或多进程在并发场景下的资源竞争,绝大多数开发者第一反应就是加锁,但笔者的观点恰恰是:锁,是复杂度的原罪。

以 Java 为例,在语言层面和 SDK 层面均提供了极其庞杂的锁机制:synchronizedReentrantLockReentrantReadWriteLockStampedLock 等等不同的锁实现,每种锁不仅用法迥异同时即便是同一种锁还可进一步区分公平和非公平模式、可重入性、乐观、悲观策略等概念。

一方面并发往往伴随着竞争条件,而竞争又在一定程度上依赖特定的时间窗口,基于锁的代码在单元测试和功能测试中通常看不出其崩坏的一面,但在高并发的生产环境下却容易间歇性出现死锁问题。另一方面随着业务逻辑演进锁的粒度控制很容易失控。粒度太粗影响并发性能,粒度太细又极易引发死锁。为了直观展现不同并发模型的差异,我们以下对比三种典型设计:

  1. 首先是经典的 共享内存 + 锁 模式
import java.util.concurrent.locks.ReentrantLock;

public class LockBasedCounter {
    private long count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock(); // 显式获取锁
        try {
            count++; // 临界区逻辑
        } finally {
            lock.unlock(); // 必须在 finally 中显式释放,避免死锁
        }
    }

    public long getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

在共享内存模式下下必须严格保证加解锁逻辑:当涉及到多个锁的复合业务时,一旦加锁顺序稍有不慎就会导致死锁。此外,高竞争下的线程挂起和调度开销同样极其昂贵。

  1. 其次是 通过通信共享内存 模式

    package main
    
    import (
    	"fmt"
    	"sync"
    )
    
    type CounterOp int
    
    const (
    	Increment CounterOp = iota
    	Get
    )
    
    type Request struct {
    	op   CounterOp
    	resp chan int64
    }
    
    // 独占状态的 Goroutine
    func startCounterServer(requests <-chan Request) {
    	var count int64 = 0 // 状态完全封闭在内部,没有任何其他协程能直接修改
    	for req := range requests {
    		switch req.op {
    		case Increment:
    			count++
    		case Get:
    			req.resp <- count
    		}
    	}
    }
    
    func main() {
    	requests := make(chan Request)
    	go startCounterServer(requests)
    
    	var wg sync.WaitGroup
    	for i := 0; i < 1000; i++ {
    		wg.Add(1)
    		go func() {
    			defer wg.Done()
    			requests <- Request{op: Increment} // 通过发送消息驱动状态变更
    		}()
    	}
    	wg.Wait()
    
    	respChan := make(chan int64)
    	requests <- Request{op: Get, resp: respChan}
    	fmt.Printf("Final Count: %d\n", <-respChan)
    }
    

    这里以 Go 语言为例,Go 贯彻了CSP哲学。状态被严格隔离在单个 Goroutine 内部,外部只能通过通道发送指令,这样的代码没有锁竞态,也没有死锁顾虑。将并发编程转换成了顺序的消息流处理,代码的可测试性和维护性呈数量级提升。

  2. 最后是无锁 CAS 指令

    对于基础数据结构的变更,利用 CPU 层面的 CAS(Compare-And-Swap) 原语避免阻塞。

    import java.util.concurrent.atomic.AtomicLong;
    
    public class LockFreeCounter {
        // 底层基于硬件级 CAS 原语,无需显式锁
        private final AtomicLong count = new AtomicLong(0);
    
        public void increment() {
            count.incrementAndGet(); // 自旋 + CAS
        }
    
        public long getCount() {
            return count.get();
        }
    }
    

    对于无锁 CAS 模式下如果竞争失败并不会阻塞而是进行 CPU 自旋直至完成。

解决并发问题的最好方式,是消除共享状态避免直接锁竞争。在大规模并发系统中优先考虑采用消息传递进行状态隔离或无锁结构才是从根本上降低系统复杂度与提升健壮性的关键所在。

任其崩溃

很多开发人员和架构师有一种防御性思维执念:试图捕获所有异常、处理好所有漏洞,将程序实现得天衣无缝。然而在复杂系统中,追求不崩溃是不切实际的

过度防御往往会导致代码中充斥着无意义的 try-catch,甚至将致命异常静默吞掉。这种延后处理手段会让程序带伤运行,产生数据不一致或更隐蔽的连锁反应最终引发业务或系统失败。

Erlang 社区很早就提出了著名的 Let It Crash 哲学,好的系统设计应当具备以下能力:故障隔离,确保单个组件、线程或请求的崩溃不会影响到整个进程或其它独立服务;自愈与重启机制,建立监督树或依靠容器化编排,当某个单元陷入不可逆的异常状态时,直接让其快速崩溃,并由上层监督者快速拉起一个新的干净实例。能够自我修复的系统,远比企图处理所有异常边界的系统更具弹性。

重视分布式追踪

微服务架构大行其道的今天几乎很少能见到巨型单体应用,然而许多架构师在做系统设计时依然将绝大部分精力放在如何切蛋糕上——即习惯性地思考按业务域切分、按高低频调用切分、按读写频次切分业务域来构建整个系统,却往往忽略了更致命的问题:服务切得越碎,系统越像一个黑盒。 一个请求从网关进来,经过鉴权、订单、支付、库存、通知,跨越五六个服务、十几个进程、若干次数据库和缓存调用。当这个请求变慢或者失败时,没有链路追踪的团队经常需要各个服务负责人拉群相互排查日志,甚至会出现因日志格式不统一、时钟不同步而互相推诿,定位问题耗时以小时或天计。因此在分布式架构中,分布式追踪不是可选的功能增强,而是不可或缺的基础设施。但而分布式追踪的核心痛点在于如何保证 Trace IDSpan ID 等上下文信息在跨进程、跨网络调用时完好无损地延续。

虽然目前绝大多数框架支持通过Agent自动插桩,但在必要时仍然可以通过代码显式注入,以下代码则简要展示了在使用 OpenTelemetry 时如何在业务层面手动创建不同 Span 域:

@Service
public class OrderService {

    // 自动为方法创建名为 "order.process" 的 Span
    // 参数值自动作为 Span 属性,如 order.id、customer.id
    @WithSpan("order.process")
    public Order processOrder(
            @SpanAttribute("order.id") String orderId,
            @SpanAttribute("customer.id") String customerId) {
        // 业务逻辑
        return repository.findAndProcess(orderId, customerId);
    }
}
@Service
public class PaymentService {

    private final Tracer tracer;

    // 注入 OpenTelemetry Bean,获取 Tracer
    public PaymentService(OpenTelemetry openTelemetry) {
        this.tracer = openTelemetry.getTracer("payment-service");
    }

    public Payment processPayment(String orderId, double amount) {
        // 创建 Span
        Span span = tracer.spanBuilder("process-payment")
                .setAttribute("order.id", orderId)
                .setAttribute("payment.amount", amount)
                .startSpan();

        // 将 Span 设为当前上下文,使其子 Span 正确继承关系
        try (Scope scope = span.makeCurrent()) {
            Payment payment = doPayment(orderId, amount);
            span.setStatus(StatusCode.OK);
            return payment;
        } catch (Exception e) {
            span.setStatus(StatusCode.ERROR, e.getMessage());
            span.recordException(e);
            throw e;
        } finally {
            // 务必结束 Span,否则数据不会上报
            span.end();
        }
    }

    private Payment doPayment(String orderId, double amount) {
        // 实际支付逻辑
        return new Payment(orderId, amount);
    }
}

在实际场景中我们经常使用线程池和消息队列等技术,而 OpenTelemetry 已经充分考虑到了这一点,无论是对kafka, RabbitMQ或是MQTTv5都天然地支持。

四、 提高技术门槛

这一点可能和很多人的直觉相悖,我见过太多技术团队热衷于技术下放组件封装——把复杂的东西包一层让更多人可以参与开发。把门槛降低,让新人也能快速上手。初衷是好的实则为项目埋下了深重的技术债务。开发者可能不知道 synchronizedReentrantLock 的区别,于是到处乱用锁、不知道 HTTP 连接池的工作机制,于是随手配置参数导致线上连接耗尽。

一个不了解数据库 MVCC 机制、索引 B+ 树数据结构的开发者仅仅依靠封装好的ORM框架,极易写出引发全表扫描或死锁的SQL语句。同样一个不了解CPU调度与线程池原理的开发者技术在工具加持下仍然会写出阻塞性代码,这也是我们在实际项目中遇到的为什么即便使用了虚拟线程但系统响应并没有提升甚至有可能下降了。

妄图以低维的技术手段解决高维问题,是项目里不可维护代码的最大来源。封装本身不是错,错的是把封装当作不需要理解原理的借口。组件封装的目的更多地在于减少重复劳动,而不是掩盖无知。唯有理解底层细节才能在架构选型、性能调优和疑难问题排查中做出正确的决策。妄图用低维的技术认知去解决高维的系统工程问题,最终只会导致项目中充斥着不可维护的代码和烂尾设计。

最后

系统的健壮性从来不是凭空产生的,它源于对复杂度的敬畏与架构审视的理性。真正健壮的系统不是把复杂性藏起来,而是正视复杂性、隔离复杂性、并让理解复杂性的人来掌控它。锁不可怕,崩溃不可怕,并发亦不可怕——可怕的是我们假装它们不复杂。