最近,我接手了一个老项目的分布式服务代码,看完之后,我整个人都不好了。

这个项目,是一个分布式的订单服务,部署在多个节点上,需要处理高并发的订单请求。理论上,应该是一个设计良好、代码优雅的分布式系统。但是,实际看到代码的时候,我惊呆了。

代码里到处都是硬编码,数据库地址、超时时间、重试次数,全是写死的;到处都是复制粘贴,同样的逻辑,在不同的地方复制了五六份,每一份还有细微的差别;到处都是超长函数,一个函数两三百行,什么都干,根本看不出逻辑;异常处理更是混乱,有的地方catch了异常直接吞掉,有的地方catch了打印一下继续跑,有的地方根本不处理异常,直接抛到最外层。

最让我崩溃的,是CAP理论相关的逻辑。这个分布式服务,本来应该根据不同的场景,在一致性(Consistency)、可用性(Availability)、分区容忍性(Partition Tolerance)之间做权衡。但是,代码里根本没有这种权衡的意识,所有的场景,都是一刀切的处理方式。有的地方,为了强一致性,把可用性搞得很差,网络稍微抖动一下,服务就不可用了;有的地方,为了可用性,完全放弃了一致性,导致数据经常不一致,出了问题很难排查。

看完代码,我花了整整一天,才勉强理清了整个系统的逻辑。然后,我做了一个决定:重构。必须重构。这样的代码,继续维护下去,只会越来越乱,迟早会出大问题。

本文,我想记录一下这次基于CAP理论的代码重构过程,包括原来的代码有多烂、CAP理论的核心思想、重构的思路和步骤、重构后的代码是什么样子、以及我在重构过程中的一些思考和经验。希望能给正在做分布式系统开发的朋友一些参考。

一、原来的代码有多烂

先给大家看几个原来代码的例子,感受一下什么叫"烂代码"。

例子1:硬编码和复制粘贴

原来的代码里,有好几个地方需要调用库存服务,扣减库存。每个地方,都是自己写一套HTTP调用的逻辑,硬编码了库存服务的地址、超时时间、重试次数。

// 地方1:创建订单时扣减库存
public void createOrder(Order order) {
    // ... 一堆逻辑 ...
    
    // 扣减库存
    try {
        URL url = new URL("http://192.168.1.100:8080/inventory/deduct");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setConnectTimeout(3000);
        conn.setReadTimeout(5000);
        conn.setDoOutput(true);
        // ... 发送请求 ...
    } catch (Exception e) {
        e.printStackTrace();
    }
    
    // ... 更多逻辑 ...
}

// 地方2:取消订单时恢复库存
public void cancelOrder(String orderId) {
    // ... 一堆逻辑 ...
    
    // 恢复库存
    try {
        URL url = new URL("http://192.168.1.100:8080/inventory/restore");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setConnectTimeout(3000);
        conn.setReadTimeout(5000);
        conn.setDoOutput(true);
        // ... 发送请求 ...
    } catch (Exception e) {
        e.printStackTrace();
    }
    
    // ... 更多逻辑 ...
}

这样的代码,在好几个地方都有,每个地方都是复制粘贴,然后改一下URL和参数。如果库存服务的地址变了,或者超时时间需要调整,就要改好几个地方,很容易漏改,导致问题。

而且,异常处理就是打印一下堆栈,什么都不做。如果库存服务调用失败了,订单还是继续创建,最后就会出现订单创建了但是库存没扣减的情况,数据不一致。

例子2:CAP逻辑混乱

最让我崩溃的,是CAP相关的逻辑。原来的代码里,有一个分布式锁的实现,用的是Redis的SETNX。但是,这个锁的实现,问题很大:

public boolean tryLock(String key, int expireTime) {
    Jedis jedis = jedisPool.getResource();
    try {
        Long result = jedis.setnx(key, "1");
        if (result == 1) {
            jedis.expire(key, expireTime);
            return true;
        }
        return false;
    } finally {
        jedis.close();
    }
}

