设计可扩展性系统的一个实例
曾经在一家大型网络公司为了可扩展性要求而重新开发过新的总帐系统。当时已有系统的最初设计容量只能处理几万帐户,随着公司业务的增加和整个付款系统的重新设计,要求现有的系统容纳上百万帐户,每天记录的交易量也由几万上升到数百万。整个设计的理念就是使用横向扩展克服单个数据库的性能瓶颈。
下面举一个类似的例子。公司有数种商务交易平台;系统A 记录客户在某平台的所有交易记录(表格User,Transaction), 系统B收集客户所有交易活动的总帐结果(表格User)。
这个模式包括了用户和交易,虽然模式相对于实际情况过于粗略, 但依然可以展现系统扩展性和一致性方面的必要元素。这里有两个方面影响到系统的可用性和可扩展性:1)由于涉及到2个独立系统,在第一章我们提到两个系统的整合点是SPOF,直接影响系统的可用性;同时两个系统的功能高度耦合/关联不仅仅影响可用性,也影响可扩展性。2 )对于系统A本身, 如果需要处理上百万的帐户和交易,数据库很容易成为瓶颈。 下面分别讨论2 个问题。
(一)2个独立系统之间的去偶联
如果使用基于ACID的SQL事务处理
Begin transaction
//更新系统A
Insert into A::transaction (_xid, seller_id, _amount )
Update A::user set _amt = _amt + $amount where id = $seller_ id
//更新系统B
Update B::user set _amt = _amt = $amount where id = $seller_ id
End transaction
因为系统A,B在不同的机器上, 这里必须使用2PC 全局交易管理。我们提过,2PC 是系统性能的瓶颈,而且在交易发生过程中使系统A,B高度偶联而同时降低了整个系统的可用性。 解决方案是什么呢? 考虑实际的商业需要来弱化一致性的要求。
系统B用户表中的总交易额项是从各个交易记录中计算出的衍生项,可以看作为了提高系统效率的一个缓存值,避免每次从不同系统的交易表中重新累积结果 。也就是说更新记录交易和计算所有交易的总和,这两者之间可以放宽时间上的严格一致性约束 。事实上人们在实际生活经常遇到这种交易和结果之间的延迟(例如查询手机当月用量, 系统都会提示查询结果可能不包括最近的用量)。因此可以重新定义用户对系统行为的期望, 用户的历史交易总额不反映一个交易的立即结果。
下面是修改过的SQL 语句, 因为每步事务都发生在当地数据库,避免了 全局事务管理的2PC的性能瓶颈。
//更新系统A
Begin transaction
Insert into A::transaction (_xid, seller_id, _amount )
Update A::user set _amt = _amt + $amount where id = $seller_ id
End transaction
//更新系统B
Begin transaction
Update B::user set _amt = _amt = $amount where id = $seller_ id
End transaction
为了提高处理系统A 交易记录的 进程的可用性和处理通量, 将这2步事务作为完全独立的事务放在不同的进程中处理,这样第一和第二的事务之间通讯故障或者第二事务的失败,都将导致系统A的事务表和系统B的用户表之间永久不一致;这种不一致性也许对社交网,博客,甚至网上购票系统的查询业务是可以接受的,但是对于金融,电子商务等很多传统领域是不可接受的。
引入一个持久消息队列(persistent message queue )可以保证在第一和第二的
事务之间的异步通讯。
Begin transaction
//更新系统A
Insert into A::transaction (_xid, seller_id, _amount )
Update A::user set _amt = _amt + $amount where id = $seller_ id
//将更新消息放入队列
Queue message “update B::User (_seller_id, _amount)
End transaction
//系统B从队列中读取请求
For each message in queue
Begin transaction
Dequeue messge
Update B::user set _amt = _amt + $amount where id = $seller_ id
End transaction
End
通过消息系统的异步处理, 我们成功的避免了2PC陷阱。 但是这个方案依然可能导致系统A的事务表和系统B的用户表之间永久不一致: 1)网络故障等不能保证消息系统一次性传输成功,和2)系统B的用户表更新失败。 下面分别讨论相应的解决方案。
(A) 实现幂等特性的API
在SOA的实现一节中介绍过幂等的概念。一个操作被认为具有幂等特性, 如果如果它可以被应用一次或多次都具有相同的结果。 幂等操作是非常有用的,因为它们允许部分失败,操作反复尝试而不改变系统的最终状态。
如果系统B实现 幂等操作, 可以保证消息传输的成功实现。 实现 幂等操作,需要消息内部具有全局的唯一识别符,这可以使消息标识符或者交易标识符,这里我们使用(transaction_id, user_id) 作为全局唯一识别符;同时需要建立一个新的表来跟踪记录哪些更新已成功地应用。
Begin transaction
//更新系统A
Insert into A::transaction (_xid, seller_id, _amount )
Update A::user set _amt = _amt + $amount where id = $seller_ id
//将更新消息放入队列
Queue message “update B::User (seller_id, xid, amount)
End transaction
//系统B从队列中读取请求
For each message in queue
Begin transaction
Dequeue messge
//查询该消息是否已经被使用,以满足幂等操作
Select count(*) as used from B::accepted_trans where _xid = message.xid and _sell _id = message.sell_id and _amount = message.amount
//如果消息没有被使用,更新用户总表,和记录改更新
if used == 0
Update B::user set _amt = _amt + $amount where id = $seller_ id
Insert into B::accepted_trans ( message.xid, message.user_id, message.amount)
end if
End transaction
End
这样我们在不使用2PC的情况下,在容忍系统部分失败的同时仍然提供事务处理的保证。
(B)处理系统B的可能出错。
系统B中的用户帐户余额的更新交易有可能失败, 解决方法是改进处理流程,在余额的更新操作成功后再删除队列中的消息。 这可以用两个独立的事务处理来实现:一个在消息队列,一个在用户数据库。数据库操作提交成功后,队列操作才提交。
有的系统对于数据的完整性要求非常严格,比如涉及到金钱的付款系统。 对于使用消息管理的分布式系统的异步通讯,往往最终可以使用类似于财会中的会计合并(Consolidation)方法,每晚在后台异步运行合并程序来检查不同系统之间的一致性。
(二)单独系统的扩展性设计
还是使用相同的数据库模型,关系很简单
对于相似的数据, 前面提到可以碎片化(Sharding)分区来获得横向的可扩展性。 同时,我们还要考虑数据重要性的不同,来控制数据库中表的大小。
(A) 数据分区
按照用户分区是比较自然也比较常见的选择。在选择分区依据时,一要分区依据的特性稳定,二是要各个分区的负载相对平衡。 如果系统A的接口API 使用的是用户姓名,可以按照姓名的字典排序分区; 为了避免一些最活跃用户分配在同一个分区里,可以对姓名取哈希值,并且维持一个列表对一些特定的用户分配特定的分区。
(B) 数据的不同商业价值的处理
提高系统性能的第二个技巧是依据数据的不同商业价值,用不同的方式存储查询。比如对于用户交易记录,显然当前的记录比3年前的更有商业价值, 周期性的把一定年限的数据归档(Archive)可以控制主表的大小而提高系统效率。
( C )API 设计
合理的API设计可以防止恶意/ 粗心的用户对整个系统的不良影响。 比如提供用户一次性选择所有交易记录不仅仅可能没有合理的商业意义,而且会严重影响整个系统的性能。
如果用户需要获得所有交易记录, 分页化(Pagination)的API 是合理方案, 分页化不仅降低了每次查询的系统负载, 而且分页化本质上是一种延迟(Laziness)技巧,避免了用户不关心的数据的使用,最后绝大多数合理设计的UI都提供分页查询界面, 因此, 虽然完整的分页API要执行计算交易记录总数的额外查询,分页化的API依然是查询结果比较大的情况下的合理的选择。
(Result-set) getTransactions(user_name, page_num, page_size)
而对于用户的基于时间区段的查询, API 需要说明使用规则
// start_time must be within 3 years
//period_length must be less than XXX
(Result-set)getTransactions(user_name, start_time, period_length)
如果用户需要查询3年以上的交易记录, 系统需要提供不同的API, 甚至使用异步执行的API。(AMD Asynchronous Method Dispatch )。在这种情况下有两种选择,A) 可以使用一个全双工(Full-dulex)模式(客户端也是一种服务,在任务完成时客户端得到通知)或B) 轮询(Polling)模式(客户端主动查询结果)。这两种解决方案都需要系统维持查询结果, 是一种维持状态的服务。
本文出自 “静水流深” 博客,转载请与作者联系!