Skip to content

4.04 保证业务正确性的手段

前面介绍了如何通过日志机制和并发控制机制实现数据访问操作的原子性。有了原子性,数据管理系统就提供了数据完整性和正确性的基本保障:凡是应用提交给系统的数据访问请求,系统都可以完整地、不受干扰地执行,不会有意外发生。但这并不意味着应用就可以高枕无忧了。应用在运行过程中需要保证其业务逻辑的完整性,而一套业务逻辑常常会涉及多次数据访问。虽然数据管理系统可以保证每次数据访问的完整性,却未必能够确保整套业务逻辑的完整性。

4.04.1 两个案例

案例1

以下程序描绘了一个博客网站删除一篇博客文章的业务逻辑:

void deleteBlog(objectID Doc)   /*删除ID为Doc的博客文章*/
{
    DB.delete(Doc);             /*先将文章删除*/
    objectID comments[] = DB.find("Comments", "referenceID", Doc);  /*找到这篇文章的所有评论,Comments是存放评论的数据集*/
    for(int i=0; comments[i] != null; i++)      /*将文章的评论逐个删除*/
        DB.delete(comments[i]);
}

由于该网站允许用户在博客文章后面写评论,在删除文章的同时还需要将它的评论逐条删除。所以,整个过程需要完成删除文章、查找评论、删除评论等多次数据访问操作。在这套业务逻辑的执行过程中,应用程序可能遇到故障而被迫终止。此时,文章的评论可能只被删除了一部分。恢复后的应用程序并不知道曾经发生过故障。因此,那些没有来得及被删除的评论就变成了垃圾数据,永远驻留在系统中了。对数据管理系统而言,数据访问操作的原子性并没有被破坏:故障发生前的操作已经全部完成,其余的操作就像没有发生过一样。但业务逻辑的整体性却被破坏了。

案例2

下面一段程序描绘了在一个电商平台上转账的业务逻辑:

bool transfer(objectID U1, objectID U2, double amount)  /*从账户U1向账户U2转账,金额为amount*/
{
    double currentB= DB.read(U1, "balance");    /*读取U1的账户余额*/
    if(currentB < amount)                       /*如果余额不足,则放弃*/
        return false;
    DB.subtract(U1, "balance", amount);     /*从U1的余额中扣除金额amount*/
    DB.increment(U2, "balance", amount);        /*在U2的余额上累加金额amount*/
    return true;
}

从一个账户向另一个账务转账的过程需要进行三次数据访问。第一次是读取转出账户的余额,如果余额不足就放弃转账。第二次是从转出账户扣除一笔钱。第三次是向转入账户中增加同样一笔钱。假如,当转账进行到一半时,应用程序遇到故障而被迫终止了。此时,转出账户的钱已经被扣除了,但转入账户却没有增加。那么,整个应用就平白无故少了一笔钱。对数据管理系统而言,数据访问操作的原子性仍然没有被破坏:第一次读取账户的操作和第二次扣除余额的操作都已经顺利完成了;第三次增加余额的操作就像没有发生过一样。但业务逻辑却被破坏了。

除了故障的情形,并发也可能对上述业务逻辑造成破坏。假如,账户A的余额有1000元。有两笔转账同时被启动,一笔是从账户A向账户B转500元,另一笔从账户A向账户C转700元。按照上述的转账业务逻辑,账户A的余额是不足够完成这两笔转账的,最多只能让其中一笔成功。但由于两笔转账是同时发生的,它们都读到账户A的余额是1000元,并都认为余额是够的。于是,两笔转账在互不知情的情况下都完成了,最后导致账户A的余额变成了-200元。虽然每次数据访问的原子性都得到了保证,但两次转账业务之间还是出现了干扰。

上述两个例子说明,数据访问操作的原子性并不能够在业务逻辑层面保护数据的正确性。为了获得更高层次的正确性保证,我们要么对业务逻辑进行改进,要么要求数据管理系统提供额外的事务处理功能。

4.04.2 从业务逻辑上保证数据正确性

应用在执行业务逻辑过程中会遇到故障、并发等情形。如果处理得不好,就会引发数据的不一致甚至错误。为此,我们可以要求应用开发者在编写业务逻辑程序时就考虑到故障和并发的情形,事先采取措施避免数据出错。这里介绍一些常用的措施。

