写点什么

基于 Event Sourcing 和 DSL 的积分规则引擎设计实现案例

2016 年 9 月 18 日

架构设计模式(Architecture Patterns),是“从特殊到普遍”的、基于各种实际问题的解决方案而总结归纳出来的架构设计最佳实践,是一种对典型的、局部的架构逻辑的高度抽象思维;在合理的场景下恰当使用它们,避免“重新发明车轮”,对技术解决方案有指导性作用,往往事半功倍。广发证券 IT 研发团队作为架构设计模式的坚定践行者,在各类证券业务中经常运用。Event Sourcing 就是这么一个比较常用而重要的架构模式。本文介绍的虽然是金融业场景,但是“积分系统”相信对其他行业的开发者也不会陌生。技术团队尝试用 Event Sourcing 架构模式和基于 Go 构建的 DSL“简单而优雅”的解决一个问题。

在电商行业,积分几乎已经成为了一个标配。 京东、淘宝都有自己的积分体系。 用户通过购物或者完成指定任务来获得积分。累积的积分可以给用户带来利益,比如增加用户等级,换取礼品或者在购物时抵扣现金。

在广发证券的金融电商运营平台中,积分同样是一个不可或缺的基础服务,很多应用都有和积分账户交互的场景。积分的用途也比较广泛,除了用在面向客户的服务中增加客户粘性和忠诚度外,积分也被用来支持内部的“游戏化”(gamification)运营,让数字化经营成为可能。 例如:公司的投资顾问可以通过编辑高质量的理财知识条目和回答客户问题获得积分,最终被换算回个人绩效收入。

一个场景,是客户提了一个关于证券的问题, 如果投资顾问回答了这一问题,并且答案被其他用户收藏,就可以获得 500 积分作为奖励。 实践表明,积分的使用大大提升了投资顾问回答问题的积极性,提高了运营的效率。这其实是精细化运营、数字化经营的一个非常重要的基础设施。这个积分体系的存在,甚至改变、颠覆了传统企业对员工进行分派任务、管理、激励、计算个人绩效的机制。

从技术的角度,怎样实现一个积分系统满足各种应用程序的需求呢? 虽然使用积分的场景不同, 有的面向客户,有的面向公司内部的理财顾问,进行抽象后的积分系统可以是相同的。 和银行账户类似, 一个用户的积分账户可以看作由账户类型, 表示余额的数字和一系列引起积分变化的流水帐组成。 根据这些共性,我们把积分实现为一个独立的服务,统一存储管理积分数据。 在和应用程序交互的方式上,最初的想法是积分系统为应用程序提供增加 / 扣除积分的接口, 由应用程序决定增加 / 扣除积分的数量。

我们很快发现这种架构在应用程序中嵌入了积分规则逻辑,当积分规则改变时,应用程序需要随之改变。 比如,如果运营人员把上面例子中的 500 积分改变为 1000 分后,开发人员就需要升级应用程序。 在使用积分的应用程序数量多,运营需求变化快的情况下,这种应用程序和积分系统紧密耦合的架构增加了系统维护成本。

典型的“事件驱动”场景

无论是面向消费者客户的电商平台、航空公司顾客的飞行里数服务(mileage program)还是面向内部员工的“游戏化运营”平台,很显然,在技术层面都是一个典型的“事件驱动”场景 – 用户通常通过在各种各样的业务系统进行了一些活动,这些活动被记录到一个积分系统中“映射”成一定的积分。所以,积分系统设施的“使用者”,往往是其他的一些各式其色的、事前甚至无法预估的应用程序。

技术架构的设计原则是这样:由不可预知的应用程序自己负责判断其用户所进行的活动有无“价值”,对于有价值的活动则以发起事件的方式异步通知积分系统,积分系统则负责实时收集事件并基于各种可能由经营管理者随时修订、配置、改变的积分规则对事件所包含的用户活动进行“簿记”(book-keeping)。

我们采用了基于消息总线的架构设计, 应用程序和积分系统之间通过异步的消息总线关联。应用程序不包含任何积分规则,只负责向消息总线发布事件。 积分系统被实现为一个独立的服务,包含了所有的积分账户数据和积分规则。 积分系统向消息总线订阅事件, 然后根据设置的积分规则处理事件, 记录积分。这种架构使应用程序和积分系统呈松耦合关系,提升了系统的可维护性。 系统架构如下图所示:

举一个例子说明记录积分的过程:某投资顾问在广发证券知识库的应用程序中回答了一个问题,并且该问题被一个客户收藏。 知识库应用程序向消息总线发布一个答案被收藏的事件。 积分系统在监听到这一事件后,根据事先配置的积分规则,向投资顾问的积分账户增加积分数量,记录积分流水。

