一、CQRS写模型中聚合边界与事务粒度的关系
在计算机系统里,CQRS(Command Query Responsibility Segregation,命令查询职责分离)写模型是个挺重要的东西。这里面的聚合边界跟事务粒度关系密切,就好比包饺子,饺子皮的大小(聚合边界)决定了能包进去多少馅料(事务粒度)。
1.1 聚合边界与事务粒度的概念
聚合是领域驱动设计里的一个概念,简单来说,它就是一组关联对象的集合,这些对象作为一个整体来处理。而聚合边界,就是这个集合的范围。事务粒度呢,就是一个事务能处理的操作数量和范围。比如在一个电商系统里,一个订单可能就被当成一个聚合,这个订单关联的商品、收货地址、买家信息等都在聚合边界内。一个事务可能就包括创建订单、扣减库存、增加用户积分这些操作,这些操作的范围和数量就是事务粒度。
1.2 聚合边界对事务粒度的影响
聚合边界要是太大,就像饺子皮太大太松,包的馅料就多,也就是事务粒度大。在并发场景下,多个操作可能同时想修改这个大聚合,就容易产生锁冲突。比如电商系统里,同时有好几个用户想修改同一个订单的收货地址,因为这个订单这个聚合范围大,很可能就会产生锁冲突,大家都得等着。
要是聚合边界太小,就像饺子皮太小,包不了多少馅料,事务粒度就小。虽然锁冲突是少了,但命令的一致性就难保证了。还是拿电商系统举例,把订单的各个部分,比如商品信息、收货地址、买家信息等都分成小聚合来处理。创建订单的时候可能商品信息更新了,但收货地址没更新上,就造成数据不一致。
二、聚合太粗和太细带来的问题
2.1 聚合太粗导致锁冲突频繁
在高并发的写场景下,聚合太粗的问题很明显。下面用一个简单的 C# 代码示例来说明:
// 定义一个大聚合,比如一个订单系统中的订单聚合
class OrderAggregate
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
public List<OrderItem> OrderItems { get; set; }
public string ShippingAddress { get; set; }
public void UpdateShippingAddress(string newAddress)
{
// 模拟更新收货地址的操作
ShippingAddress = newAddress;
}
public void AddOrderItem(OrderItem newItem)
{
// 模拟添加订单商品的操作
OrderItems.Add(newItem);
}
}
class OrderItem
{
public int ItemId { get; set; }
public string ProductName { get; set; }
public int Quantity { get; set; }
}
class Program
{
static void Main()
{
OrderAggregate order = new OrderAggregate();
// 假设有多个线程同时对这个订单进行操作
Thread t1 = new Thread(() => order.UpdateShippingAddress("New Address 1"));
Thread t2 = new Thread(() => order.AddOrderItem(new OrderItem { ItemId = 1, ProductName = "Product 1", Quantity = 1 }));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
}
}
在这个示例中,OrderAggregate 是一个大聚合。多个线程同时对它进行操作时,很可能会出现锁冲突。因为一个线程在更新收货地址时,另一个线程也想修改订单商品,就会产生竞争,需要排队等待,影响系统的性能和响应速度。
2.2 聚合太细导致命令一致性难以保证
当聚合太细时,命令一致性就成了大问题。下面还是用 C# 写个简单示例:
// 把订单拆分成多个小聚合
class OrderHeader
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
public void UpdateCustomerName(string newName)
{
CustomerName = newName;
}
}
class OrderItemsCollection
{
public int OrderId { get; set; }
public List<OrderItem> OrderItems { get; set; }
public void AddOrderItem(OrderItem newItem)
{
OrderItems.Add(newItem);
}
}
class ShippingAddressInfo
{
public int OrderId { get; set; }
public string ShippingAddress { get; set; }
public void UpdateShippingAddress(string newAddress)
{
ShippingAddress = newAddress;
}
}
class Program
{
static void Main()
{
OrderHeader orderHeader = new OrderHeader();
OrderItemsCollection orderItems = new OrderItemsCollection();
ShippingAddressInfo shippingAddress = new ShippingAddressInfo();
// 模拟创建订单的流程,三个操作可能在不同事务中
Thread t1 = new Thread(() => orderHeader.UpdateCustomerName("New Customer"));
Thread t2 = new Thread(() => orderItems.AddOrderItem(new OrderItem { ItemId = 1, ProductName = "Product 1", Quantity = 1 }));
Thread t3 = new Thread(() => shippingAddress.UpdateShippingAddress("New Address"));
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
}
}
在这个示例中,把订单拆分成了 OrderHeader、OrderItemsCollection 和 ShippingAddressInfo 三个小聚合。在高并发场景下,这三个操作可能在不同的事务中执行,很可能会出现部分操作成功,部分操作失败的情况,导致数据不一致。比如订单商品添加成功了,但收货地址更新失败,这样订单数据就不完整了。
三、权衡聚合边界以扛住高并发写
3.1 基于业务场景划分聚合边界
要根据具体的业务场景来划分聚合边界。比如在电商系统中,对于一些经常一起操作的数据,就可以放在一个聚合里。像订单和订单商品,它们的操作关联性很强,通常在创建订单时会同时处理商品信息,所以可以把订单和订单商品放在一个聚合里。而对于一些不太相关的数据,比如用户的积分信息和订单信息,就可以分开成不同的聚合。
以下是一个 C# 示例,展示如何根据业务场景划分聚合:
// 订单和订单商品聚合
class OrderWithItemsAggregate
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
public List<OrderItem> OrderItems { get; set; }
public void AddOrderItem(OrderItem newItem)
{
OrderItems.Add(newItem);
// 可以在这里添加一些业务逻辑,比如更新订单总价
}
}
// 用户积分聚合
class UserPointsAggregate
{
public int UserId { get; set; }
public int Points { get; set; }
public void AddPoints(int points)
{
Points += points;
}
}
class Program
{
static void Main()
{
OrderWithItemsAggregate order = new OrderWithItemsAggregate();
UserPointsAggregate userPoints = new UserPointsAggregate();
// 模拟订单操作和用户积分操作
order.AddOrderItem(new OrderItem { ItemId = 1, ProductName = "Product 1", Quantity = 1 });
userPoints.AddPoints(10);
}
}
这样划分聚合边界,既能减少锁冲突,又能保证命令的一致性。因为订单和订单商品的操作在一个聚合里,它们的一致性可以通过事务来保证,而用户积分和订单操作分开,不会相互影响。
3.2 采用最终一致性
在高并发场景下,可以采用最终一致性来保证数据的一致性。最终一致性是指在一段时间后,系统中的数据会达到一致状态。比如在电商系统中,当用户下单时,先记录订单信息,然后异步处理库存扣减和用户积分增加等操作。即使在处理过程中出现一些短暂的不一致,只要最终数据能达到一致就行。
以下是一个 C# 示例,展示最终一致性的实现:
// 订单服务
class OrderService
{
public void PlaceOrder(Order order)
{
// 记录订单信息
SaveOrder(order);
// 异步处理库存扣减和用户积分增加
Task.Run(() =>
{
DeductInventory(order.OrderItems);
AddUserPoints(order);
});
}
private void SaveOrder(Order order)
{
// 保存订单信息到数据库
Console.WriteLine($"Order {order.OrderId} saved.");
}
private void DeductInventory(List<OrderItem> orderItems)
{
// 扣减库存操作
foreach (var item in orderItems)
{
Console.WriteLine($"Deduct {item.Quantity} of {item.ProductName} from inventory.");
}
}
private void AddUserPoints(Order order)
{
// 增加用户积分操作
Console.WriteLine($"Add {order.TotalPrice * 0.1} points to user.");
}
}
class Order
{
public int OrderId { get; set; }
public List<OrderItem> OrderItems { get; set; }
public decimal TotalPrice { get; set; }
}
class OrderItem
{
public int ItemId { get; set; }
public string ProductName { get; set; }
public int Quantity { get; set; }
}
class Program
{
static void Main()
{
OrderService orderService = new OrderService();
Order order = new Order
{
OrderId = 1,
OrderItems = new List<OrderItem>
{
new OrderItem { ItemId = 1, ProductName = "Product 1", Quantity = 1 }
},
TotalPrice = 100
};
orderService.PlaceOrder(order);
}
}
在这个示例中,下单操作和库存扣减、用户积分增加操作是分开处理的。下单操作先完成,然后异步处理库存扣减和用户积分增加。即使在异步处理过程中出现一些延迟或失败,只要有相应的补偿机制,最终数据还是能达到一致的。
3.3 使用分布式锁和乐观锁
在高并发场景下,可以使用分布式锁和乐观锁来控制并发访问。分布式锁可以保证在分布式系统中同一时间只有一个线程能访问某个资源,而乐观锁则是通过版本号等机制,让多个线程可以同时访问资源,只有在更新时检查数据是否被其他线程修改过。
以下是一个 C# 示例,展示乐观锁的使用:
// 商品实体
class Product
{
public int ProductId { get; set; }
public string ProductName { get; set; }
public int Quantity { get; set; }
public int Version { get; set; }
public bool UpdateQuantity(int newQuantity, int currentVersion)
{
if (currentVersion == Version)
{
Quantity = newQuantity;
Version++;
return true;
}
return false;
}
}
class Program
{
static void Main()
{
Product product = new Product
{
ProductId = 1,
ProductName = "Product 1",
Quantity = 10,
Version = 1
};
// 模拟两个线程同时更新商品库存
Task t1 = Task.Run(() =>
{
bool result = product.UpdateQuantity(5, 1);
if (result)
{
Console.WriteLine("Thread 1 updated quantity successfully.");
}
else
{
Console.WriteLine("Thread 1 failed to update quantity due to version conflict.");
}
});
Task t2 = Task.Run(() =>
{
bool result = product.UpdateQuantity(3, 1);
if (result)
{
Console.WriteLine("Thread 2 updated quantity successfully.");
}
else
{
Console.WriteLine("Thread 2 failed to update quantity due to version conflict.");
}
});
Task.WaitAll(t1, t2);
}
}
在这个示例中,Product 类使用 Version 字段来实现乐观锁。当一个线程更新商品库存时,会先检查当前版本号是否和传入的版本号一致。如果一致,就更新库存并增加版本号;如果不一致,就说明数据已经被其他线程修改过,更新失败。这样可以减少锁冲突,提高系统的并发性能。
四、应用场景
4.1 电商系统
在电商系统中,高并发写操作很常见。比如在促销活动期间,大量用户同时下单,就需要处理大量的订单创建、库存扣减、用户积分增加等操作。通过合理划分聚合边界,采用最终一致性和使用分布式锁、乐观锁等技术,可以保证系统在高并发场景下的稳定性和数据一致性。
4.2 社交媒体系统
社交媒体系统中,用户的点赞、评论、发布动态等操作都是高并发写操作。比如在热门话题讨论时,大量用户同时发布评论,这就需要处理大量的评论数据写入。通过合理规划聚合边界,可以减少锁冲突,同时采用最终一致性来保证数据的一致性。
五、技术优缺点
5.1 优点
- 提高并发性能:通过合理划分聚合边界,减少锁冲突,让系统能处理更多的并发写操作。比如在电商系统中,合理划分订单和用户积分的聚合边界,就可以让这两个操作同时进行,提高系统的处理能力。
- 保证数据一致性:采用最终一致性和锁机制,可以在高并发场景下保证数据的一致性。即使在某些操作出现延迟或失败时,通过补偿机制也能让数据最终达到一致状态。
- 适应业务变化:根据业务场景划分聚合边界,可以更好地适应业务的变化。比如电商系统增加新的业务功能时,可以根据新功能的特点调整聚合边界。
5.2 缺点
- 实现复杂度高:采用最终一致性和分布式锁、乐观锁等技术,需要处理很多复杂的情况,比如异步处理、补偿机制、版本控制等,增加了系统的实现复杂度。
- 数据一致性延迟:最终一致性会导致数据在一段时间内不一致。虽然最终会达到一致状态,但在某些对数据一致性要求很高的场景下,可能会有问题。比如在金融系统中,用户进行转账操作,就需要实时保证数据的一致性,最终一致性就不太适用。
六、注意事项
6.1 业务理解要深入
在划分聚合边界时,一定要深入理解业务需求和业务流程。只有准确把握业务的关联性和独立性,才能合理划分聚合边界。比如在电商系统中,如果不了解订单和用户积分的业务关系,就可能把它们划分到同一个聚合里,导致锁冲突频繁。
6.2 测试要充分
采用最终一致性和锁机制等技术,需要进行充分的测试。要测试在各种并发场景下系统的性能和数据的一致性。比如在电商系统中,要测试在大量用户同时下单的情况下,库存扣减和用户积分增加是否正确,是否会出现数据不一致的情况。
6.3 监控和调优要持续
在系统上线后,要持续对系统进行监控和调优。监控系统的性能指标,如响应时间、吞吐量等,以及数据的一致性情况。根据监控结果,及时调整聚合边界和锁机制等参数,以保证系统的性能和数据一致性。
七、文章总结
在 CQRS 写模型中,聚合边界和事务粒度的权衡是一个关键问题。聚合太粗会导致锁冲突频繁,聚合太细会导致命令一致性难以保证。为了扛住高并发写,我们可以基于业务场景划分聚合边界,采用最终一致性和使用分布式锁、乐观锁等技术。同时,要注意深入理解业务、充分测试和持续监控调优等事项。通过合理的权衡和技术手段的应用,可以让系统在高并发写场景下保持稳定和高效。
评论
围绕“CQRS写模型中的聚合边界决定了事务粒度,聚合太粗锁冲突频繁,聚合太细命令一致性难以保证,怎么权衡边界才能扛住高并发写?”参与讨论