标志位的使用

标志位是一种简单有效的应对故障的手段。我们可以使用标志位对上述删除博客文章的业务逻辑做如下改进:

void deleteBlog(objectID Doc)   /*删除ID为Doc的博客文章*/
{
    DB.update(Doc, "status", "deleted");    /*更新文章的status字段,将其设置为“已删除”*/
    objectID comments[] = DB.find("Comments", "referenceID", Doc);  /*找到这篇文章的所有评论,Comments是存放评论的数据集*/
    for(int i=0; comments[i] != null; i++)      /*将文章的评论逐个删除*/
        DB.delete(comments[i]);
    DB.delete(Doc);             /*最后将文章删除*/
}

我们在博客文章的数据项中增加一个status字段,将它作为文章的一个标志位。当文章被创建的时候,这个标志位被设置为“正常”。当删除一篇文章时,我们不急于将它从系统中删除,而是先将它的标志位设置为“已删除”。然后,我们再查找文章的所有评论,并将评论逐个删除。待所有评论都删除完后,我们再将文章从系统中删除。

假设在这套业务逻辑的执行过程中,应用程序遇到故障而被迫终止了。此时,文章的评论可能只被删除了一部分。待应用程序在恢复后,它可以根据标志位发现文章处于“已删除”的状态,从而推断出删除过程并没有完成。那么,应用程序就可以继续完成删除工作,即再次查找文章的评论并将其删除,最后再将文章删除。这样,故障就不再能破坏业务逻辑的整体性了。

这样的标志位能够应对故障,却还不能应对并发。在执行过程中,如果有用户在文章上新添加了一条评论,这条评论可能会被删除过程遗漏。为了让流程不受并发的影响,当文章的标志位被设置成“已删除”后,应用程序就不能允许用户对文章再添加评论了。同理,当一个用户还在添加评论时,应用程序就不能允许文章被删除。为了让删除文章和添加评论两套流程互斥,我们进一步修改流程如下。

void deleteBlog(objectID Doc)   /*删除ID为Doc的博客文章*/
{
    while(true) /*循环等待,直到没有其他流程在更新文章时,将文章的status字段设置为“已删除”*/
    {
        /*testandset是一个原子操作:检查status字段,如果它的取值不是“被更改”,就将它设置为“已删除”,并返回真;否则返回假*/
        if(DB.testandset(Doc, "status", "modified", false, "deleted"))  
            break;
    }
    objectID comments[] = DB.find("Comments", "referenceID", Doc);  /*找到这篇文章的所有评论,Comments是存放评论的数据集*/
    for(int i=0; comments[i] != null; i++)      /*将文章的评论逐个删除*/
        DB.delete(comments[i]);
    DB.delete(Doc);             /*最后将文章删除*/
}

bool addComment(ObjectID Doc, objectID Comment) /*为博客文章Doc添加评论Comment*/
{
    /*testandset是一个原子操作:检查status字段,如果它的取值不是“已删除”,就将它设置为“被更改”,并返回真;否则返回假*/
    if(!DB.testandset(Doc, "status", "deleted", false, "modified"))
        return false;       /*如果文章已经被标识为“已删除”,则放弃添加评论*/
    ... ...
    DB.update(Doc, "status", "normal");     /*添加完评论后,将文章标识为“正常”*/
    return true;
}

上述流程使用了testandset这个特殊的数据访问操作来实现互斥。testandset将两次数据访问合并成了一个原子的数据访问操作。它首先判断数据项是否满足某个条件,只有当条件满足时,它才会对数据项进行更新。如果将判断和更新分成两个不同的数据访问操作进行,则无法实现互斥,因为在并发场景下我们无法确保:在更新发生的时候,前面的判断结果依然成立。由此可见,除了简单的增、删、改、查接口,数据管理系统还需要提供一些功能更复杂的数据访问接口(比如testandset),以帮助上层应用实现更好的并发控制。例如,MongoDB数据库就提供findAndModify这样的数据访问接口,可以实现与testandset类似的功能。

上面的例子利用标志位有效应对了故障和并发的问题。总结起来,标志位的使用规则大致如下:

  1. 在业务流程开始时,在关键数据上将标志位设置为“开始”,表示流程开始了并且还没有完成;
  2. 在业务流程完成时,将标志位设置为“结束”或删除标志位,表示流程已经完整结束;
  3. 当应用程序故障恢复后,一旦发现某个标志位被设置为“开始”,则将相应的流程重新完成一遍;
  4. 当标志位被设置为“开始”时,禁止其他流程对相关数据进行修改,以避免流程间的相互干扰(此处需要用到更复杂的原子操作)。