积分系统监听的事件并不一定由应用程序直接产生。对于复杂的积分规则,可能由其他服务处理应用程序的事件流后,产生新的事件流,再由积分系统处理。例如,需要对 7 月份连续 3 天登录的用户奖励 50 分。应用程序没有保存历史登录数据,只产生简单的登录事件。 大数据平台(对于积分系统而言是一个应用程序)可以根据保存的历史数据产生包含连续登录天数的事件, 发布到消息总线上后由积分系统订阅处理。 这体现了基于消息总线架构的优点,能把积分处理逻辑从应用程序中完全剥离出来,同时具有扩展性。

积分类似虚拟货币,可最终换算成员工绩效或者消费者的某些形式的奖励,所以不能多记,也不能少记。为了达到这一目标,技术层面上需要解决消息被处理一次且仅被处理一次的问题。我们的消息总线采用的是分布式消息系统 Kafka, 它具有比较好的容错性和扩展性, 但不直接提供这样的支持,需要在应用程序层面处理。 应用程序向 kafka 发送消息时可能因为网络的原因发送失败。

为了避免丢失用户积分,我们要求应用程序在向 Kafka 发送消息失败后进行重试。但这样又有可能出现同一个积分事件被重复接收导致多记积分的问题。 我们的解决办法是应用程序在产生积分的事件中带上一个对用户唯一的 uuid, 并且通过重发的机制确保事件最少被发送到 Kafka 一次。 在积分系统中根据 uuid 进行排重,丢掉 uuid 重复的积分事件,保证积分事件最多被处理一次。通过这样一种应用程序之间的协议实现了一个积分事件被被处理一次且仅被处理一次的目标。

Event Sourcing 架构模式

在实践中,我们有修正积分的需求。 比如, 由于 bug, 应用程序错误的产生出了一些事件,需要减掉由这些事件而增加的积分。直接的方法是找出这些事件产生的积分,然后从账户中直接扣减。 但是这一方法在下面的场景中会导致错误:

假设积分规则是用户首次登录奖励 500 分,当天内第 2 次登陆再奖励 1000 分。

  1. 由于应用程序错误,产生了登录事件 L1,导致增加 500 积分
  2. 用户登录产生登陆事件 L2。 积分系统发现当天已经出现过 1 次登陆事件 L1, 根据规则增加了 1000 积分。

管理员发现为 L1 不应该发生,直接扣除 500 积分,用户实际得分 1000 分。 这是错误的。 在没有事件 L1 的情况下,登陆事件 L2 只应该获得 500 分。产生这一错误的根本原因是积分的计算可能依赖于历史事件。 历史事件的变化将影响后续事件处理。

解决这种问题的一种方式是:当历史事件发生了变化时, 回滚到该时间点前的历史状态,然后按照时间顺序重新处理之后的所有积分事件, 这类似于数据库系统中使用 checkpoint 和日志来恢复数据库状态的方式。Event Sourcing 概括了这种软件设计模式(详细内容可参考软件设计领域大师 Martin Fowler 的相关文章)。Event Sourcing 模式最核心的概念是程序的所有状态改动都是由事件触发并且这些事件被持久化到磁盘中。 当需要恢复程序状态时,只需把保存的事件读出来再重新处理一遍。

积分系统遵照 Event Sourcing 模式实现。 积分的所有变化都由积分事件触发,所有积分事件都存储在数据库中。为了回滚积分账户状态,还需要保存积分账户的历史数据。我们实现的方法是在积分账户发生变化时,产生一条积分流水,保存了积分变化数量,以及积分变化前和变化后的总额。当需要回滚积分账户状态时,找到离回滚时间点最近的积分流水,恢复历史积分账户的总额,然后按照时间顺序逐一处理保存的积分事件,恢复积分账户数据。 下图展示了这一流程:

下面是用命令行工具把积分账户状态恢复到 2016-05-01 之前,然后重新处理积分事件恢复积分的界面。

在生产环境的运维经验表明,相对于手工直接修改积分账户数据, 这种修改历史积分事件,回滚账户状态然后重新处理积分事件的方式不但提高了准确性,而且简化了修正工作,节省了运维人员的时间。

用 Go 构建 DSL 实现灵活的积分规则引擎

由于接入的应用程序类型多样,积分规则会随着运营的开展而频繁变化。如果每次积分规则发生了变化,都要求对积分系统改动升级, 积分系统维护就会变成一项很繁琐的工作。 我们的目标是让积分系统保持足够的灵活性,当积分业务规则变化时,在大多数情况下可以不用改动升级积分系统。最理想的情况是运营人员通过简单培训后自己就能配置积分规则,不需要开发人员修改积分系统软件。

为此我们开发了一个积分规则引擎, 通过提供一个积分规则描述语言,把积分的业务逻辑从积分系统软件中分离出去:

