最近,我接手了一个老项目的分布式服务代码,看完之后,我整个人都不好了。
这个项目,是一个分布式的订单服务,部署在多个节点上,需要处理高并发的订单请求。理论上,应该是一个设计良好、代码优雅的分布式系统。但是,实际看到代码的时候,我惊呆了。
代码里到处都是硬编码,数据库地址、超时时间、重试次数,全是写死的;到处都是复制粘贴,同样的逻辑,在不同的地方复制了五六份,每一份还有细微的差别;到处都是超长函数,一个函数两三百行,什么都干,根本看不出逻辑;异常处理更是混乱,有的地方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();
}
}这个实现,有两个严重的问题:
- 非原子性:SETNX和EXPIRE是两个操作,不是原子的。如果SETNX成功之后,进程崩溃了,没有执行EXPIRE,那么这个锁就永远不会过期,变成死锁。
- 没有锁的所有者标识:锁的值是写死的"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();
}
}
}重构之后的分布式锁,解决了原来的两个问题:
- 原子性:用SET key value NX EX timeout一条命令,原子地完成加锁和设置过期时间,不会出现死锁。
- 锁所有者标识:用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理论有了更深的理解,也对分布式系统的设计和代码重构有了更多的经验。这些经验,在以后的工作中,都会很有帮助。
代码重构,是每个程序员都会遇到的事情。面对烂代码,我们不要逃避,也不要抱怨,而是要有计划、有步骤地去重构它。重构的过程,虽然辛苦,但是也是一个学习和成长的过程。当你把一堆烂代码,重构得清晰、优雅、可维护的时候,那种成就感,是很难形容的。
最后,用一句话来总结这次重构的感受:"烂代码不可怕,可怕的是面对烂代码却无动于衷。只要有正确的方法和足够的耐心,再烂的代码,也能重构成优雅的代码。"
愿我们都能写出优雅的代码,也都有勇气和能力去重构烂代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录