这个实现,有两个严重的问题:

  1. 非原子性:SETNX和EXPIRE是两个操作,不是原子的。如果SETNX成功之后,进程崩溃了,没有执行EXPIRE,那么这个锁就永远不会过期,变成死锁。
  2. 没有锁的所有者标识:锁的值是写死的"1",没有标识是谁加的锁。这样,如果一个线程加了锁,但是因为业务执行时间太长,锁过期了,另一个线程又加了锁,然后第一个线程执行完了,释放锁的时候,就会把第二个线程的锁给释放掉,导致锁失效。

而且,这个分布式锁,在所有的场景里都是一样的用法,不管是创建订单、扣减库存,还是更新用户信息,都是用这个锁,都是同样的超时时间,同样的重试策略。根本没有根据不同的业务场景,在一致性和可用性之间做权衡。

比如,创建订单的时候,对一致性要求很高,不能出现重复创建订单的情况,这个时候,应该用强一致性的锁,即使牺牲一点可用性也要保证一致性。但是,原来的代码里,锁的超时时间很短,重试次数很少,如果获取锁失败,就直接返回错误,导致很多创建订单的请求失败,可用性很差。

而更新用户信息的时候,对一致性的要求没那么高,偶尔的不一致可以接受,这个时候,应该优先保证可用性,用更宽松的锁策略,或者不用分布式锁,用最终一致性的方案。但是,原来的代码里,还是用同样的强一致性锁,导致更新用户信息的接口经常因为获取锁失败而不可用。

这就是典型的CAP理论应用混乱:没有根据不同的业务场景,在C(一致性)、A(可用性)、P(分区容忍性)之间做合理的权衡,而是一刀切,导致该一致的地方不够一致,该可用的地方不够可用。

例子3:超长函数和混乱的职责

原来的代码里,有一个createOrder函数,整整300多行,什么都干:参数校验、用户信息查询、商品信息查询、库存扣减、价格计算、优惠计算、订单生成、订单入库、消息发送、日志记录……全部塞在一个函数里。

这个函数,根本没法看,也没法改。你想改一下优惠计算的逻辑,要在300多行里找到优惠计算的部分,改完之后,还可能影响到其他逻辑,因为所有逻辑都耦合在一起。

而且,这个函数里,到处都是if-else嵌套,最深的地方嵌套了七八层,根本看不出逻辑分支。变量命名也很随意,a、b、c、temp、data,根本不知道是什么意思。

这样的代码,维护成本极高,出了问题很难排查,想加新功能也不敢加,怕改出问题。

二、CAP理论的核心思想

在开始重构之前,我先重新梳理了一下CAP理论的核心思想,确保自己对这个理论有正确的理解。

CAP理论,是由计算机科学家Eric Brewer在2000年提出的,所以也叫Brewer定理。它指出,一个分布式系统,不可能同时满足以下三个特性:

  • 一致性(Consistency):所有节点在同一时间看到的数据是一致的。也就是说,写操作之后,后续的读操作都能读到最新的值。
  • 可用性(Availability):每个请求都能在有限的时间内得到响应(不保证是最新的数据)。也就是说,服务一直是可用的,不会因为某个节点出问题而导致整个服务不可用。
  • 分区容忍性(Partition Tolerance):分布式系统在遇到网络分区(节点之间的网络断开了)的情况下,仍然能继续运行。

CAP理论说的是,在分布式系统中,这三个特性不可能同时满足,最多只能同时满足两个。所以,你必须在这三个特性之间做权衡,选择你最看重的两个,放弃第三个。

具体来说,有三种组合:

1. CA:满足一致性和可用性,放弃分区容忍性

这种系统,保证数据一致,同时保证服务可用,但是不能容忍网络分区。也就是说,如果网络出了问题,节点之间无法通信,整个系统就不能正常工作了。

传统的关系型数据库(比如MySQL的单机版),就是CA系统的代表。它们保证数据一致性,也保证可用性,但是它们是单机的,不存在网络分区的问题。如果要做成分布式的,就必须在C和A之间做选择了。

但是,在实际的分布式系统中,网络分区是不可避免的(网络总会出问题),所以P是必须要保证的。也就是说,在分布式系统中,你实际上只能在CP和AP之间做选择,CA只存在于理论中或者非分布式系统中。

2. CP:满足一致性和分区容忍性,放弃可用性