下面首先描述积分规则描述语言的语法表示和存储方式, 然后描述规则引擎加载解释积分规则的流程。

积分规则引擎首先需要提供一个让运营人员描述积分规则的语法。抽象的看,积分规则可以表示为一个元组: (积分条件,积分数量), 表示当满足设置的条件时,增加对应的积分数量。 很容易联想到积分条件可以用编程语言中的布尔表达式表示,积分数量用数值表达式表示。

由于我们使用的是 Go 语言实现积分系统, 出于解析方便的考虑(Go 自带了自身的语法分析库),我们采用了 Go 语言的表达式语法表示积分规则的条件和数量。 在积分规则的表达式中,Go 语言的字符串、数字、布尔常量都可以直接使用。变量表示积分事件中的字段数据。比如,积分规则 (event_type==“answer_is_liked”, 250) 表示当前积分事件类型 (event_type) 为 answer_is_liked(答案被点赞) 时,积分条件匹配, 记录 250 个积分 。

在定义了积分规则的语法表示后,还需要决定在哪里存储积分规则。最初考虑存放在文件中,很快发现如果把积分规则和积分数据存放在同一个数据库中就可以方便的利用数据库的一致性检查功能保证数据一致性, 这是保证软件系统长期正确运行的关键措施。 比如,通过数据库的外键设置,我们能保证每条积分流水指向一个有效积分规则,杜绝因为规则被错删,积分流水指向无效积分规则的情况。 下面是积分规则在数据库中表示的例子:

上表第 1 行积分规则表示当积分事件是 answer_is_liked 时,增加 250 分;

第 2 行要复杂一些,表示当积分事件是 answer_question(回答问题),并且属于首次回答问题时增加积分, 如果是投资顾问,增加 4000 分,其他人员增加 2500 分。其中 event_type 是积分事件的字段; count_by_same_event_attr 是在规则表达式中允许使用的函数,用来统计该用户的具有相同字段值的积分事件数量;data.originator_type 也是积分事件的字段,表示用户类型。

为了增强扩展性,规则引擎提供了一套插件机制,可以用 Go 语言编写能用在规则表达式中使用的函数。比如上表第 2 行中的 count_by_same_event_attr 就是通过插件实现的,用来计算目前已经收到的具有相同属性值的事件数量。在实践中,当发现积分规则不能满足业务需求时,我们往往通过编写插件的方式来扩展积分规则的表达能力,而不是修改规则引擎的核心代码。

在运营人员配置积分规则后,积分系统需要使用规则引擎解释执行积分规则, 主要流程是:

1、积分系统在启动时加载所有应用程序的积分规则

积分规则在被规则引擎加载后完成语法解析,在内存中解释执行。 这避免了在运行中访问磁盘或数据库引起的性能瓶颈。需要注意的是,虽然积分规则的语法和 Go 语言表达式相同,积分规则的语义却有变化。对于会引起 Go 语言抛出异常的表达式(e.g. 除 0),积分规则引擎解释为 nil,避免了程序异常退出。

2、监听消息总线,对于新收到的积分事件,逐个尝试匹配积分规则的条件。如果该积分事件能满足某个积分规则的条件,则增加由积分规则中的积分。

下图表示了运行规则引擎记录积分的流程。

可以看出,我们实际上构造了一个 DSL(Domain Specific Language), 语法和 Go 语言的表达式一样,但是语义不同。积分规则其实是这一 DSL 编写的程序, 作为数据保存在数据库中, 在被规则引擎装载后又当作程序来执行。这里体现了“代码即数据”(code as data)的编程思想。

技术栈:Go + Postgres + Docker

1、Go 语言

Go 语言是为大规模系统软件的开发而设计的, 具有语法简洁,静态类型检查,编译快速,支持并发程序设计等特点。

和 JavaScript 等动态语言相比,我们感觉在某些场景下,由于 Go 的类型系统比较复杂并且不支持范型, 编写的代码量会多一些。一个典型的例子是排序,使用 Go 的排序库时,一般需要实现一个 sort.Interface, 包含有 Len, Swap, Less 3 个方法。 而使用 JavaScript 进行排序,往往只需要 1 行代码。

但是和动态语言相比,Go 的静态类型检查减少了很多运行时 bug,节约了调试时间, 并且 Go 提供的工具比较完善,自带文档,格式化,单元测试和包管理工具。 Go 的生态系统也比较成熟,第 3 方软件包丰富。综合来看,使用 Go 的开发效率并不会低太多。

我们发现 Go 语言的静态链接特性非常适合 docker 部署,积分系统用 docker 打包后只有 10M 左右。相比于 NodeJS 打包后上百 M 的体积,采用 Go 语言大大节省了部署时间和资源。

总的来说,我们对 Go 语言是比较满意的,将会继续在关键的系统服务中使用。

2、Postgres

