真实案例引起的对系统健壮性的思考
大年初四(2012年1月26日)上午,我在重庆移动某营业厅的自助客户端使用招商银行信用卡为我妻子充话费(我妻子的手机已经停机)。在插入信用卡并输入密码后,系统提示正在交易。大约几秒后,我的手机收到招行的短信,提示消费100元,但自助客户端仍然显示正在交易。此时的我已经有了不详的预感。果然,在等待大约一分钟,系统提示操作失败,之后系统崩溃,弹出了一个Windows命令窗口。因为我妻子的手机停机了,所以立刻可以确认上一次充值确实是失败的。而我的手机能收到信用卡的消费信息,则可以确认银行确实已经支付了100元(我之后查询了我的信用卡账单,确实存在这一笔消费记录)。
之后,我又用现金充值话费,此次操作成功了,但却显示赠送的3元话费失败,提示相同Id的赠送记录冲突。这是因为系统规定了约束,要求当月同一个号码只能享受一次优惠。但问题是,我之前用信用卡的充值并未成功,也未收到赠送的话费。
我没有机会能够看到该自助充值系统的设计与代码,但以我的开发经验,可以直观感受到这是事务出现了问题。这个简单的充值操作,实际上完成了四个职责:
1)调用第三方的银行支付服务,付费;
2)获取优惠策略,并计算优惠金额;
3)保存优惠记录,便于满足优惠的约束条件;
4)充值(包括付费金额+优惠金额);
显然,这四个操作必须放在一个事务范围内,并遵循ACID原则中的一致性原则。由于在该操作中,至少对第三方银行支付服务的调用是跨系统跨资源的,因此,事务必须是分布式事务。目前看到的系统问题,显然是在充值时,系统出现了故障,却未能将前面的两个操作回滚,导致执行结果不一致。结果,我悲催了:银行扣了款,优惠没落着,费用没充上。
从直观表现看,我甚至有理由怀疑,对于充值的整个操作,系统是否使用了事务??因为,倘若将这四个不同的操作作为一个服务放在事务中时,应该不会在系统提示正在交易时,银行就扣款成功。不过,考虑到对于这样的业务需求,使用事务基本上已经成为了常识,这个系统的设计者或者实现者应该不会犯如此低级的错误,那我只能善良地认为,该系统没有能够很好或正确地使用分布式事务。
我不知道,该系统是基于什么平台开发,是使用了.NET的DTC,还是Java的JTA。然而基于分布式事务的基本原理来看,这是一种将对多个资源和服务的访问放在同一个事务中的情况。此外,系统调用的第三方银行支付服务必然也是使用了事务的,它会作为整个分布式事务的事务提交树中的子节点,而支付服务的事务则为根事务,是整个事务的总体协调者。由于访问的资源并不相同,即使各个操作放在了自己的事务中,也无法保证满足ACID,因此,这里应该使用两端式提交(two-phase commit)。
重庆移动在提出需求时,必然首先考虑自身的利益,因而系统充值服务包含的四个操作,其执行顺序必然是:首先调用第三方银行支付服务,如果成功,再获取优惠策略,并获得优惠金额;然后充值(从故障表现看,似乎记录优惠信息的操作却在充值操作之前)。考虑简单的情况,假设后三个操作访问的是同一个资源(主要应该是数据库)。那么支付服务事务作为根节点,应该协调银行付费服务事务和充值事务的投票结果,然后再决定是否提交。当所有的参与者表示Prepare,才会提交。而在提交过程中如果出现问题,就必须回滚事务。从故障表现来看,似乎该事务并未采用两段式提交,因为它没有协调投票结果的过程(因为我的手机首先收到了银行的消费信息,优惠记录也保存了,否则不会出现优惠记录ID冲突)。此外,故障出现时,系统一直显示“正在处理……”字样,并在约1分钟左右提示故障。这说明系统可能考虑了Timeout值的设置。如果采用了两段式提交,在各个参与者准备就绪后,如果出现了问题,就应该存在未决(In-Doubt)事务,在规定时间内没有解决,分布式事务会中止整个事务,并回滚。
所以,我在这里有理由相信该系统即使使用了事务,也没有很好地用好事务,尤其是分布式事务。在这里,第三方银行服务是没有任何问题的,它自身的事务必然是完整的,但此时它作为整个事务的参与者,是事务提交树的子节点,却没有被很好地协调。
当故障出现后,系统在提示“操作失败”后的表现是崩溃,而不是回到主界面,这也说明了系统连基本的异常处理也可能没有做好。
这里,事实上还存在一个小插曲。那就是在我询问了营业厅的营业员后,该营业员打开机器,查询了日志文件夹下的交易日志,并没有查询到我的充值记录。后来,她才醒悟,说道自助客户端并不会记录银行卡充值的交易信息。这让我倍感纳闷。虽然现金充值和银行卡充值是两种不同的充值方式,但从抽象层面来看,它们的行为是完全是一致的,充值方式不过是充值策略的两种体现罢了。从设计的角度来看,这是一个典型的Template Method模式。因为对于Charge操作来看,除了支付的实现不同之外,其余操作包括充值、获得优惠策略并计算优惠金额、事务处理、异常处理、资源管理以及日志记录,都应该是完全相同的,为何不能在系统中统一处理呢?显然,它应该是定义在应用服务层的一个统一服务接口(它可以是一个抽象类,也可以作为接口,并另外定义一个抽象类实现它,并实现共同的逻辑),并提供Cash和Card的两个实现类。系统会根据输入实例化不同的实现类。
而对于系统维护而言,日志本身就是必不可少的信息(也许系统内部有日志可供维护人员查询,但日志可以是分级的,以便于不同角色根据需要对日志进行查询)。对于这种涉及到金钱交易的业务,日志记录更显得重要,因为它能够减少很多消费纠纷。这样的设计真的让我很不解。不错,我没有看到系统的实现(我很有兴趣能够看看这个系统的设计与实现,可惜没有这个机会),但根据这些故障表现,确实可以分析得到,这样的系统没有很好地保障系统的健壮性。这个真实案例,很可以值得我们软件从业人员深思。