这种系统,保证数据一致,也能容忍网络分区,但是在网络分区的时候,会牺牲可用性。也就是说,如果网络出了问题,节点之间无法通信,为了保证数据一致性,系统会拒绝一部分请求,变得不可用。

比如,ZooKeeper、etcd、HBase这些分布式协调和存储系统,就是CP系统的代表。它们保证数据的强一致性,也能容忍网络分区,但是在网络分区的时候,为了保证一致性,会有一部分节点不可用,或者拒绝写请求。

CP系统,适合对数据一致性要求很高的场景,比如分布式锁、配置管理、元数据存储等。这些场景,数据不一致会导致严重的问题,所以宁愿牺牲一点可用性,也要保证一致性。

3. AP:满足可用性和分区容忍性,放弃一致性

这种系统,保证服务可用,也能容忍网络分区,但是在网络分区的时候,会牺牲一致性。也就是说,如果网络出了问题,节点之间无法通信,每个节点仍然可以独立处理请求,保证可用性,但是不同节点上的数据可能不一致,需要等网络恢复之后,通过数据同步来达到最终一致。

比如,Cassandra、DynamoDB、CouchDB这些分布式存储系统,就是AP系统的代表。它们保证高可用性,也能容忍网络分区,但是不保证强一致性,只保证最终一致性。

AP系统,适合对可用性要求很高、对一致性要求不高的场景,比如评论系统、点赞系统、日志收集等。这些场景,偶尔的数据不一致是可以接受的,但是服务不能不可用。

理解了CAP理论之后,我意识到,原来的代码最大的问题,就是没有根据不同的业务场景,在CP和AP之间做合理的选择。所有的场景,都是一刀切,有的地方该用CP的用了AP,有的地方该用AP的用了CP,导致整个系统既不一致,也不可用。

所以,重构的核心思路,就是根据不同的业务场景,明确每个场景应该是CP还是AP,然后用对应的代码实现,在一致性和可用性之间做合理的权衡。

三、重构的思路和步骤

明确了问题和方向之后,我开始了重构。重构不是一蹴而就的,需要有计划、有步骤地进行,不能上来就大改,否则很容易改出问题。

我的重构思路,分为以下几个步骤:

步骤一:梳理业务场景,明确每个场景的CAP选择

首先,我把整个系统的所有业务场景都列出来,然后逐个分析,明确每个场景对一致性和可用性的要求,确定应该选择CP还是AP。

比如:

  • 创建订单:对一致性要求很高,不能出现重复创建订单、超卖等情况,应该选择CP,优先保证一致性。
  • 扣减库存:对一致性要求很高,不能出现超卖,应该选择CP。
  • 取消订单:对一致性要求较高,但是可以接受短暂的不一致,应该选择CP偏向,但是可以适当放宽。
  • 更新用户信息:对一致性要求不高,偶尔的不一致可以接受,应该选择AP,优先保证可用性。
  • 用户登录:对一致性要求较高,但是可以接受短暂的不一致(比如用户改了密码,短时间内旧密码还能登录),可以选择AP偏向,用最终一致性。
  • 商品浏览:对一致性要求很低,商品信息稍微旧一点没关系,应该选择AP,优先保证可用性和性能。
  • 日志记录:对一致性要求很低,丢几条日志也没关系,应该选择AP,优先保证性能。

这样,每个场景都明确了CAP选择,后面的代码重构就有了依据。

步骤二:抽取公共组件,消除复制粘贴

接下来,我把原来代码里复制粘贴的公共逻辑,抽取成独立的组件和工具类,消除重复代码。

比如,原来的HTTP调用逻辑,在好几个地方都有复制粘贴,我把它抽取成一个统一的HttpClient工具类,封装了连接池、超时设置、重试机制、异常处理等逻辑。所有需要调用外部服务的地方,都用这个工具类,不再自己写HTTP调用逻辑。

再比如,原来的分布式锁,实现有问题,而且到处都是复制粘贴,我把它重构成一个统一的DistributedLock组件,用Redis的原子操作(SET key value NX EX timeout)来实现,解决了原来的非原子性和锁所有者标识的问题。并且,提供了不同的锁策略,适配不同的CAP场景。