在使用了一段时间的 MongoDB 后,我们希望在关键业务中采用有严格 schema 检查的关系型数据库。 Postgres 是一个成熟的开源数据库,除了支持数据一致性检查和事务外,也支持 JSON, 吸收了 NoSQL 的优点。

在积分系统中,应用程序需要在积分事件中保存一些自定义的属性, 在查询积分流水时积分系统原样返回,由应用程序自行处理。 由于事先无法预知应用程序保存的内容格式,我们把这样的数据放在一个 JSON 字段中, 完全由应用程序控制。在数据存入之后, 通过 Postgres 的 JSON 操作符,我们可以方便的管理这些数据,比如,根据指定的 JSON 字段查询。

除了使用 Go、Postgres、Docker 这些技术开发和部署服务,由于积分系统是为应用程序提供服务的,它天然需要通过 API 来支持其他开发者。 我们选择了用工具 slate 来制作 API 文档。下图是使用 markdown 编写,由 slate 转换成 html 格式的 API 文档式样。

总结

积分系统并不是一个技术架构上复杂的系统,但是它是借鉴“游戏”实践而进行的数字化精细化经营的重要业务环节,相信在越来越多进行“互联网 +”创新的垂直行业中会有类似的实践。具体的技术实现手段也很多,在此为便于行业内外读者的理解,我们对方案作了简化和抽象。

然而,对相对简单的问题作“教科书”式的简练实现,遵循 KISS(Keep It Simple,Stupid!)的原则,避免“过度工程”(over-engineering),也是我们的团队文化和准则。本文所介绍的 Event Sourcing 架构模式和 DSL 规则引擎,可以帮助我们在很多场景“简单而优雅”(simple but elegant)的解决问题。

作者介绍

龚力,毕业于电子科技大学,广发证券 IT 研发资深架构师,一直负责金融电商、零售金融相关技术系统的设计与实施。是在大规模金融电商领域成功使用 MEAN(MongoDB、Express、Angular、Node.js)技术栈的最早践行者;近期更多采用 Go 语言进行业务开发。投身金融业前有多年互联网、电信行业研发经验。


感谢郭蕾对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2016 年 9 月 18 日 17:525973

评论

发布
暂无评论
发现更多内容

第六周总结

秦宝齐

作业

记一次Apache的代码导致生产问题

java金融

Java Apache spring BeanUtils

400GE燎原前夜,智能IP网络的核心路由器巅峰际会

脑极体

redis系列之——数据类型bitmaps:今天你签到了吗?

诸葛小猿

redis bitmaps bloomfilter

CAP的原理

满山李子

为了保存VuePress构建的网站为PDF,我竟然。。。

Leetao

Python python 爬虫 PDF vuepress pdfkit

极客时间架构师训练营 - week6 - 作业 2

jjn0703

极客大学架构师训练营

区块链扩张路径变局:从技术比拼转向生态落地

CECBC区块链专委会

架构师训练营第 06 周——总结

李伟

极客大学架构师训练营

架构感悟 6- 平衡之美

旭东(Frank)

黑鹰坠落

escray

用Roslyn做个JIT的AOP

八苦-瞿昙

技术 随笔杂谈 aop 代理 框架

负载均衡

满山李子

React与前端开发发展史

pingan8787

Week 6 作业

Shawn

从面试到入职到离职,我在B站工作的30天时光!!!

诸葛小猿

面试 B站 哔哩哔哩 收钱吧

架构师训练营第六章作业

叮叮董董

LeetCode题解:15. 三数之和,JavaScript双循环+HashMap,详细注释

Lee Chen

LeetCode 前端进阶训练营

架构师训练营 Week 06 总结

Wancho

架构师训练营 Week 06 作业

Wancho

架构师第六周作业及总结

傻傻的帅

用“实例化需求”,让需求澄清更高效

小隐乐乐

分布式系统架构学习总结(分布式数据库和NoSQL)

qihuajun

架构师训练营第六周学习总结

fenix

极客大学架构师训练营

架构师训练营第六章总结

叮叮董董

By Experience的三个层次 -- 领域驱动设计的经验之谈

Winfield

架构 领域驱动设计 DDD 架构设计

[架构师训练营]Week03 - 作业

谭方敏

1. react起始 | 2020年前端再入门系列连载

chaozh

前端开发 React

架构设计篇之中台战略思想与落地

小诚信驿站

架构设计 刘晓成 中台战略 服务化改造

week6.课后作业

个人练习生niki

极客大学架构师训练营

架构师训练营第 06 周—— 练习

李伟

极客大学架构师训练营

InfoQ 极客传媒开发者生态共创计划线上发布会

InfoQ 极客传媒开发者生态共创计划线上发布会

基于Event Sourcing和DSL的积分规则引擎设计实现案例-InfoQ