消息队列的使用

消息队列是另一种应对故障的手段。相对于标志位,它更加灵活,适用范围更广。标志位通常需要被附加到某个关键数据项上,比如上述例子中的博客文章。但这样的关键数据项在某些场景中很难被确定。而消息队列中的消息是一个单独的数据项。我们可以直接使用这个独立的数据项来确定一个业务流程的状态。消息队列的使用规则大致如下:

  1. 在业务流程开始时,在消息队列中添加一则消息,在消息中记录业务流程要完成的工作,将消息的状态设置为“开始”;
  2. 在业务流程完成时,在消息队列中找到对应的消息,将其状态设置为“结束”;
  3. 当应用程序故障恢复后,在消息队列中找到所有尚未“结束”的消息,将其对应的流程重新完成一遍;
  4. 为了避免业务流程间的相互干扰,需保证消息队列中业务流程是按照进入队列的顺序被执行的。

使用消息队列,我们可以对删除博客文章的流程做如下改造:

void deleteBlog(objectID Doc)   /*删除ID为Doc的博客文章*/
{
    /*向数据集messageQueue插入一个新的数据项(消息),其job字段为deleteBlog,status字段为started,blogID字段为文章的ID,最后返回消息的ID*/
    objectID message = DB.insert("messageQueue", "job:deleteBlog, status:started, blogID:"+Doc);    

    objectID comments[] = DB.find("Comments", "referenceID", Doc);  /*找到这篇文章的所有评论,Comments是存放评论的数据集*/
    for(int i=0; comments[i] != null; i++)      /*将文章的评论逐个删除*/
        DB.delete(comments[i]);
    DB.delete(Doc);             /*将文章删除*/
    DB.update(message, "status", "done");       /*将消息的status字段置为done*/
}

这里的消息队列实质上就是一个简单的数据集。每一则消息实质上就是数据集中一个数据项。假设在这套业务逻辑的执行过程中,应用程序遇到故障而被迫终止了。此时,文章的评论可能只被删除了一部分。应用程序恢复后,可以从消息队列中发现这一则没有结束的消息,从而可以推断这篇文章的的删除过程意外终止了。于是,应用程序可以在执行一遍删除工作,最后将消息状态设置为“结束”。

规则中的第4条要求应用程序按照顺序执行消息队列中的业务流程。上述流程实际上还无法保证这一点。为了实现串行执行,通常需要将发起流程的程序和执行流程的程序分开:一方面,让所有用户都可以发起流程,即并发地向消息队列里插入消息;另一方面,只使用一个单独的程序从消息队列里获取消息,并依次执行流程。这样可能会降低应用程序的并行能力,影响性能。为了实现更高的并发度,应用可以使用多个消息队列,将互不冲突的业务流程分散到不同的队列中,从而提高并行能力。

标志位的方法和消息队列的方法都要求应用程序在故障恢复时检查是否有业务流程被中断了,如果有,就重新执行一遍业务流程。但是,应用程序并不清楚这个被中断的业务流程的实际进度。它可能刚开始,也可能快要结束了。但无论进度如何,整个流程都会被重新执行一遍。要保证这个恢复逻辑的正确性,业务流程就必须满足幂等性,即无论这个流程被执行到了什么程度,只要从头再执行一遍,它都会正确地把数据更新到同一个状态。上面的文章删除流程显然满足幂等性,因为无论删除流程进行到什么程度,只要再删除一遍,它都是不多不少地将文章和它的评论全部删掉。

在现实世界中,很多业务流程都满足幂等性。比如,在博客网站上更改用户名的流程如下:首先修改用户数据中的名字,再找到这个用户发表的所有博客文章,将每篇文章的作者名字也改掉。这个流程显然也满足幂等性,因为无论这个过程进行到了什么程度,只要我从头到尾再改一次,就可以不多不少地将所有名字都改掉。因此,我们也可以使用标志位或者消息队列来保护这个流程的完整性。