还有,序列化、日志、异常处理等公共逻辑,也都抽取成了统一的组件,不再到处复制粘贴。

步骤三:拆分超长函数,明确职责

然后,我把原来的超长函数(比如300多行的createOrder),拆分成多个小函数,每个函数只做一件事,职责明确。

比如,createOrder函数,拆分成了以下几个小函数:

  • validateOrderParam:校验订单参数
  • getUserInfo:查询用户信息
  • getProductInfo:查询商品信息
  • calculatePrice:计算价格
  • calculateDiscount:计算优惠
  • deductInventory:扣减库存
  • createOrderRecord:生成订单记录
  • saveOrder:保存订单到数据库
  • sendOrderMessage:发送订单消息
  • recordOperationLog:记录操作日志

每个小函数,都只有几十行,逻辑清晰,职责明确。createOrder函数,变成了一个编排函数,只负责调用这些小函数,控制流程,不再包含具体的业务逻辑。

这样拆分之后,代码的可读性大大提高,维护成本也降低了。想改优惠计算的逻辑,只需要改calculateDiscount函数就行,不会影响其他逻辑。

步骤四:根据CAP选择,实现不同的策略

这是重构的核心步骤。根据步骤一明确的CAP选择,为不同的业务场景实现不同的处理策略。

对于CP场景(比如创建订单、扣减库存),用强一致性的策略:

  • 分布式锁用强一致性的实现,锁的超时时间设置得合理(不要太短也不要太长)
  • 获取锁失败的时候,进行有限次数的重试,而不是直接返回错误
  • 数据操作用事务,保证原子性
  • 外部服务调用失败的时候,进行回滚,保证数据一致

对于AP场景(比如更新用户信息、商品浏览),用高可用性的策略:

  • 不用分布式锁,或者用很宽松的锁策略
  • 数据操作不用强事务,用最终一致性的方案(比如消息队列、异步同步)
  • 外部服务调用失败的时候,不影响主流程,记录日志,后续重试
  • 优先保证请求能得到响应,即使返回的不是最新的数据

这样,不同的场景用不同的策略,该一致的地方保证一致,该可用的地方保证可用,整个系统的CAP权衡就合理了。

步骤五:完善异常处理和日志

最后,完善异常处理和日志。原来的代码,异常处理很混乱,日志也很少,出了问题很难排查。

重构之后,我建立了统一的异常体系,定义了业务异常、系统异常、外部服务异常等不同类型的异常,每种异常有不同的处理方式。并且,在关键的业务节点,都加上了详细的日志,记录请求参数、返回结果、异常信息等,方便排查问题。

同时,还加上了监控和告警,对关键接口的响应时间、错误率、吞吐量等进行监控,出现异常及时告警。

四、重构后的代码是什么样子

重构完成之后,代码的质量有了质的提升。给大家看几个重构后的例子。

例子1:统一的HTTP客户端

@Component
public class HttpClientUtil {
    private final CloseableHttpClient httpClient;
    
    public HttpClientUtil() {
        // 初始化连接池、超时设置等
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        cm.setMaxTotal(200);
        cm.setDefaultMaxPerRoute(50);
        
        RequestConfig requestConfig = RequestConfig.custom()
            .setConnectTimeout(3000)
            .setConnectionRequestTimeout(1000)
            .setSocketTimeout(5000)
            .build();
        
        this.httpClient = HttpClients.custom()
            .setConnectionManager(cm)
            .setDefaultRequestConfig(requestConfig)
            .build();
    }
    
    public <T> T post(String url, Object body, Class<T> responseType, HttpStrategy strategy) {
        // 统一的POST请求处理,包含重试、异常处理等
        // strategy参数指定不同的策略(CP策略或AP策略)
        int retryCount = strategy.getRetryCount();
        for (int i = 0; i <= retryCount; i++) {
            try {
                HttpPost post = new HttpPost(url);
                // ... 设置请求体 ...
                CloseableHttpResponse response = httpClient.execute(post);
                // ... 解析响应 ...
                return result;
            } catch (Exception e) {
                if (i == retryCount || !strategy.shouldRetry(e)) {
                    if (strategy.isCp()) {
                        throw new ServiceException("调用服务失败", e);
                    } else {
                        log.error("调用服务失败,AP策略,不抛出异常", e);
                        return null;
                    }
                }
                // 重试等待
                Thread.sleep(strategy.getRetryInterval());
            }
        }
        return null;
    }
}

