基于Event Sourcing和DSL的积分规则引擎设计实现案例
tvvi3739
8年前
<p>架构设计模式(Architecture Patterns),是“从特殊到普遍”的、基于各种实际问题的解决方案而总结归纳出来的架构设计最佳实践,是一种对典型的、局部的架构逻辑的高度抽象思维;在合理的场景下恰当使用它们,避免“重新发明车轮”,对技术解决方案有指导性作用,往往事半功倍。广发证券IT研发团队作为架构设计模式的坚定践行者,在各类证券业务中经常运用。Event Sourcing就是这么一个比较常用而重要的架构模式。本文介绍的虽然是金融业场景,但是“积分系统”相信对其他行业的开发者也不会陌生。技术团队尝试用Event Sourcing架构模式和基于Go构建的DSL“简单而优雅”的解决一个问题。</p> <p>在电商行业,积分几乎已经成为了一个标配。 京东、淘宝都有自己的积分体系。 用户通过购物或者完成指定任务来获得积分。累积的积分可以给用户带来利益,比如增加用户等级,换取礼品或者在购物时抵扣现金。</p> <p>在广发证券的金融电商运营平台中,积分同样是一个不可或缺的基础服务, <strong>很多应用都有和积分账户交互的场景</strong> 。积分的用途也比较广泛,除了用在面向客户的服务中增加客户粘性和忠诚度外,积分也被用来支持内部的“游戏化”(gamification)运营,让数字化经营成为可能。 例如:公司的投资顾问可以通过编辑高质量的理财知识条目和回答客户问题获得积分,最终被换算回个人绩效收入。</p> <p>一个场景,是客户提了一个关于证券的问题, 如果投资顾问回答了这一问题,并且答案被其他用户收藏,就可以获得500积分作为奖励。 实践表明,积分的使用大大提升了投资顾问回答问题的积极性,提高了运营的效率。这其实是精细化运营、数字化经营的一个非常重要的基础设施。这个积分体系的存在,甚至改变、颠覆了传统企业对员工进行分派任务、管理、激励、计算个人绩效的机制。</p> <p>从技术的角度, <strong>怎样实现一个积分系统满足各种应用程序的需求呢?</strong> 虽然使用积分的场景不同, 有的面向客户,有的面向公司内部的理财顾问,进行抽象后的积分系统可以是相同的。 和银行账户类似, 一个用户的积分账户可以看作由账户类型, 表示余额的数字和一系列引起积分变化的流水帐组成。 根据这些共性, 我们把积分实现为一个独立的服务,统一存储管理积分数据。 在和应用程序交互的方式上,最初的想法是积分系统为应用程序提供增加/扣除积分的接口, 由应用程序决定增加/扣除积分的数量。</p> <p>我们很快发现这种架构在应用程序中嵌入了积分规则逻辑,当积分规则改变时,应用程序需要随之改变。 比如,如果运营人员把上面例子中的500积分改变为1000分后,开发人员就需要升级应用程序。 在使用积分的应用程序数量多,运营需求变化快的情况下,这种应用程序和积分系统紧密耦合的架构增加了系统维护成本。</p> <h2><strong>典型的“事件驱动”场景</strong></h2> <p>无论是面向消费者客户的电商平台、航空公司顾客的飞行里数服务(mileage program)还是面向内部员工的“游戏化运营”平台,很显然,在技术层面都是一个典型的“事件驱动”场景 – 用户通常通过在各种各样的业务系统进行了一些活动,这些活动被记录到一个积分系统中“映射”成一定的积分。所以,积分系统设施的“使用者”,往往是其他的一些各式其色的、事前甚至无法预估的应用程序。</p> <p>技术架构的设计原则是这样: 由不可预知的应用程序自己负责判断其用户所进行的活动有无“价值”,对于有价值的活动则以发起事件的方式异步通知积分系统 ,积分系统则负责实时收集事件并基于各种可能由经营管理者随时修订、配置、改变的积分规则对事件所包含的用户活动进行“簿记”(book-keeping)。</p> <p>我们采用了基于 <strong>消息总线的架构设计</strong> , 应用程序和积分系统之间通过异步的消息总线关联。应用程序不包含任何积分规则,只负责向消息总线发布事件。 积分系统被实现为一个独立的服务,包含了所有的积分账户数据和积分规则。 积分系统向消息总线订阅事件, 然后根据设置的积分规则处理事件, 记录积分。这种架构使应用程序和积分系统呈松耦合关系,提升了系统的可维护性。 系统架构如下图所示:</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/81121a8f598ded9f71b10d8c18318d47.jpg"></p> <p>举一个例子说明记录积分的过程:某投资顾问在广发证券知识库的应用程序中回答了一个问题,并且该问题被一个客户收藏。 知识库应用程序向消息总线发布一个答案被收藏的事件。 积分系统在监听到这一事件后,根据 <strong>事先配置的积分规则</strong> ,向投资顾问的积分账户增加积分数量,记录积分流水。</p> <p>积分系统监听的事件并不一定由应用程序直接产生。对于复杂的积分规则,可能由其他服务处理应用程序的事件流后,产生新的事件流,再由积分系统处理。例如, <strong>需要对7月份连续3天登录的用户奖励50分</strong> 。应用程序没有保存历史登录数据,只产生简单的登录事件。 大数据平台(对于积分系统而言是一个应用程序)可以根据保存的历史数据产生包含连续登录天数的事件, 发布到消息总线上后由积分系统订阅处理。 这体现了基于消息总线架构的优点,能把积分处理逻辑从应用程序中完全剥离出来,同时具有扩展性。</p> <p>积分类似虚拟货币,可最终换算成员工绩效或者消费者的某些形式的奖励,所以不能多记,也不能少记。为了达到这一目标, 技术层面上需要解决消息被处理一次且仅被处理一次的问题。 我们的消息总线采用的是分布式消息系统Kafka, 它具有比较好的容错性和扩展性, 但不直接提供这样的支持,需要在应用程序层面处理。 应用程序向kafka发送消息时可能因为网络的原因发送失败。</p> <p>为了避免丢失用户积分,我们要求应用程序在向Kafka发送消息失败后进行重试。但这样又有可能出现同一个积分事件被重复接收导致多记积分的问题。 我们的解决办法是应用程序在产生积分的事件中带上一个对用户唯一的uuid, 并且通过重发的机制确保事件最少被发送到Kafka一次。 在积分系统中根据uuid进行排重,丢掉uuid重复的积分事件,保证积分事件最多被处理一次。通过这样一种应用程序之间的协议实现了一个积分事件被被处理一次且仅被处理一次的目标。</p> <h2><strong>Event Sourcing 架构模式</strong></h2> <p>在实践中,我们有修正积分的需求。 比如, 由于bug, 应用程序错误的产生出了一些事件,需要减掉由这些事件而增加的积分。直接的方法是找出这些事件产生的积分,然后从账户中直接扣减。 但是这一方法在下面的场景中会导致错误:</p> <p>假设积分规则是用户首次登录奖励500分,当天内第2次登陆再奖励1000分。</p> <ol> <li> <p>由于应用程序错误,产生了登录事件L1,导致增加500积分</p> </li> <li> <p>用户登录产生登陆事件L2。 积分系统发现当天已经出现过1次登陆事件L1, 根据规则增加了1000积分。</p> </li> </ol> <p>管理员发现为L1不应该发生,直接扣除500积分,用户实际得分1000分。 这是错误的。 在没有事件L1的情况下,登陆事件L2只应该获得500分。产生这一错误的根本原因是积分的计算可能依赖于历史事件。 历史事件的变化将影响后续事件处理。</p> <p>解决这种问题的一种方式是: 当历史事件发生了变化时, 回滚到该时间点前的历史状态,然后按照时间顺序重新处理之后的所有积分事件 , 这类似于数据库系统中使用checkpoint和日志来恢复数据库状态的方式。Event Sourcing 概括了这种软件设计模式(详细内容可参考软件设计领域大师Martin Fowler的相关文章)。Event Sourcing 模式最核心的概念是程序的所有状态改动都是由事件触发并且这些事件被持久化到磁盘中。 当需要恢复程序状态时,只需把保存的事件读出来再重新处理一遍。</p> <p>积分系统遵照Event Sourcing模式实现。 积分的所有变化都由积分事件触发,所有积分事件都存储在数据库中。为了回滚积分账户状态,还需要保存积分账户的历史数据。我们实现的方法是在积分账户发生变化时,产生一条积分流水,保存了积分变化数量,以及积分变化前和变化后的总额。当需要回滚积分账户状态时,找到离回滚时间点最近的积分流水,恢复历史积分账户的总额,然后按照时间顺序逐一处理保存的积分事件,恢复积分账户数据。 下图展示了这一流程:</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/8f67bf8abcc757dce0e460b40c9eb3f0.jpg"></p> <p>下面是用命令行工具把积分账户状态恢复到2016-05-01之前,然后重新处理积分事件恢复积分的界面。</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/9a37f651500d63c19984eb5894b282b5.jpg"></p> <p>在生产环境的运维经验表明,相对于手工直接修改积分账户数据, 这种修改历史积分事件,回滚账户状态然后重新处理积分事件的方式不但提高了准确性,而且简化了修正工作,节省了运维人员的时间。</p> <h2><strong>用Go构建DSL实现灵活的积分规则引擎</strong></h2> <p>由于接入的应用程序类型多样,积分规则会随着运营的开展而频繁变化。如果每次积分规则发生了变化,都要求对积分系统改动升级, 积分系统维护就会变成一项很繁琐的工作。 我们的目标是让积分系统保持足够的灵活性,当积分业务规则变化时,在大多数情况下可以不用改动升级积分系统。最理想的情况是运营人员通过简单培训后自己就能配置积分规则,不需要开发人员修改积分系统软件。</p> <p>为此我们开发了一个积分规则引擎, 通过提供一个积分规则描述语言,把积分的业务逻辑从积分系统软件中分离出去:</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/0ec0a5dc4a85cdbc2cd2e455f6947131.jpg"></p> <p>下面首先描述积分规则描述语言的语法表示和存储方式, 然后描述规则引擎加载解释积分规则的流程。</p> <p>积分规则引擎首先需要提供一个让运营人员描述积分规则的语法。抽象的看,积分规则可以表示为一个元组: (积分条件,积分数量), 表示当满足设置的条件时,增加对应的积分数量。 很容易联想到积分条件可以用编程语言中的布尔表达式表示,积分数量用数值表达式表示。</p> <p>由于我们使用的是Go语言实现积分系统, 出于解析方便的考虑(Go自带了自身的语法分析库),我们采用了Go语言的表达式语法表示积分规则的条件和数量。 在积分规则的表达式中,Go语言的字符串、数字、布尔常量都可以直接使用。变量表示积分事件中的字段数据。比如,积分规则(event_type==“answer_is_liked”, 250) 表示当前积分事件类型(event_type)为answer_is_liked(答案被点赞) 时,积分条件匹配, 记录 250 个积分 。</p> <p>在定义了积分规则的语法表示后,还需要决定在哪里存储积分规则。最初考虑存放在文件中,很快发现如果把积分规则和积分数据存放在同一个数据库中就可以方便的利用数据库的一致性检查功能保证数据一致性, 这是保证软件系统长期正确运行的关键措施。 比如,通过数据库的外键设置,我们能保证每条积分流水指向一个有效积分规则,杜绝因为规则被错删,积分流水指向无效积分规则的情况。 下面是积分规则在数据库中表示的例子:</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/17777e0f5be7ce8b8742487ca65d0936.jpg"></p> <p>上表第1行积分规则表示当积分事件是answer_is_liked时,增加250分;</p> <p>第2行要复杂一些,表示当积分事件是answer_question(回答问题),并且属于首次回答问题时增加积分, 如果是投资顾问,增加4000分,其他人员增加2500分。其中event_type是积分事件的字段; count_by_same_event_attr是在规则表达式中允许使用的函数,用来统计该用户的具有相同字段值的积分事件数量;data.originator_type 也是积分事件的字段,表示用户类型。</p> <p>为了增强扩展性,规则引擎提供了一套插件机制,可以用Go语言编写能用在规则表达式中使用的函数。比如上表第2行中的count_by_same_event_attr就是通过插件实现的,用来计算目前已经收到的具有相同属性值的事件数量。在实践中,当发现积分规则不能满足业务需求时,我们往往通过编写插件的方式来扩展积分规则的表达能力,而不是修改规则引擎的核心代码。</p> <p>在运营人员配置积分规则后,积分系统需要使用规则引擎解释执行积分规则, 主要流程是:</p> <p><strong>1、积分系统在启动时加载所有应用程序的积分规则</strong></p> <p>积分规则在被规则引擎加载后完成语法解析,在内存中解释执行。 这避免了在运行中访问磁盘或数据库引起的性能瓶颈。需要注意的是,虽然积分规则的语法和Go语言表达式相同,积分规则的语义却有变化。对于会引起Go语言抛出异常的表达式(e.g. 除 0),积分规则引擎解释为nil,避免了程序异常退出。</p> <p><strong>2、监听消息总线,对于新收到的积分事件,逐个尝试匹配积分规则的条件。如果该积分事件能满足某个积分规则的条件,则增加由积分规则中的积分。</strong></p> <p>下图表示了运行规则引擎记录积分的流程。</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/61e54070ec90b735cb31181ea74de829.jpg"></p> <p>可以看出,我们实际上构造了一个DSL(Domain Specific Language), 语法和Go语言的表达式一样,但是语义不同。积分规则其实是这一DSL编写的程序, 作为数据保存在数据库中, 在被规则引擎装载后又当作程序来执行。这里体现了“代码即数据”(code as data)的编程思想。</p> <h2><strong>技术栈:Go + Postgres + Docker</strong></h2> <p><strong>1、Go 语言</strong></p> <p>Go语言是为大规模系统软件的开发而设计的, 具有语法简洁,静态类型检查,编译快速,支持并发程序设计等特点。</p> <p>和JavaScript等动态语言相比,我们感觉在某些场景下,由于Go的类型系统比较复杂并且不支持范型, 编写的代码量会多一些。一个典型的例子是排序,使用Go的排序库时,一般需要实现一个sort.Interface, 包含有Len, Swap, Less 3个方法。 而使用JavaScript进行排序,往往只需要1行代码。</p> <p>但是和动态语言相比,Go的静态类型检查减少了很多运行时bug,节约了调试时间, 并且Go提供的工具比较完善,自带文档,格式化,单元测试和包管理工具。 Go的生态系统也比较成熟,第3方软件包丰富。综合来看,使用Go的开发效率并不会低太多。</p> <p>我们发现Go语言的静态链接特性非常适合docker部署,积分系统用docker打包后只有10M左右。相比于NodeJS打包后上百M的体积,采用Go语言大大节省了部署时间和资源。</p> <p>总的来说,我们对Go语言是比较满意的,将会继续在关键的系统服务中使用。</p> <p><strong>2、Postgres</strong></p> <p>在使用了一段时间的MongoDB后,我们希望在关键业务中采用有严格schema检查的关系型数据库。 Postgres是一个成熟的开源数据库,除了支持数据一致性检查和事务外,也支持JSON, 吸收了NoSQL的优点。</p> <p>在积分系统中,应用程序需要在积分事件中保存一些自定义的属性, 在查询积分流水时积分系统原样返回,由应用程序自行处理。 由于事先无法预知应用程序保存的内容格式,我们把这样的数据放在一个JSON字段中, 完全由应用程序控制。在数据存入之后, 通过Postgres的JSON操作符,我们可以方便的管理这些数据,比如,根据指定的JSON字段查询。</p> <p>除了使用Go、Postgres、Docker这些技术开发和部署服务,由于积分系统是为应用程序提供服务的,它天然需要通过API来支持其他开发者。 我们选择了用工具slate来制作API文档。下图是使用markdown编写,由slate转换成html格式的 API文档式样。</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/642f80b94c89fad98bbfc4d5755376f9.jpg"></p> <h2><strong>总结</strong></h2> <p>积分系统并不是一个技术架构上复杂的系统,但是它是借鉴“游戏”实践而进行的数字化精细化经营的重要业务环节,相信在越来越多进行“互联网+”创新的垂直行业中会有类似的实践。具体的技术实现手段也很多,在此为便于行业内外读者的理解,我们对方案作了简化和抽象。</p> <p>然而,对相对简单的问题作“教科书”式的简练实现,遵循KISS(Keep It Simple,Stupid!)的原则,避免“过度工程”(over-engineering),也是我们的团队文化和准则。本文所介绍的Event Sourcing架构模式和DSL规则引擎,可以帮助我们在很多场景“简单而优雅”(simple but elegant)的解决问题。</p> <p> </p> <p> </p> <p>来自:http://www.infoq.com/cn/articles/design-and-implementation-of-the-integral-rule-engine</p> <p> </p>