但前面提到的电商平台的转账流程是不满足幂等性的。假设转账进行到一半时应用程序因故障被终止了。此时,转出账户的钱已经被扣除了,但转入账户却没有增加。恢复后,如果应用程序简单将这个流程重新执行一边,那么转出账户的钱还会再被扣除一笔。整个业务仍然会缺失一笔钱。面对这样的业务流程,我们不能简单使用标志位和消息队列来保护它的完整性。

可回溯的消息队列

当业务流程不满足幂等性时,我们就需要对上述的消息队列进行改进,使用可回溯的消息队列来解决业务完整性的问题。可回溯消息队列的使用规则如下:

  1. 在业务流程开始时,在消息队列中添加一则消息,在消息中记录业务流程要完成的工作,并将消息的状态设置为“预备开始”;随后,确定业务流程需要修改哪些数据,并将这些数据的原始值记录到消息中,同时将消息的状态设置为“开始”
  2. 在业务流程完成时,在消息队列中找到对应的消息,将其状态设置为“结束”;
  3. 当应用程序故障恢复后,在消息队列中找到所有尚未“结束”的消息;如果消息的状态为“预备开始”,回到规则1重新执行一遍流程;如果消息的状态为“开始”,先将数据恢复到流程开始时的原始值,再从规则1之后重新执行一遍流程
  4. 为了避免业务流程间的相互干扰,需保证消息队列中业务流程是按照进入队列的顺序被执行的。

使用消息队列,我们可以对电商平台的转账流程做如下改造:

bool transfer(objectID U1, objectID U2, double amount)  /*从账户U1向账户U2转账,金额为amount*/
{
    /*向数据集messageQueue插入一个新的数据项(消息),其job字段为transfer,status字段为pre-start,account1字段为转出账户,account2字段为转入账户,amount字段为金额,返回消息的ID*/
    objectID message = DB.insert("messageQueue", "job:tranfer, status:pre-start, account1:"+U1+", account2:"+U2+", amount:"+amount);    

    double B1 = DB.read(U1, "balance");     /*读取U1的账户余额*/
    double B2 = DB.read(U1, "balance");     /*读取U2的账户余额*/
    DB.add(message, "balance1:"+B1+", balance2:"+B2);   /*将账户余额的初始值记录在消息中*/
    DB.update(message, "status", "started");        /*将消息的status字段为started*/
    if(currentB < amount)                           /*如果余额不足,则放弃*/
    {
        DB.update(message, "status", "done");       /*将消息的status字段置为done*/
        return true;
    }
    DB.subtract(U1, "balance", amount);         /*从U1的余额中扣除金额amount*/
    DB.increment(U2, "balance", amount);        /*在U2的余额上累加金额amount*/
    DB.update(message, "status", "done");       /*将消息的status字段置为done*/
    return true;
}

void recover_transfer(objectID message)   /*对一个转账流程进行恢复;假设转账的信息被记录在消息message里面*/
{
    /*从消息里读取转账信息*/
    objectID U1 = DB.read(message, "account1");
    objectID U2 = DB.read(message, "account2");
    double amount = DB.read(message, "amount");

    /*如果流程已经被执行了,即消息的状态为“开始”,那么将账户余额恢复到流程开始之前的值*/
    if(DB.read(message, "status") == "started")  
    {
        DB.set(U1, "balance", DB.read(message, "balance1"));
        DB.set(U2, "balance", DB.read(message, "balance2"));
    }

    /*从头开始执行流程*/
    double B1 = DB.read(U1, "balance");     /*读取U1的账户余额*/
    double B2 = DB.read(U1, "balance");     /*读取U2的账户余额*/
    DB.add(message, "balance1:"+B1+", balance2:"+B2);   /*将账户余额的初始值记录在消息中*/
    DB.update(message, "status", "started");        /*将消息的status字段为started*/
    if(currentB < amount)                           /*如果余额不足,则放弃*/
    {
        DB.update(message, "status", "done");       /*将消息的status字段置为done*/
        return;
    }
    DB.subtract(U1, "balance", amount);         /*从U1的余额中扣除金额amount*/
    DB.increment(U2, "balance", amount);        /*在U2的余额上累加金额amount*/
    DB.update(message, "status", "done");       /*将消息的status字段置为done*/   
}