重构之后,所有的HTTP调用都用这个统一的工具类,不再到处复制粘贴。并且,通过HttpStrategy参数,可以指定不同的策略(CP策略或AP策略),适配不同的业务场景。CP策略下,调用失败会抛出异常,触发回滚,保证一致性;AP策略下,调用失败只记录日志,不影响主流程,保证可用性。

例子2:正确的分布式锁实现

@Component
public class DistributedLock {
    private final JedisPool jedisPool;
    private static final String LOCK_PREFIX = "lock:";
    private static final ThreadLocal<String> lockValueHolder = new ThreadLocal<>();
    
    public boolean tryLock(String key, int expireTime) {
        Jedis jedis = jedisPool.getResource();
        try {
            // 生成唯一的锁值,标识锁的所有者
            String lockValue = UUID.randomUUID().toString();
            lockValueHolder.set(lockValue);
            
            // 用SET key value NX EX timeout 原子操作
            String result = jedis.set(LOCK_PREFIX + key, lockValue, 
                SetParams.setParams().nx().ex(expireTime));
            return "OK".equals(result);
        } finally {
            jedis.close();
        }
    }
    
    public void unlock(String key) {
        Jedis jedis = jedisPool.getResource();
        try {
            String lockValue = lockValueHolder.get();
            if (lockValue == null) return;
            
            // 用Lua脚本原子地判断锁值并删除,避免误删别人的锁
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                           "return redis.call('del', KEYS[1]) else return 0 end";
            jedis.eval(script, Collections.singletonList(LOCK_PREFIX + key), 
                Collections.singletonList(lockValue));
            lockValueHolder.remove();
        } finally {
            jedis.close();
        }
    }
}

重构之后的分布式锁,解决了原来的两个问题:

  1. 原子性:用SET key value NX EX timeout一条命令,原子地完成加锁和设置过期时间,不会出现死锁。
  2. 锁所有者标识:用UUID作为锁的值,标识锁的所有者;释放锁的时候,用Lua脚本原子地判断锁值并删除,不会误删别人的锁。

并且,在CP场景和AP场景,可以用不同的锁策略。CP场景下,用这个分布式锁,保证强一致性;AP场景下,不用分布式锁,或者用很宽松的策略,优先保证可用性。

例子3:拆分后的createOrder函数

@Service
public class OrderService {
    // ... 注入各种依赖 ...
    
    @Transactional
    public OrderResult createOrder(OrderRequest request) {
        // 1. 校验参数
        validateOrderParam(request);
        
        // 2. 查询用户和商品信息
        UserInfo user = getUserInfo(request.getUserId());
        ProductInfo product = getProductInfo(request.getProductId());
        
        // 3. CP场景:加分布式锁,防止重复创建和超卖
        String lockKey = "create_order:" + request.getUserId() + ":" + request.getProductId();
        if (!distributedLock.tryLock(lockKey, 30)) {
            throw new BusinessException("系统繁忙,请稍后重试");
        }
        
        try {
            // 4. 计算价格和优惠
            BigDecimal price = calculatePrice(product, request.getQuantity());
            BigDecimal discount = calculateDiscount(user, product, price);
            BigDecimal actualAmount = price.subtract(discount);
            
            // 5. CP场景:扣减库存(调用失败会抛出异常,触发事务回滚)
            inventoryService.deduct(product.getId(), request.getQuantity());
            
            // 6. 生成并保存订单
            Order order = buildOrder(user, product, request, price, discount, actualAmount);
            order = saveOrder(order);
            
            // 7. AP场景:发送消息(异步,不影响主流程)
            orderMessageSender.send(order);
            
            // 8. 记录日志
            recordOperationLog(user, order);
            
            return buildResult(order);
        } finally {
            distributedLock.unlock(lockKey);
        }
    }
}

