分布式系统中时序的重要性
本文是我们组前2天讨论交易邮件的处理流程过程讨论过程的总结。
交易邮件就是用户在拿取附件的同时,必须付给发件人指定的相应的报酬的邮件。
由于Pets是一个全区全服的游戏系统系统,邮件服务器可能有多台,不同的用户的邮件存放在不同的邮件服务器Mailsvrd 上。
假设的场景都是用户B发送交易邮件给用户A,用户A如果同意交易,则在拿取附件的同时,要按照用户B的要求数量给用户B发送一封付款(Pets的货币名称是元宝)邮件。用户A的邮件数据在邮件服务器Mailsvrd A上,用户B的邮件在邮件服务器Mailsvrd B上。另外客户端不直接链接Mailsvrd,而是通过接入层的Fedsvrd进行游戏。
图1 邮件系统架构
1 “正常”思维的方案
最开始的方案如下图,这是一个典型的“正常”思维的过程,处理的时序思路就是按部就班。
这个过程基本思路就是客户端在请求拿取附件后,前端的养成服务器负责处理整个流程,和后面的2个Mailsvrd服务器交互完成处理。
图2 正常的拿取邮件请求
如果用户正常,这个事情也没有任何问题。但是……,中国的用户都是不正常的。假如用户破解客户端或者直接修改协议进行发送,(Sniffer这类工具都有这类功能)。那么用户可能在第一次请求拿取附件后,再发送一条拿取附件请求。如下图,注意中间的那条蓝色的线。
图3 用户攻击导致服务器异常
这个请求,在服务器上一般会新开一个处理单元(线程或者事务处理对象)对这个请求进行处理。那么很可能(触发你有复杂的保护代码和回滚逻辑)导致一个结果是可以多次获得邮件附件。
2 加入事务锁
发现这个问题后,进行了思路的改变。我们要保证用户在一段时间内只能发起一个这样的事务。
事务锁可以加在前端服务器(面向客户端),但是考虑到前端服务器的有很多个点,锁的控制点放在后面的控制点。
觉得必须在Mailsvrd上增加一个拿取附件的事务,同时对这个事务增加事务锁。
改造后的时序变成了如下:
图4 加入事务锁的时序
这个方法是在用户A拿取附件请求Mailsvrd服务器的时候,Mailsvrd服务器对这个用户的拿取行为进行加锁。如果用户A再有任何拿取请求都拒绝。
事务锁本身就是一个限制检查,不是阻塞类型。所以对服务器的性能没有影响。
3 如果有天灾人祸
这样的确安全了很多,但是由于是分布式系统,任何一个节点都可能坏掉,天灾人祸是避免不了的,那么假如Mailsvrd B服务器坏掉了呢?
有两种糟糕可能,部分用户倒霉或者部分用户可能得到可以利用的漏洞。
假如你的代码时序就如同加入事务锁的时序,那么Mailsvrd B 请求失败的情况下,用户B将无法得到应得的元宝。另外假如你的时序和前面的方案略有差别,仅仅改变了修改邮件A状态以及给用户B邮件的时序前后关系,结果。结果会如何呢。这要看你如何处理Mailsvrd B返回的失败了。如果你在失败的情况下没有回滚操作,而且没有继续后面的操作到Mailsvrd A上修改邮件的状态,那么就可能导致用户A在这段时间内都能利用这个漏洞反复获得邮件内部的物品。
图5 天灾人祸
所以在分布式系统的多阶段(可以理解为有限状态机)的处理过程中,一定要考虑超时处理以及错误处处理。后面我们再来慢慢分析这些问题。
4 把危险的操作放在前面
再回头看看2种情况,先到Mailsvrd A服务器上处理修改邮件状态,还是先到Mailsvrd B上发送邮件。这的确是一个问题。
危险的操作步骤放在前面操作,主要的目的是在出现错误后避免处理更多的回滚操作。【注】
【注】危险操作步骤尽量放在前面完成,这应该算一个准则,但也要明白的是一切准则都有例外。
所以如果为了避免更多的在出现错误后进行复杂的回滚操作。先处理危险的操作是一个比较好的选择。
假设服务器的处理逻辑都正确(一般情况还是应该这样假设吧),那么可以任务,交互第一步骤FEDSVRD已经到Mailsvrd A上取过一次邮件,在这个事务的周期(最不及也只有5s时间吧)内,Mailsvrd A停止服务(coredump,断电)的概率不会很高,大致小于0.0001%吧,就算Mailsvrd A在用户读取邮件后停止了服务,会出现问题的用户大致也只有5s以内,为这5s内出错的用户写回滚语句是否值得全看你的个人意志和观点。个人倾向于逃避这个问题,记录日志便于日后回溯也许就足够了(大家也许好奇,为什么我一直主张逃避复杂的回滚操作,请看下一节)。
但对于服务器B,我们此时无法判断他是否可靠【注】。所以在读取邮件信息操作后,对于Mailsvrd B的操作危险成都远远大于对于Mailsvrd A的操作。
【注】一直在思考在分布式系统中的一个问题,有没有方法让一个服务器知道其他服务器的状态?到目前为止我的结论还是未必能实现,而且实现的意义待考。因为服务器的架构往往希望对于很多其他节点屏蔽信息。
综合前面的意见,我们又将服务器的时序调整成了下面这个样子。
图6 危险操作放前面的方案
5 回滚,适度就好
首先必须说明,回滚很难。分布式系统的回滚更是难上加难。但是也绝不是完全不做回滚操作。这个也符合tony老大的柔性服务思路。
5.1 为什么回滚那么难
前面这个例子,我们的回滚操作只发生在2个阶段后面,第一个是给用户B发送付款邮件失败,第二个是删除邮件的附件失败。
如果在第一个阶段失败后回滚,我们要回滚删除用户A的道具(前面增加了),回滚增加用户A的元宝(前面扣除了)。
如果在第二个阶段失败后回顾,我们要删除给用户B的邮件,还要要回滚删除用户A的道具,回滚增加用户A的元宝。
如果将前面的处理看做有限状态机,你可以认为在一个阶段上发生问题后,就是要在前的步骤逆向走一次。
是不是够复杂了?还不复杂?如果回滚的步骤中又有一个失败了呢?【注】
【注】一般而言,回滚的操作失败的处理方式是继续回滚。
所以尽量少回滚,能用时序避免的就避免。在”危险操作放前面的方案”中,如果认为删除邮件附件的失败几率很小,完全可以不去考虑回滚,因为一旦发生故障,这样影响的用户数量会极少,极少。毕竟我们不是银行业。对于这类故障的处理方式,可以考虑记录本地日志和在日志服务器上记录问题。这样至少保证日后有据可查。
5.2 完全不回滚?找死
这个观点几乎也不用解释,涉及钱,用户财富的事情,如果能回滚还是有必要进行回滚操作的。
5.3 容易回滚的步骤先处理
还是前面的哪个例子,如果我们的方案已经是”危险操作放前面的方案”。只在第一个阶段给用户B发送付款邮件失败后进行回滚,那么回滚操作只会有两个,回滚增加用户的元宝,回滚扣除用户获得的物品。
一般而言,用户的元宝比较容易回滚,而背包操作的回滚就要复杂一些,完整的回滚方案要么是记录原来放入的位置才能正确回滚,或者要采用加锁,再记录用户原有背包数据便于在回滚中使用这样的方式。
再回头看原来时序,你可以发现其实发送邮件前,只需要先扣除用户A的元宝就可以继续给用户B发送邮件,不用先给用户A邮件附件。这样我们可以进一步改进方案。将用户A拿取附件的时序放在给用户B发送邮件后面。
这样如果给用户B发送邮件失败后,只需要回滚增加用户A的元宝。这大致是我们最终的方案。
图6 容易回滚的步骤先处理
5.4 如何写回滚
前面以及说过了,最简单的方法一般都是加锁,然后记录下原有的数据,再进行操作,回滚时利用原有的数据恢复数据区,最后解锁。【注】
【注】比较麻烦的是Pets有一些操作是服务器自动给(扣)用户物品,用户不用主动操作,此时加锁也会引发一些麻烦。我认为这是一些设计导致的结构复杂性。作为程序很难解决这类设计导致的结构化矛盾。
另外,对于用户的请求,处理的模型是采用有限状态机(或者说是事务)的模型,这样在回滚的过程中,可以根据这个所处的状态进行回滚。
6 总结
分布式系统的开发过程中,由于往往要修改N个点的数据才能完成操作,事务性很难保证,要仔细实考各个某个操作的各个步骤的时序。
分布式系统的是一个面向异常的系统
危险的步骤一般会放在前面,这样可以避免出现更多的回滚操作。
谨慎面对回滚类的操作,