转账流程在执行过程中会首先将两个账户的原始余额先记录在消息中。这样,即使流程被中断,应用程序可以根据消息里的记录先将账户余额恢复到原始值,再重新执行流程,从而保证恢复过程的正确性。这样的消息队列称为可回溯的消息队列。通过其中的消息,我们可以对业务流程进行回退和重放,就像我们可以通过redo/undo日志对数据访问操作进行回退和重放一样。

第4条规则要求应用程序串行执行消息队列中的业务流程。这也就消除了并发转账流程之间的相互干扰,避免了案例2中余额为负的情况。如上所述,串行执行业务流程可能导致应用程序的性能低下。为了更好的性能,应用需要使用多个消息队列,并将业务流程分散到不同队列中。

总之,消息队列是一种常用的保护业务逻辑正确性的方法,被现代互联网应用广泛使用。它在实际场景中还有很多更复杂的使用方法。由于篇幅所限,本书不再做进一步展开。

4.04.3 事务处理功能

标志位和消息队列可以帮助应用程序保护业务流程的完整性,并能在一定程度上避免并发流程之间的干扰。然而,它们都要求开发人员手动使用标志位和消息队列对原有业务流程进行改造。通过上面的例子,我们看到这样的改造并非易事。业务流程的复杂度明确增加了,需要经过严密的逻辑推理才能确保业务流程的正确性。这无疑对开发人员提出了更高的要求,也在无形中增加了应用开发的代价。因此,我们希望数据管理系统能够提供更强大的能力,让应用程序能够用更简单的方式保证业务逻辑在故障和并发场景下的正确性。

通过前几节的内容,我们看到数据管理系统利用日志和并发控制机制确保了数据访问操作的原子性。那么,我们是否可以将应用层的业务流程打包成一个复杂的数据访问操作,然后使用同样的日志和并发控制机制获得整个业务流程的原子性呢?一旦一个业务流程满足原子性,我们就不再担心它受到故障和并发的干扰了。事实证明,这确实也是一种行之有效的方案。不少数据管理系统都提供事务处理功能,就是对这个想法的一种实现。

提供事务处理功能的数据管理系统通常提供三个事务功能调用接口,分别为BeginTransaction(事务启动)Commit(事务提交)Abort(事务撤销)。一个业务流程在开始时首先调用BeginTransaction,告诉数据管理系统一个新的事务(transaction)启动了。随后,这个业务流程开始执行,其间对数据的所有访问操作都将被系统视为这个事务的一部分。当业务流程执行完毕后,它应该调用Commit,告诉数据管理系统这个事务顺利结束了。之后的数据访问操作就不再作为这个事务的一部分了。数据管理系统会将事务执行期间的所有数据访问操作看作一个整体,就像对待一个单独的数据访问操作一样,为它记录日志,并对它实施并发控制,以确保整个事务的原子性。(换言之,对日志机制而言,在事务开始的时候记录开始日志,而要等到事务提交才能记录结束日志(对事务称为提交日志);对并发控制机制而言,事务期间对所有数据加的锁需要等到事务提交时才能释放。)

在事务启动后,业务流程还可以调用Abort将事务撤销掉。Abort也意味这一个事务的结束,其后的数据访问操作就不再作为这个事务的一部分了。调用Abort后,数据管理系统会将事务期间的所有数据更新操作都撤销掉。(它通常可以使用undo日志做到这一点。)待所有操作都撤销后,它再释放掉事务所持的锁。Abort完成后,整个事务就像从来没有被启动过一样。由于应用的业务逻辑常常需要处理各种例外情况,允许业务流程主动撤销事务是必要的。

对于案例1和案例2的两套业务流程,我们可以使用事务确保其完整性和正确性。通过事务改造后的程序如下:

void deleteBlog(objectID Doc)   /*删除ID为Doc的博客文章*/
{
    DB.beginTransaction();      /*启动事务*/
    DB.delete(Doc);             /*先将文章删除*/
    objectID comments[] = DB.find("Comments", "referenceID", Doc);  /*找到这篇文章的所有评论,Comments是存放评论的数据集*/
    for(int i=0; comments[i] != null; i++)      /*将文章的评论逐个删除*/
        DB.delete(comments[i]);
    DB.commit();                /*提交事务*/
}
bool transfer(objectID U1, objectID U2, double amount)  /*从账户U1向账户U2转账,金额为amount*/
{
    DB.beginTransaction();      /*启动事务*/
    double currentB= DB.read(U1, "balance");    /*读取U1的账户余额*/
    if(currentB < amount)                       /*如果余额不足,则放弃*/
    {
        DB.abort();             /*撤销事务*/
        return false;
    }
    DB.subtract(U1, "balance", amount);     /*从U1的余额中扣除金额amount*/
    DB.increment(U2, "balance", amount);        /*在U2的余额上累加金额amount*/
    DB.commit();                /*提交事务*/
    return true;
}

