Google NDB数据存储区为用户检查帐户/电子钱包。如何计算余额

时间:2018-02-12 00:49:53

标签: python google-app-engine google-cloud-datastore

基本上,使用Google Cloud Datastore计算滚动余额以确定何时需要补充用户钱包的最佳方法是什么?

付款&交易

我维护一个支付平台,我们的用户可以从第三方代理处支付各种物品。不幸的是,我所在行业的性质是,这些付款活动并非实时发送给我们,它们捆绑在一起,并在几个星期后发送给我们。

这些是影响用户钱包余额的主要对象:

class Transaction(ndb.model):
    user = ndb.KeyProperty(User, required=True)
    amount = ndb.FloatProperty(required=True)
    # ... other fields

class Payment(ndb.model):
    user = ndb.KeyProperty(User, required=True)
    amount = ndb.FloatProperty(required=True)
    # ... other fields

    @classmethod
    def charge(cls, user, amount):
        # ... make a call to braintree/stripe & save result if successful

(未显示退款,'商店信用',调整等)

电子钱包

然而,大部分交易金额是< $ 1由于我们必须将信用卡处理的成本转嫁给用户,因此我们的用户会与我们保持一致,以尽量减少这些费用。

他们可以加载10到200美元,交易从该余额中扣除,当他们的余额很低(低于2美元)时,我们会向他们的卡充值以补充他们的帐户。

这就是我设想钱包活动模式的工作方式

class WalletActivity(ndb.Model):
    user = ndb.KeyProperty(User, required=True)
    post_date = ndb.DateTimeProperty(required=True)
    balance_increment = ndb.FloatProperty(required=True)
    balance_result = ndb.FloatProperty(required=True)
    # the key to the Transaction or Payment object that this is for
    object_key = ndb.KeyProperty(required=True)

    @classmethod
    def create(cls, obj, previous_balance):
        return WalletActivity(
            user_key=obj.user,
            post_date=datetime.datetime.now(),
            balance_increment=obj.amount,
            balance_result=previous_balance+obj.amount,
            object_key=obj.key)

    @classmethod
    def fetch_last_wallet_activity(cls, user_key):
        return cls.query(cls.user == user_key).order(-cls.post_date).get()

计算余额