重构之后的createOrder函数,只有几十行,逻辑清晰,职责明确。它只负责编排,具体的业务逻辑都在小函数里。并且,明确区分了CP场景和AP场景:创建订单、扣减库存是CP场景,用分布式锁和事务,保证一致性;发送消息是AP场景,异步处理,不影响主流程,保证可用性。

五、重构过程中的一些思考和经验

这次重构,花了我将近两周的时间,过程中遇到了很多问题,也积累了一些经验。在这里,分享给大家。

1. 重构之前,一定要有测试

这是最重要的一点。重构之前,一定要有足够的测试用例,确保重构之后,功能和原来一致。如果没有测试,重构就是盲人摸象,改完之后不知道有没有改出问题,很容易引入新的bug。

我在重构之前,先花了几天时间,给核心的业务逻辑写了单元测试和集成测试,确保原来的功能都被测试覆盖了。然后,每改一部分,就跑一遍测试,确保没有破坏原来的功能。

如果原来的代码太烂,写测试很困难,可以先写一些粗粒度的集成测试,覆盖主要的业务流程,然后再逐步细化。

2. 小步快跑,逐步重构

不要试图一次就把所有代码都重构完,那样风险太大,也很容易半途而废。应该小步快跑,一次只重构一部分,每重构完一部分,就测试、验证,确保没问题了,再重构下一部分。

我这次重构,就是分模块进行的。先重构HTTP调用的部分,然后是分布式锁的部分,然后是订单创建的部分,然后是用户信息的部分,一个模块一个模块地来。每个模块重构完,都跑一遍测试,验证没问题了,再继续下一个。

这样,即使重构过程中出了问题,影响范围也有限,容易回滚和修复。

3. 不要在重构的同时加新功能

重构的时候,只做代码结构的优化,不要同时加新功能。因为,重构的目的是改善代码结构,不改变功能。如果同时加新功能,就分不清是重构出了问题,还是新功能出了问题,排查起来很困难。

如果确实需要加新功能,可以等重构完成之后,再加。或者,先加新功能,再重构。不要同时进行。

4. CAP理论不是非黑即白的

最后,关于CAP理论,我想说的是,CAP不是非黑即白的,不是说一个系统要么是CP,要么是AP。实际上,很多系统,是在CP和AP之间的一个连续谱,根据不同的场景,有不同的权衡。

而且,CAP理论说的是,在网络分区发生的时候,你只能在C和A之间选一个。但是,网络分区不是经常发生的,在大部分时间里,网络是正常的,这个时候,你可以同时保证C和A。CAP理论的核心,是让你在网络分区发生的时候,有一个明确的取舍策略,知道该优先保证什么,放弃什么。

所以,在实际的系统设计中,不要简单地给系统贴上CP或AP的标签,而是要根据具体的业务场景,分析每个场景对一致性和可用性的要求,然后设计对应的策略。该一致的地方保证一致,该可用的地方保证可用,在不同的场景下做不同的权衡,这才是CAP理论的正确应用方式。

六、写在最后

这次基于CAP理论的代码重构,虽然过程很辛苦,花了将近两周的时间,但是收获很大。重构之后,代码的质量有了质的提升,可读性、可维护性、可扩展性都大大提高。而且,通过明确每个场景的CAP选择,系统的一致性和可用性也得到了合理的权衡,该一致的地方一致,该可用的地方可用,整个系统更加稳定和可靠。

更重要的是,通过这次重构,我对CAP理论有了更深的理解,也对分布式系统的设计和代码重构有了更多的经验。这些经验,在以后的工作中,都会很有帮助。

代码重构,是每个程序员都会遇到的事情。面对烂代码,我们不要逃避,也不要抱怨,而是要有计划、有步骤地去重构它。重构的过程,虽然辛苦,但是也是一个学习和成长的过程。当你把一堆烂代码,重构得清晰、优雅、可维护的时候,那种成就感,是很难形容的。

最后,用一句话来总结这次重构的感受:"烂代码不可怕,可怕的是面对烂代码却无动于衷。只要有正确的方法和足够的耐心,再烂的代码,也能重构成优雅的代码。"

愿我们都能写出优雅的代码,也都有勇气和能力去重构烂代码。