利用事务功能,我们将删除文章或转账的业务流程整体包装到单独的事务中,由数据管理系统来应对故障和并发的情况。

我们可以看到,相比标志位和消息队列的使用方式,事务的使用无需对业务逻辑做过多改动,因此是一种更便利的措施。然而,并非所有的数据管理系统都提供事务处理功能。一方面,事务功能实现起来比较复杂,会给系统带来额外的开发成本和性能代价。另一方面,事务功能也可能会被应用开发人员滥用,因而未必有利于构建高质量的应用程序。

4.04.4 合理地使用事务

我们在上一节中提到,通用并发控制机制(比如两阶段锁机制)的效率通常不会很高。为了获得更好的性能,数据管理系统常常会用到一些定制化的并发控制机制,比如在前文中提到的B+树的并发控制机制。一个事务的数据访问顺序和逻辑是由应用程序决定的,对数据管理系统而言是不可预知的。因此,数据管理系统无法为事务提供定制化的并发控制机制,而只能使用通用并发控制机制保护事务的原子性。这样一来,大部分数据管理系统的并发事务处理能力都是有限的。这就要求应用开发人员合理使用事务,避免并发事务处理成为整个应用的性能瓶颈。

通过总结应用开发经验,我们发现最核心的事务使用原则只有一条,即避免使用长事务。换言之,事务越短,越有利于应用的性能提升。

我们用订电影票的业务流程来阐明这个原则。假设一个互联网应用提供的电影票订票流程如下:用户首先选择心仪的电影场次;应用将该场次的空余座位展示给用户;用户选择自己喜欢的座位;用户跟应用确定座位并完成订票。这个流程可以通过以下程序描述。

bool tickeBooking(objectID movie, objectID user)        /*为用户user提供电影场次movie订票服务*/
{
    DB.beginTransaction();      /*启动事务*/
    objectID availableSeats[] = getAvailabeSeats(movie);    /*访问数据管理系统,获得该场次的空闲座位*/
    objectID selectedSeats[] = waitforUserChoice(availableSeats);  /*将空闲座位展示给用户,并等待用户的选择结果*/
    bookSeats(selectedSeats[], user);       /*在数据管理系统中记录用户订单,并将座位分配给用户*/
    DB.commit();                /*提交事务*/
    return true;
}

当多个用户在同一个电影场次上订票时,可能出现由并发引发的错误。比如,两个用户可能在互不知情的情况下选择了同一个座位,最后导致一个座位被分配给了两个人。为了规避并发带来的问题,可以使用事务功能。上述流程一开始就启动了事务,并在订好座位后才提交事务。这样确实可以确保不同用户的订票流程互不影响。但在实际执行过程中,这样的事务设计却会遇到明显的性能问题。

这个设计的主要问题是用户交互出现在了事务执行过程中。用户选择座位所花费的时间通常是不确定的。即便用户立即完成座位选择,仍然会有一定的耗时。这个耗时也会远远多于执行程序的耗时。因此,这个用户交互环节将这个事务变成了一个超长事务。对于一个热门的电影场次,可能会有多个用户同时进入到座位选择过程中。如果系统使用两阶段锁实现并发控制,这几个订票流程都会在空闲座位的数据上加上共享锁,并完成了空闲座位的读取。当某个用户选好座位后,他(她)的订票流程会试图将座位分配给该用户。此时,系统需要先在被选择的空闲座位加上排他锁。但由于其他流程还持有这个空闲座位的共享锁,这个排他锁将无法被加上。这样,该用户会发现应用程序停顿在那里,不响应自己的订位请求,从而产生极差的用户体验。同理,其他用户在选择完座位后也无法获得座位的排他锁,最后还会引发死锁。任何一款商业应用都难以容忍出现如此严重的阻塞问题。