为了确定平衡,光谱的两端似乎是:

  • 即时计算,计算帐户的整个钱包历史记录
  • 存储预先计算的值(WalletActivity.fetch_last_wallet_activity().balance_result

这里的正确答案听起来像2的组合。 在每个帐户的每一天结束时存储某种BalanceUpdate / WalletDaySummary对象。 然后,您只需总结今天的活动并将其添加到昨天的BalanceUpdate中。 https://stackoverflow.com/a/4376221/4458510

class BalanceUpdate(ndb.model):
    user = ndb.KeyProperty(User)
    cut_off_date = ndb.DateTimeProperty()
    balance = ndb.IntegerProperty()

    @classmethod
    def current_balance(cls, user_key):
        last_balance_update = cls.query(cls.user == user_key).order(
            -cls.cut_off_date).get()
        recent_wallet_activity = WalletActivity.query(cls.user == user_key, 
            cls.post_date > last_balance_update.cut_off_date).fetch()
        return (last_balance_update.balance + 
            sum([i.balance_increment for i in recent_wallet_activity]))

但是,这可能不适用于在一天内产生大量交易的公司帐户。 最好使用最新balance_result

WalletActivity

如何处理交易

选项1

要处理一批交易,我们

  1. 获取用户余额
  2. 如果现有余额不足,请补充帐户
  3. 将交易添加到其钱包
  4. 代码:

    def _process_transactions(user, transactions, last_wallet_activity):
        transactions_amount = sum([i.amount for i in transactions])
        # 2. Replenish their account if the existing balance is low
        if last_wallet_activity.balance_result - transactions_amount < user.wallet_bottom_threshold:
            payment = Payment.charge(
                user=user,
                amount=user.wallet_replenish_amount + transactions_amount)
            payment.put()
            last_wallet_activity = WalletActivity.create(
                obj=payment,
                previous_balance=last_wallet_activity.balance_result)
            last_wallet_activity.put()
        # 3. Add the transactions to their wallet
        new_objects = []
        for transaction in transactions:
            last_wallet_activity = WalletActivity.create(
                obj=transaction,
                previous_balance=last_wallet_activity.balance_result)
            new_objects.append(last_wallet_activity)
        ndb.put_multi(new_objects)
        return new_objects
    
    def process_transactions_1(user, transactions):
        # 1. Get the user's balance from the last WalletActivity
        last_wallet_activity = WalletActivity.fetch_last_wallet_activity(user_key=user.key)
        return _process_transactions(user, transactions, last_wallet_activity)
    

    WalletActivity.fetch_last_wallet_activity().balance_result和{。}的问题 BalanceUpdate.current_balance()是数据存储区查询最终是一致的。

    我想过使用实体组&amp;祖先查询,但听起来你会遇到争用错误:

    选项2 - 按键获取最后一个WalletActivity

    我们可以跟踪上一个WalletActivity的密钥,因为按密钥提取非常一致:

    class LastWalletActivity(ndb.Model):
        last_wallet_activity = ndb.KeyProperty(WalletActivity, required=True)
    
        @classmethod
        def get_for_user(cls, user_key):
            # LastWalletActivity has the same key as the user it is for
            return ndb.Key(cls, user_key.id()).get(use_cache=False, use_memcache=False)
    
    def process_transactions_2(user, transactions):
        # 1. Get the user's balance from the last WalletActivity
        last_wallet_activity = LastWalletActivity.get_for_user(user_key=user.key)
        new_objects = _process_transactions(user, transactions, last_wallet_activity.last_wallet_activity)
    
        # update LastWalletActivity
        last_wallet_activity.last_wallet_activity = new_objects[-1].key
        last_wallet_activity.put()
        return new_objects
    

    或者,我可以将last_wallet_activity存储在User对象上,但我不想担心竞争条件 用户更新其电子邮件并清除last_wallet_activity

    的新值

    选项3 - 付款锁

    但是竞争条件如何,其中2个作业试图同时处理同一用户的交易。 我们可以添加另一个对象来锁定&#39;一个帐户。

    class UserPaymentLock(ndb.Model):
        lock_time = ndb.DateTimeProperty(auto_now_add=True)
    
        @classmethod
        @ndb.transactional()
        def lock_user(cls, user_key):
            # UserPaymentLock has the same key as the user it is for
            key = ndb.Key(cls, user_key.id())
            lock = key.get(use_cache=False, use_memcache=False)
            if lock:
                # If the lock is older than a minute, still return False, but delete it
                # There are situations where the instance can crash and a user may never get unlocked
                if datetime.datetime.now() - lock.lock_time > datetime.timedelta(seconds=60):
                    lock.key.delete()
                return False
            key.put()
            return True
    
        @classmethod
        def unlock_user(cls, user_key):
            ndb.Key(cls, user_key.id()).delete()
    
    def process_transactions_3(user, transactions):
        # Attempt to lock the account, abort & try again if already locked 
        if not UserPaymentLock.lock_user(user_key=user.key):
            raise Exception("Unable to acquire payment lock")
    
        # 1. Get the user's balance from the last WalletActivity
        last_wallet_activity = LastWalletActivity.get_for_user(user_key=user.key)
        new_objects = _process_transactions(user, transactions, last_wallet_activity.last_wallet_activity)
    
        # update LastWalletActivity
        last_wallet_activity.last_wallet_activity = new_objects[-1].key
        last_wallet_activity.put()
    
        # unlock the account
        UserPaymentLock.unlock_user(user_key=user.key)
        return new_objects
    

    我想在事务中尝试完成这一切,但我需要阻止将2 http发送到braintree / stripe。

    我倾向于选项3,但随着我介绍的每个新模型,系统越来越脆弱。

1 个答案:

答案 0 :(得分:2)

有关争用的设计注意事项

一般情况下,我完全同意Dan's answer与您的original question,但在您的特定用例中,与大型实体组合作可能是合理的。

每秒对同一实体组的写操作数超过1次时可能会发生争用错误,例如:一个特定的钱包。此限制也适用于已从数据存储区事务中的数据存储区读取的实体,但未明确写回(我的理解是它们为written together with the modified entities for serializability)。

虽然,1秒规则不是强制限制,根据我的经验,云数据存储通常可以处理略高于此限制的短突发,但无法保证,并且通常建议和最佳做法是避免大型实体组,特别是在写操作不是来自同一用户的情况下。相反,将用户发布的所有评论存储在同一实体组(author = parent)中可能是安全的,因为用户每秒可以发布多个评论是非常不可能的,甚至可能是不可取的。另一个例子可能是时间敏感且不面向用户的后台任务,其中实体组的写操作是按实体组编排的,或者至少可以在发生争用时显着退回写操作。 / p>

在以非常高的速率添加新实体的情况下,争用错误也可能由单调增加的键/ ID或索引属性和复合索引引起,其中索引值彼此太靠近(例如时间戳)。建议让Datastore自动创建新实体的ID(例如用户ID),因为Datastore会将密钥扩展得足够远。并且要么避免索引可以发生单调递增值的属性,要么使用散列为值添加前缀。

Cloud Datastore文章Best Practices包含一个关于Designing for scale的部分,该部分提供了非常有用的建议。

也就是说,可以更容易地设计一个应用程序,以便在写入限制内安全地工作,并依赖于数据存储(或其他数据库)的事务性和强一致性支持,而不是编写试图模仿的应用程序逻辑交易方面。竞争条件,死锁等等可能出错,使系统更加脆弱,容易出错。

(A)小型权利组中的钱包活动

在原始问题中,您提到了公司帐户和付款,这表明了一些实时付款解决方案。场景:该公司中的数千名用户可以为单个帐户提交新的交易,但很多人可能同时这样做。如果每个事务都是同一个实体组(公司帐户),这很容易导致争用错误。如果在写操作中实现重试,那么这将导致延迟延迟,直到用户获得对其事务请求的响应。但即使重试,写操作也可能经常失败,用户经常会遇到服务器错误。这将带来糟糕的用户体验。

我倾向于选项(2),但有Wallet类。您表示担心存储last_wallet_activity的位置。您可以拥有自己的Wallet种类,它始终与User具有相同的ID。在这种情况下,您可以拥有两个单独的实体组,而不必关心用户触发的User对象的中间更改。我也会使用数据存储区事务。它允许同一事务中最多25个不同的实体组,即一批中可能有23个事件。根据您当前的设计,这也是您每个钱包的最大写入率。不确定这是您的应用程序的可接受限制。

但是,如果某个钱包的交易真的很多(通过频繁更新last_wallet_activity),您可能再次面临争用的风险。为了实现每个钱包更高的写入速率并避免这种争用,您还可以将选项(1)与某种sharding结合使用。

选项(3)尝试实现事务方面(见前文)。选项(1)确实遭受了查询最终只是一致的事实。

(B)单个大型实体组中的钱包活动

但是,这里的问题提到所有这些钱包活动都是在后台处理(而不是由用户请求处理),并且事件不是实时处理的,而是延迟数小时或数周。这将允许通过后台任务批量处理它们。假设您的应用将保持在另一个Cloud Datastore limits内,例如对于10 MiB的交易的最大大小,您的应用可以将每个公司钱包(实体组)的最多500个钱包活动批量转换为每秒一次写入操作。这相当于每个公司帐户每小时1.8米钱包活动。即使有重试和停电,这对即使是最大的企业账户也足够了吗?如果是,如果您的产品永远不会改变为面向用户的实时钱包活动,我就不明白为什么您不应该将钱包活动放入每个钱包的实体组中。另一种方法当然是每个公司帐户只有多个钱包。

在这种情况下,您的选项(1)应该有效,因为祖先查询(钱包是WalletActivity查询中的祖先)非常一致。

使用ndb.put_multi()

我已经看到您在同一个请求中使用多个put()调用,您可以在某些toPut列表中完美地收集实体,然后将所有实体一起编写。通过这样做,您可以保存实例运行时,还可以减少对同一实体组的写操作数。

如何避免重复付款请求

关于对Braintree或Stripe的付款请求:

  1. 在将钱包活动添加到将写入数据存储区的下一批次之前,请检查钱包的余额是否足够。
  2. 如果余额不足,请停止添加更多活动。
  3. 在您将批处理编写到数据存储区的数据存储区事务中,添加transactional push task(如果事务失败,则不会创建)。我相信,GAE / NDB每个HTTP请求最多可接受5个事务性任务。
  4. 此任务负责将请求发送至Braintree / Stripe,并更新钱包的余额。这也应该在数据存储区事务中,事务任务继续处理之前离开的事件。
  5. 您需要处理Braintree / Stripe拒绝付款请求的案例。

    我也不知道你的活动是如何到达应用程序的,因此我不确定如何协调每个钱包的批处理任务。上面的模式表明,对于每个钱包,只有一个任务在运行(即,不是同一个钱包并行的多个任务/批次)。但是,有不同的方法可以执行此操作,具体取决于事件在您的应用中的到达方式。