为了让这个订票流程可用,我们需要将用户交互从事务执行过程中移走,并且让事务尽可能短小。一种可行的方案如下:

bool tickeBooking(objectID movie, objectID user)        /*为用户user提供电影场次movie订票服务*/
{
    objectID availableSeats[] = getAvailabeSeats(movie);    /*访问数据管理系统,获得该场次的空闲座位*/
    objectID selectedSeats[] = waitforUserChoice(availableSeats);  /*将空闲座位展示给用户,并等待用户的选择结果*/
    DB.beginTransaction();      /*启动事务*/
    if(checkSeats(selectedSeats))       /*检查用户选择的座位是否仍然空闲*/
        bookSeats(selectedSeats[], user);       /*如果用户选择的座位仍然空闲,在数据管理系统中记录用户订单,并将座位分配给用户*/
    else
    {
        DB.abort();             /*如果用户选择的座位已经不可用了,撤销事务*/
        return false;           /*宣告订位失败,之后可通知用户另选座位*/
    }
    DB.commit();                /*提交事务*/
    return true;
}

经过改进的订票流程会先让用户选择空闲座位。待用户选好座位后,再启动事务。由于在事务启动前任何座位都可能被分配给其他用户,所以事务开始后会先检查用户选择的座位是否仍然是空闲的。如果座位仍然空闲,则将座位分配给用户,并提交事务。否则,撤销事务,并通知用户另选座位。

在这个设计中,事务的执行过程不再有用户交互环节。这样就不会引起长时间的阻塞以及严重的用户体验问题。在这个事务逻辑下,死锁也不会发生。对一个热门的电影场次,用户可能遇到自己选择的座位被其他用户抢先占得,甚至所有座位很快被一抢而空。但这正是一个热门电影场次在情理之中的表现,不会给用户带来太意外的感受。

可见,虽然事务功能是方便易用的,但应用开发人员也不能盲目地将整个业务流程全部塞进事务中。在使用事务时,需要全方位考虑性能隐患。而最核心的原则就是让事务执行过程尽可能短小精干。

练习题

1. 以下哪个操作不是幂等(idempotent)的?

  1. 向一个集合里插入一个元素
  2. 从一个集合里删除一个元素
  3. 对一个数据进行赋值:X=10
  4. 对一个数据进行自增:X=X+1

2. 思考题:在一个电影评论网站上,用户可以给电影点赞。网站记录了每个电影获得的点赞数,以及每一个导演和演员获得的点赞数。一位用户给一个电影点赞会让该电影的点赞数增加1,同时还会让电影的导演和演员的点赞数都增加1。假设数据管理系统不提供事务处理功能,只保证数据访问操作的原子性。请想办法保证点赞功能程序的正确性。

3. 思考题:当我们使用Abort撤销一个事务时,是否需要记录相关的日志以确保事务的原子性?如果Abort过程中遇到故障,应该怎么办?

4. 在一个同城快递应用的数据库中,有两张表。其中一张为订单表,记录了每一条订单的订单号、送货人信息、收货人信息、货物的信息、发货的起点地址和终点地址、以及送货的状态(包括“新提交订单”、“已接单”、“已取货”、“已送达”四种状态)。另一张为配送表,记录了每个配送员的信息、配送员当前所在的区域、以及配送员正在执行的订单号(当订单号为空时,表示配送员还未接单)。该应用只允许每个配送员同一时间执行一个订单。该应用对外提供多个功能,包括:

(1)用户下单:用户将订单提交给系统。\ (2)配送员接单:配送员浏览自己所在区域的订单,并且选择一个订单接单。\ (3)取货:配送员到发货起点取货,并更新订单信息。\ (4)完成配送:配送员将货物送达终点,并更新自己和订单的信息。

每次功能调用可能会访问一张或多张表。应用在实际运行时,四种功能会并发被使用。此外,运行应用的服务器随时可能遇到故障,我们需要保证:在服务器宕机重启后,用户的订单能够被顺利完成。为此,我们需要使用事务来保证应用的正常运行。例如,用户下单的过程可以按照如下的伪代码执行:

功能1用户下单
Function
    同用户进行交互获得订单的所有信息;
    Begin Transaction;
    访问订单表新插入一项数据标志订单状态为新提交订单;
    Commit;
End
请给出功能(2)、(3)、(4)的事务处理伪代码。

上一页<< | >>下一页