SegmentFault 有赞技术最新的文章
2020-12-23T16:54:07+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
Vant 3.0 正式发布:全面拥抱 Vue 3
https://segmentfault.com/a/1190000038612513
2020-12-23T16:54:07+08:00
2020-12-23T16:54:07+08:00
有赞技术
https://segmentfault.com/u/youzantech
16
<blockquote>Vant 是有赞前端团队开源的一套轻量、可靠的移动端组件库。</blockquote><p>历经八个月时长的开发,Vant 3.0 终于和大家正式见面咯。在本次迭代中,我们的主要工作是<strong>基于 Vue 3 重构整个代码仓库和周边生态</strong>,并发布 Vant 3.0、Vant Cli 3.0 和 Vant Use 1.0。</p><h2>回顾</h2><p>按照惯例,我们先简单回顾一下 Vant 开源至今的成绩:</p><ul><li><strong>270 位开发者</strong> 参与了 Vant 和 VantWeapp 开发,并贡献了 4100 个 Pull Request</li><li><strong>7300 个 issue</strong> 被关闭,99.3% 的 issue 得到解决或答复</li><li>站点月访问量 <strong>800 万次</strong></li><li>CDN 月下载量 <strong>3 亿次</strong></li></ul><h2>更新内容</h2><h3>Vant 3.0:全面拥抱 Vue 3 💪</h3><p>Vue 3 带来了许多激动人心的新特性,比如 Composition API、emits Option 和 Teleport。在 Vant 3.0 中,我们全面拥抱了 Vue 3 带来的各种变化,完成下列改造:</p><ul><li>使用 Composition API 重构所有组件</li><li>使用 Composition API 重写所有文档和示例</li><li>组件增加 emits 选项,提供更好的事件提示</li><li>移除所有 mixins,提升代码可读性</li><li>所有弹窗类组件支持 teleport 属性</li></ul><p>重构完成后,组件之间可以基于 Composition API 进行逻辑复用,代码的可压缩性也有所提升。与 Vant 2.12 版本进行对比,可以看到 Vant 3.0 的 JS 体积<strong>下降了 16.6%</strong>,Gzip 后体积下降至 67.5kb。</p><p><img src="/img/remote/1460000038612516" alt="" title=""></p><h3>新组件:Vant 2、Vant 3 同步供应</h3><p>Vant 3.0 中包含 3 个全新的组件,分别是:</p><ul><li><strong>Badge 徽标</strong>:用于在右上角展示徽标数字或小红点。</li><li><strong>Popover 气泡弹出框</strong>:弹出式的气泡菜单组件。</li><li><strong>Cascader 级联选择器</strong>:用于多层级数据的选择,典型场景为省市区选择。</li></ul><p>考虑到大部分开发者仍然在使用 Vue 2 进行开发,我们在 Vant 2 中<strong>同步实现了以上三个组件</strong>,大家可以升级到 Vant 2.12 版本进行使用。</p><p><img src="/img/remote/1460000038612519" alt="" title=""></p><h3>Vant Use:新伙伴 👬</h3><p><a href="https://link.segmentfault.com/?enc=%2BuqCP77AFx5lA8crzQFBNg%3D%3D.OTaa9D%2BZwDHToXZyl0NayNZOmPRdASof8tHbus4qUntzFyGwSkRGhWdlkuL6hA90" rel="nofollow">Vant Use</a> 从 Vant 中沉淀出的 Composition API 库。除了提供常用的 Composition API 外,Vant Use 也会将某些组件的逻辑抽离出来,让开发者在使用组件逻辑的同时,能够完全自定义组件的展现形式。</p><p>下面是一个简单的例子,我们将 CountDown 组件的倒计时逻辑抽象为 <code>useCountDown</code> 方法,功能与 CountDown 组件基本等价,但使用起来更加灵活,我们可以自定义倒计时的 UI 样式,或者通过 <code>computed</code> 对倒计时进行预处理。</p><p><img src="/img/remote/1460000038612518" alt="" title=""></p><p>Vant Use 仍然处于早期阶段,在未来的演进过程中,我们会继续抽离 Vant 组件内部的通用逻辑,并下沉到 Vant Use 中。</p><h3>Vant Cli 3.0:更新,更快 🚀</h3><p><a href="https://link.segmentfault.com/?enc=6s%2B%2FqgzpBa3EZcqUynLjuw%3D%3D.9DtjFw%2BCzWV%2FWG%2BuUGdiL0y4%2Bt1PzDkBD6ZCHAgfr1JJPOXF%2BTozqf4aq2Qr%2BY6JBbiJCtsKETCy6V0Pe80Eiw%3D%3D" rel="nofollow">Vant Cli</a> 是 Vant 底层的组件库构建工具,通过 Vant Cli 可以快速搭建一套功能完备的 Vue 组件库。在 Vant Cli 3.0 中,我们<strong>对所有底层依赖进行了大版本升级</strong>,在支持 Vue 3 的同时,提供更流畅的开发体验。</p><ul><li>升级 Vue 3、VueRouter 4、VueLoader 16</li><li>升级 Webpack 5,开启持久缓存能力</li><li>升级 Docsearch 3,全新的搜索框样式</li><li>升级 TypeScript 4、ESLint 7</li></ul><p>在创建 <code>vant-cli</code> 工程时,你可以自由选择基于 Vue 2 或者 Vue 3 进行组件库开发:</p><p><img src="/img/remote/1460000038612517" alt="" title=""></p><h3>Vant Demo:2 个新的示例工程</h3><p><a href="https://link.segmentfault.com/?enc=JBNB%2Bamy7%2BrM6L80Z%2F%2FBBg%3D%3D.NB%2BT4WVgznZNlt9ZFJcL%2BjagUl5JAv%2F7n1g7Ruda8znswDVwESOh0JHIIG%2BhozMC" rel="nofollow">Vant Demo</a> 是 Vant 官方提供的示例工程合集,在本次迭代中,我们新增了 2 个示例工程,分别演示:</p><ul><li>如何使用 <strong>Vue 3 + Vant 3 + Vue Cli</strong> 搭建应用</li><li>如何使用 <strong>Vue 3 + Vant 3 + Vite</strong> 搭建应用</li></ul><p>许多喜欢尝鲜的小伙伴已经在使用 Vite 进行开发了,在使用 Vite 的过程中,经常令大家困惑的一点是,如何在 Vite 中进行按需引入 Vant 组件。在 Vue Cli 中,我们可以通过 babel-plugin-import 插件实现按需引入,但在 Vite 中无法使用该插件。</p><p>其实在 Vite 中无须考虑按需引入的问题。Vite 在构建代码时,会自动通过 <strong>Tree Shaking</strong> 移除未使用的 ESM 模块。而 Vant 3.0 内部所有模块都是基于 ESM 编写的,天然具备按需引入的能力。现阶段遗留的问题是,未使用的组件样式无法被 Tree Shaking 识别并移除,后续我们会考虑通过 Vite 插件的方式进行支持。</p><h2>开始尝鲜</h2><p>目前,Vant 官网默认展示 Vant 2 的 API 文档,你可以通过官网右上角的<strong>版本切换按钮</strong>访问 Vant 3 的文档,也可以 👉 <a href="https://link.segmentfault.com/?enc=HDunPKeCc22iWVHblP%2FN7Q%3D%3D.QHzvg0a%2BjasEErHC2Jyj4XYeMuen7mCc0ZrvohkCDc7N0Emdu9Lu3nKDPbI7GhHq" rel="nofollow">点此访问</a>。</p><p>同时,Vant 的 npm latest 标签也保持在 v2 版本,这意味着使用 <code>npm install vant</code> 命令仍会安装 Vant 2,而安装 Vant 3 需要使用 <code>npm install vant@next</code> 命令。在 Vue 的默认文档版本和 npm 标签切换为 v3 后,我们也会同步进行切换。</p><p>从现有 Vant 2 项目升级,请参考 <a href="https://link.segmentfault.com/?enc=46y5Kt6%2FgrV0%2BGQ69X%2BC0A%3D%3D.SEpE0CnLaBKR1Vag81RNW575LOrIjD6QA7RAxGHqyZ4SXyOBoJji8QqUClW%2FiCz87ZPYgMt%2FCA7iW3R%2Bzjv5vQ%3D%3D" rel="nofollow">🚀 升级指南</a>。</p><h2>下一步计划</h2><p>未来 6 ~ 12 个月内,我们会保持 Vant 2 和 Vant 3 的功能同步更新。随着 Vue 3 的普及,我们会逐步降低 Vant 2 的维护频率,并将工作重心转移到 Vant 3 上。</p><p>另外,除了官方维护的 Vue 版本和微信小程序版本,Vant 也有由社区的小伙伴们发起和维护的 <a href="https://link.segmentfault.com/?enc=2SLJz0T11KqRZoWInqxP8g%3D%3D.Kgn66k6TDqj6BhfolBpUIU%2FrCT9m2MHqVsWJGEhQsKP9vyAtffTkwrkJNuRHxh2F" rel="nofollow">React 版本</a>和<a href="https://link.segmentfault.com/?enc=scGneUS7BPH8lxI4ItcUgQ%3D%3D.gu%2FP8ohjAPU5bLgTOw4SfVg232KtZKVxjQnLAURVsk74xvnblOSApSOUkHSFy5ux" rel="nofollow">支付宝小程序版本</a>,欢迎大家一起参与建设 💪</p><p>不平凡的 2020 年即将过去,希望 Vant 能给大家的工作带来一点点的帮助,我们明年再会。</p>
有赞应用市场开发者招募
https://segmentfault.com/a/1190000038213209
2020-11-18T16:14:37+08:00
2020-11-18T16:14:37+08:00
有赞技术
https://segmentfault.com/u/youzantech
0
<p><img src="/img/bVcKu5h" alt="image" title="image"></p>
有你有赞|嘉涵:秉持匠心,修炼成长
https://segmentfault.com/a/1190000037669929
2020-10-30T15:56:15+08:00
2020-10-30T15:56:15+08:00
有赞技术
https://segmentfault.com/u/youzantech
2
<p>“希望自己能保持一点少年感,希望能给世界留下一点什么”——我是嘉涵,我是有赞一名前端工程师。我正在和一群有趣的人,一起做一件有趣的事儿~</p><h3>曾梦想执笔走天涯,而今敲代码闯天下</h3><p>作为初中时写过网络小说,曾梦想执笔走天涯的我,而今换了一种方式向世界传递着自己的能量。因为在我看来,敲代码其实和写作相差无几,都属于传达信息的媒介,写作是对读者表达,编码则是对机器表达。阅读代码后,计算机理解了我们的想法,并创造出对应的产品,这是一个很有意思的事情。</p><p><strong>而且,写代码是最容易触达到几亿人的方式</strong>:只需要找到一个用户体量较大的产品,在里面写下几行代码,转眼间,这些代码就会被分发到无数用户的设备上。通过代码,我们与远方的人们达成了某种联结,产生了微弱而美好的影响,继而感到幸福,这便是心理学家阿德勒描绘的“共同体感觉”。</p><p><img src="/img/bVcIdKz" alt="image.png" title="image.png"></p><p>17年的时候,在逛 Github 时看到了有赞一系列的开源技术项目,里面正好有我感兴趣的方向,所以当时没太多考虑就来了。现在回头看,技术团队的开源项目或博客其实是一面镜子,对外折射出整个团队的技术氛围和成员素养。包括技术分享,有赞一直都在做持续投入,所以才会有这么多优秀的小伙伴被吸引并且加入我们。</p><p>团队中每个人都有趣有料,而且还有一个“大哥文化”——人人都是大哥。在互相称呼时,大家会自动加上“大哥”后缀,显得特别亲切。如果有新伙伴入职,加入群聊的那一刻就会一下子收到几十条“欢迎某某大哥”的消息,产生一种“混入社会组织”的奇妙感。</p><p><img src="/img/remote/1460000037669884" alt="" title=""></p><p><img src="/img/remote/1460000037669885" alt="" title=""></p><p><img src="/img/remote/1460000037669886" alt="" title=""></p><p><img src="/img/remote/1460000037669887" alt="" title=""></p><h3>触碰代码,拥抱成长与变化</h3><p><strong>在有赞,最大的改变是认知水平发生了变化。</strong></p><p>加入有赞的时候,我刚毕业一年,那时候看待事物还是以一种学生视角,觉得写好代码就是最牛掰的事。</p><p>现在我渐渐开始了解事物是多元的。比如一个互联网产品成功了,背后其实隐含着很多因素,可能是抓住了社会的小趋势,或是有资本在推波助澜。认识到这些,可以帮助我们跳脱出单纯的技术思维,找准技术的定位,更好地发挥自身的价值。</p><p><img src="/img/bVcIdK6" alt="image.png" title="image.png"></p><p><strong>在有赞,每个时期都会遇到不同的技术挑战</strong>。</p><p>比如核心交易链路重构、双十一大促压测、线上紧急问题等等,但这些困难都是短期的,终将会被攻克。在我个人看来,最有挑战的是坚持做长期有价值的事情。</p><p>从加入有赞开始,我就一直在维护有赞的开源组件库 Vant。组件库其实是一个很平凡的技术项目,每个成熟的互联网公司都会开发自己的组件库。但组件库同时也是一个很重要的技术项目,所有的前端页面都是基于组件库搭建的,组件库的质量时刻影响着我们的产品体验和研发效率。</p><p>在过去三年里,我们与设计同学从业务场景中提炼出 60 多个通用组件,并持续收集来自内外部的反馈,对每个组件的细节进行打磨,累计迭代 300 多个版本。过程中最磨人的是适配移动端千奇百怪的机型,很多组件在 iPhone 上呈现效果很好,但放到某些安卓机上就出幺蛾子了,渲染偏移、动画卡顿等问题层出不穷。有些问题只影响少量用户,但为了保证体验的一致性,还是得找到对应的测试机,反复尝试兼容方案,直到呈现出一致的效果。</p><p>持续不间断的投入,Vant 已经成为业界最有影响力的组件库之一,对内承载有赞所有的 C 端业务,对外服务十多万开发者。这些过程很漫长、很磨人,但结果很美好,自身也满满成就感。这就是长期主义的魅力,并且这也是最能让人成长的。</p><p>还有一个让我记忆犹新的时期:18年底,公司恰好有支持创新业务的机会,加之原先做的事情到了一个稳定期,于是我跟着TL加入到创新业务团队。这是一个从0到1的项目,产品形态、业务模式都是不确定的,工作中很有创业的氛围。我们团队一共十几个小伙伴,在闭关室里埋头做产品,不到一年时间内,从产品试验、产品内测到大量主播入驻,成就感满满。在这过程中,各位业务大佬也给予了我们很多指导,这对我个人成长影响非常大。<br><img src="/img/bVcIdK7" alt="image.png" title="image.png"></p><p><strong>在有赞,最大的感受是时间过得可真快。</strong></p><p>眼睛一闭一睁,三年就过去了。</p><p>对我自己来说,我把这三年归类为我职业生涯的初期——起步阶段,就像SaaS行业的核心是逐年稳定增长,希望我在职业生涯上也做到这一点。</p><p>作为前端岗位,也想分享一下我对前端工程师这个工种的看法:其实在程序员这个行当里,前端是技术门槛相对较低的,大部分时间里,我们面对的需求都是不难实现的。在这些场景里,前端的价值不在于解决技术难题,而在于发挥自身的同理心,秉持匠心,将产品细节、技术架构打磨到极致。做到这些,门槛自然就有了。</p><p><img src="/img/bVcIdLv" alt="image.png" title="image.png"></p><p><strong>就像巨大的建筑,总是由一木一石叠起来的,我们何妨做做这一木一石呢?</strong></p><p>我时常做这些“一木一石”的碎事,在我看来,任何事都是有意义和价值的,就像人人都懂万丈高楼平地起的道理,只有我们坚持去做一件有意义的事儿,那么剩下的就是把答案交给时间。最近在看《重来3》这本书,其中看到一个概念,叫做“信任电池”,挺有意思的,有一些感悟想和大家分享一下:</p><p>“<strong>我们和每个同事之间都有一节信任电池,当我们刚进公司的时候,这节电池的电量是50%。在工作中的每一次合作,都会为这节电池“充电”或“耗电”。电量高时,合作过程会十分顺畅,一件事情很容易就做成了。电量过低,则容易产生冲突,大量时间消耗在扯皮上,导致工作效率变低。用古人的话来类比,就是“得道多助,失道寡助”。</strong>”</p><p>因此,在工作中,我们需要用心经营与每个同事之间的信任电池,积极帮助他人解决问题,及时给出结果反馈。把信任电池充满,工作自然得心应手。</p><p>当然,这些每一个习惯都是在习惯中养成的,坏的习惯必须打破,好的习惯必须加以培养。坚持你所热爱的,拥抱你所看见的,信任这无穷的力量。这样,我们才能走的更加坚定决绝,更加有力量!</p>
有赞崔玉松:借有赞测试团队出书谈谈我们的思考
https://segmentfault.com/a/1190000022084083
2020-03-20T14:33:23+08:00
2020-03-20T14:33:23+08:00
有赞技术
https://segmentfault.com/u/youzantech
2
<p>过完年,有一天我突然收到同事的消息,说有赞测试团队准备出版一些东西,让我写个序。</p>
<p>当时非常意外,在此之前我完全没有听说过测试团队的同学在做这个事情,惊讶的一部分也来自于我从未写过序;虽然有赞技术博客在2019年输出了100多篇干货,但有赞技术团队之前从未出版过任何长篇的专题。</p>
<p>我仔细阅读了一下这本电子书,内容非常完整。我原来预期可能是一些实践分享,但是实际上里面还有很多行业中最新理论,以及基于这个理论下的有赞实践。当时一口气看了四五章,我还是挺震惊的,有赞的工作并不轻松,尤其是2019年团队面临很大的业务压力,系统扩容了一轮又一轮,几十轮的线上真实环境的压力测试,都是在半夜进行的。在这样背景下,测试团队还有如此高品质的内容产出,非常不容易,不得不让人感叹这些年有赞测试团队成长非常快。</p>
<p>今天的有赞测试团队,99.999%都是靠团队同学自己的努力,作为一个过去为这个团队做了0.001%的事情的人还是挺自豪的。 </p>
<p><strong>有赞早期没有测试这一工种</strong></p>
<p>有赞是相对比较晚才有测试团队,有赞第一行代码是2012年11月写下的,而测试团队到2014年下半年才开始筹建,那时候有赞技术团队已经超过五十人了,只有前端和后端两个工种。</p>
<p>可能今天很多人都觉得不可思议,为什么当时这么大的技术团队没有测试,按照行业常规,大约5~8个工程师就会配一个测试。按照道理,当时有赞技术团队应该有5名以上的测试了。</p>
<p>有赞是一家在很多方面都追求极致的公司,包括在测试这个岗位。其实一直到现在,我们在技术新人必修课上都会讲一个道理:「bug是写出来的,不是测试弄出来的」。所以第一责任人是coding的人,并非是测试,当然,测试有负责检查出bug的责任。我们经常在内部宣传的一句话「自己的屁股自己擦」。</p>
<p>也正是因为这种理念,我们一开始组建有赞测试团队时,就要求招聘会写代码的测试。当时整个行业里其实还以功能测试居多。当时做出这样的选择其实是不容易的,会写代码的测试在当时在市面上很少,尤其是杭州这样的城市。当然,价格也贵,我们那是还处在创业早期,没有一毛钱收入。 </p>
<p><strong>有赞只招会写代码的测试</strong></p>
<p>其实当时业务并不算很复杂,如果纯粹从满足业务需求角度出发(以线上不出大问题为目标),我们可以选择招聘少量的懂代码的测试(今天叫测试开发工程师),而更多的选择偏业务层的功能测试人员。之所以没有这样选择的原因是我觉得站在未来的5~10年的角度,纯功能测试的职业空间会很小,招聘大量的这样人过来,将来再换掉,对于这些人不太负责任,同时对于效率的提升没有帮助。我始终觉得,一个优秀的工程师是一个普通工程师效率的10倍,而且更具有扩展性。</p>
<p>事实也证明了我当时的想法:有赞测试团队组建大约一年到一年半的时间,渐渐获得了杭州技术圈子的认可。测试同学开始走出去和同行去分享有赞的测试实践和思考。</p>
<p>测试团队也逐步成为了有赞技术内部非常强悍的力量存在,主要表现在几个方面:</p>
<p>第一,在组建后的一年左右时间里就能做到梯队是最完整的;</p>
<p>第二,因为职业的天然优势,测试要跟很多项目、测很多业务,所以对业务和场景非常熟悉,从测试团队成长出来很多偏业务的技术负责人,甚至纯业务的负责人;</p>
<p>第三,因为我们坚持只招聘会写代码的测试工程师,也让很多原来由多个角色承担的事情收敛到一个角色,比如基础安全(攻防、渗透等)、早期的开发效能体系建设(CI, CD等)、稳定性保障(全链路压测、性能分析)等。正是因为补位和角色收敛,让我们在角色专业化之前节省了大量的时间精力和金钱成本,测试团队因为他们的能力优秀,主动承担了更多的事情。 </p>
<p><strong>追求长期价值</strong></p>
<p>类似这样的选择和思考,在有赞过去的发展史上有很多很多的案例,不仅仅是测试团队这一个。比如,最早我们在对于人的选择上,比如,我们对于入职新人的培训安排上,比如我们对于运维体系的组建上,比如我们对于全民服务的实施上… 太多太多了,测试团队的整个发展过程是有赞在这些事情上的一个缩影。</p>
<p>如果把目光聚焦在当下,解决当下的问题,很多事情是不值得的,如果把眼光放到5年甚至更长的周期里,两三年以后就会发现过去的「投资」慢慢开始在产生「利润」了。</p>
<p>有赞测试团队今天能有这样产出,能在行业里有一些影响力,也正是过去的「投资」和积累。这次的测试出版内容里,几乎100%都是来自有赞的真实实践,里面提到了大量关于如何做的细节,甚至包括团队搭建。</p>
<p>可能有些人会觉得有广告的嫌疑,其实要做广告有一万种方式,每种都比写本书要轻松,如果为了广告做这个事情,那真是蠢到家了。在我看来原汁原味的还原,才是真正有用的东西,这个世界从来不缺少理论,缺的是理论背后的实践。</p>
<p>小平同志说:「实践是检验真理的唯一方式」。但是我觉得,实践也未必能够检验真理。毕竟真理是普适性的,而任何实践是有限的。</p>
<p>所以最后,我还是希望提醒所有的读者,请你一定要注意前因后果,不要照搬硬套,有赞适合的,未必适合每个团队,有赞的实践只是提供了一个思路和可能,大家还是要根据自己的业务进行适度的改良和适配。</p>
<p>能和这样的测试团队一起并肩战斗,是我毕生的荣幸,他们做的远比我让他们做的要多得多,是一支令人骄傲的团队。</p>
<p>感谢所有人对于有赞测试团队的支持,欢迎所有人对于内容提出疑问和建议,欢迎任何形式直接交流。</p>
<p><strong>《测试实战前沿——有赞测试5年经验全呈现》电子书现已上架,微信识别二维码即可订阅</strong></p>
<p><img src="/img/bVbEPdA" alt="image.png" title="image.png"></p>
有赞云如何支持多语言
https://segmentfault.com/a/1190000021890970
2020-03-02T16:03:22+08:00
2020-03-02T16:03:22+08:00
有赞技术
https://segmentfault.com/u/youzantech
2
<h2>一、背景</h2>
<p>有赞是Saas公司,向商家提供了全方位的软件服务,支撑商家进行采购、店铺、商品、营销、订单、物流等等管理服务。</p>
<p>在这个软件服务里,能够满足大部分的商家,为商家保驾护航。</p>
<p>Saas形成是追求共性的过程,Saas生态化是求同存异的过程,所以当我们能够满足大部分客户需求时,我们得考虑大客户的个性化需求场景。</p>
<h3>1.1 客户分析</h3>
<p>上面讲到我们要求同存异,我们要满足个性化需求,这里简单讲解一下大客户的价值,下面就不区分优势和劣势了,都放一起:</p>
<ul>
<li>
<p>中小客户</p>
<ul>
<li>企业规模小,付费能力相对弱一些;</li>
<li>企业周期短,无法很好的保证续费;</li>
<li>大部分停留使用产品基本能力,说明软件可替代性强,难形成粘性;</li>
<li>但是数量庞大;</li>
<li>获客成本相对低一些;</li>
</ul>
</li>
<li>
<p>大客户</p>
<ul>
<li>企业规模大,付费能力强;</li>
<li>发展稳定,企业周期长,能够保证续费;</li>
<li>有利于形成标杆案例,用于推广;</li>
<li>只要能实现需求,对资金要求和价格不敏感;</li>
<li>定制需求多,一旦定制,替代成本高,粘性好;</li>
</ul>
</li>
</ul>
<p>简单罗列了一下,当然其他点还有很多,对比会发现,Saas会满足大部分客户的需求,尤其是中小客户,而且中小客户量可能会达到90%以上,但是中小客户续费能力弱会导致销售成本高,同时无法形成标杆,很难有行业影响力;大客户付费能力很强,只要能够满足需求,可能不太会对相对高的价格说不,但是基本上每个大客户都有定制需求,而且一旦达成合作,可以稳定的续费。</p>
<h3>1.2 有赞云是干嘛的</h3>
<p>前面简单的分析了一下中小客户和大客户,由于本篇的重点在于多语言,所以就不过多的讲解,实际上一些中小客户也有定制需求,希望自己的店铺标新立异,希望能够用低成本并且快速满足自己的需求,当成长为大客户时再有更多的需求。</p>
<p>Saas传统情况下是怎么做定制的呢?</p>
<pre><code class="java">//核心业务流程
............
...........
if (店铺Id.equeals(0023423)) {
//do custom logic
} else if (店铺Id.equeals(0056673)) {
//do custom logic
} else {
//do stardand logic
}
............
...........</code></pre>
<p>作为一个技术人员,可能会对上述代码很熟悉,客户是上帝,当客户提各种各样的需求时,如果不满足可能会导致客户远走他乡,如果去满足,那么我们的核心业务代码耦合性会很高,而且代码扩展性很差,定制代码越来越多,越来越约束业务的发展,导致系统出现瓶颈;当然上面的代码有点直白,一般情况下会运用规则引擎、会独立一个定制应用来处理恶心的业务,但是问题依旧;更难受的一点是客户要定制页面,难道在node层做业务判断吗?看到这里相信大家都会说不,这个系统将没法扩展,业务没办法大步向前走。</p>
<p>这个时候有赞云出现了,所以有赞云的初衷是解决客户的个性定制需求,让各种各样的客户都能够和有赞合作,有赞提供的软件可以应用于各行各业,覆盖所有场景。</p>
<p><img src="/img/bVbtUrO" alt="clipboard.png" title="clipboard.png"></p>
<p>图相对抽象,但是更好理解。从图中的箭头可以看出,有赞云就是核心业务的扩展,我们的核心业务系统将交易流程输出到有赞云,开发者或者商家可以自己定制<strong>交易流程</strong>,如<strong>卖电影票</strong>的商家可以在下单流程中加一个选座的流程;同时在流程中的各个业务关键点输出<strong>扩展能力</strong>,让开发者可以去实现这个扩展能力,如<strong>价格计算</strong>,原来价格计算是核心系统做的事情,现在开发者可以自己写价格计算逻辑,这个价格计算的结果可能就是商品的实际成交价格;<strong>前端页面组件化</strong>,开发者可以定制自己的组件,修改原有组件的行为。当然还有很多很多更加复杂的定制。</p>
<p>所以有赞云是一个平台,提供了定制能力,并且把定制能力市场化,开发者开发各种各样的<strong>工具型App</strong>,实现了各种奇思妙想的功能,然后发布到有赞云的应用市场;商家浏览应用市场,发现某个或者几个应用的功能和自己的需求很匹配,然后购买应用,快速又低成本的完成了自己的店铺的定制。除此之外,我们还提供<strong>行业解决方案定制</strong>,如果你足够有能力,你可以定制整个行业的方案,比如有赞除了微商城还有有赞教育,你可以定制一套有赞教育,这是不可思议的;大客户往往希望能够私有定制,因为大客户的定制往往很特殊,不能推广,所以大客户找开发者可以开发<strong>自用型App</strong>,最大程度的完成自己的需求。</p>
<h4>1.2.1 工具型应用</h4>
<p><img src="/img/bVbtUrP" alt="clipboard.png" title="clipboard.png"></p>
<p>这里有两个角色两种资源,开发者和商家,店铺和应用。</p>
<p>开发者开发完App上架应用市场,商家购买应用,购买后会产生商家店铺和应用的授权关系,此时店铺可以使用应用的功能,比如<strong>某应用实现了国际地址,那么该店铺的买家在下单过程中选择地址时可以选择全球地址了。</strong></p>
<h4>1.2.2 自用型应用</h4>
<p>自用型顾名思义是自己使用的,此时商家可以自己找开发者为自己开发一个应用来实现自己店铺的定制,该应用的所有权也属于商家。</p>
<h4>1.2.3 行业解决方案</h4>
<p>这是一整套方案,前面提到,你可以开发一个行业的软件,比如打车、外卖等等,和工具型应用一样需要上架应用市场,然后商家购买。</p>
<h4>1.2.4 有赞云的影响</h4>
<p><strong>将会全面助力商家成功,大大提升商家成功发展,给商家更多可能性;</strong></p>
<p><strong>给开发者提供平台,拓展开发者的收入,提升开发能力,拓展影响力。</strong></p>
<h2>二、什么是多语言</h2>
<h3>2.1 应用</h3>
<p>什么是多语言,多语言应用在什么地方,离不开一个载体,那就是应用。</p>
<p>应用是开发者开发定制功能的实体,它包含代码、控制台、组件(mysql、redis等等)、权限、业务配置等等。</p>
<p>同时这个应用是部署在有赞云提供的容器里。</p>
<p>那么为什么要部署在有赞云提供的容器里:</p>
<ol>
<li>如果部署在私有机房和外部的云,很难保证公网的稳定和延时;</li>
<li>我们需要保证应用的质量,防止违规违法现象出现;</li>
<li>部署在有赞云的容器里,和有赞核心应用更近,可以通过RPC的方式进行调用;</li>
<li>这种方式可以监控整个链路,出问题容易排查;</li>
<li>可靠性、稳定性、安全性有保障,这是对商家的保障。</li>
</ol>
<h3>2.2 多语言</h3>
<p>我们希望通过这种模式形成一种生态,支持各种语言进行开发的开发者,所以我们也上线了开发者社区,方便开发者学习、交流、更好地与有赞云沟通、与商家沟通。</p>
<p>多语言,此语言非彼语言,不是国家化支持多个国家语言的意思哈。</p>
<p>多语言是指我们提供一些规范、协议、框架、基础设施等等来支持开发者进行如Java、Php、Python、NodeJs等等语言的应用开发,并且我们的调用。</p>
<p><img src="/img/bVbtUrS" alt="clipboard.png" title="clipboard.png"></p>
<p>如上图所示,多语言包含很多模块,会着重挑几块讲一下,事实上每一块内容都可以写好几篇文章。</p>
<h2>三、如何实现多语言</h2>
<p>如何实现多语言,这是一个好问题,也是一个难题,至少做有赞云是如此,这是一个全新的模式,虽然多语言调用有多种解决方案,但是对于有赞云这种模式来说没有前路可参考。</p>
<p>一开始可能还不知道如何上手,思维导图是个很好的工具,能够帮助梳理多语言该做哪些事情,如上图所示,当然下面细分的内容还有很多。</p>
<h3>3.1 远程调用</h3>
<p>我们先来看远程调用,毕竟这个是主链路,没有这个能力,我们的扩展能力就无法实现。</p>
<p>相信大部分Java开发同学都在用dubbo或者spring cloud体系,但是这些目前基本能够解决Java应用之间的RPC调用,作为一个公司来说,我们可以去限定大家统一用某些技术栈,统一模型。但是有赞云的用户是开发者,在有赞云里部署的应用是各个语言开发的。</p>
<h4>3.1.1 技术选型</h4>
<p>首先我们需要保证远程调用时低延时的,支持多种开发语言,开发者不用感知服务的注册、发现;需要用监控和链路追踪能力;有赞云需要在开发者不感知或者不影响开发者实际开发体验的情况下去添加功能、完善链路并持续升级。</p>
<p>Service Mesh的理念非常符合我们的场景,接下来看看我们的多语言实践。</p>
<h4>3.1.2 内部RPC</h4>
<p>相信大部分公司的RPC框架使用了Dubbo,在Dubbo的基础上进行二次开发来满足自己公司的场景,有赞也是如此。</p>
<p><img src="/img/bVbtUrW" alt="clipboard.png" title="clipboard.png"></p>
<p>这张图相信大家都很熟悉,有consumer、provider、registry、monitor,但是这里有几个问题:</p>
<ol>
<li>Consumer和Provider需要Java应用;</li>
<li>Consumer和Provider的行为耦合在业务应用里;</li>
<li>架构上对跨机房、跨网络的调用没做支持。</li>
</ol>
<p>有赞公司内部对Dubbo进行了二次开发来支持前面提到的几个问题,对于Java应用来说,基本上和图中的架构类似。所以可以理解为有赞原本Java应用之间通过Dubbo来做RPC(这是现状),然后现在要实现<strong>跨网络并且调用多种语言</strong>的应用(目标)。</p>
<p>当然有赞内部本身就演进过对特定语言的调用方案。</p>
<h4>3.1.3 部署架构</h4>
<p><img src="/img/bVbtUr2" alt="clipboard.png" title="clipboard.png"></p>
<p>如图,有赞内部核心域和有赞开发者定制域属于两个独立的网络,两个网络之间不能进行相互访问。</p>
<p>上图中的Side Car是完全由有赞自主研发的组件,取名叫Tether,他完成了服务的请求转发,与有赞监控、日志对接,实现了对服务化调用的监控和报警,并将服务发现、负载均衡、后端服务的优雅下线等等,全部都下沉到Tether层处理,所以可以理解为Tether就是Service Mesh中的Side Car。</p>
<h4>3.1.3 Mesh实践</h4>
<p>从上图中会发现,我们在核心域调用App的过程中没有采用Dubbo直连的方式进行RPC行为,而是通过了Side car进行转发。</p>
<p><img src="/img/bVbdAL3" alt="clipboard.png" title="clipboard.png"></p>
<p>Service Mesh架构图,如果将前面的部署架构图两边的网络域换成两个服务器,会发现和Service Mesh图类似。</p>
<p>Service Mesh的理念是将服务的注册、发现、服务治理、服务调用等等能力作为基础设施,让应用不用去感知业务逻辑之外的内容,这也是云原生的一部分。</p>
<p>因为如果让应用去做这些事情,很多事情变得不可控,比如服务协议版本不一致、出现Bug时不能统一升级。而现在有赞云的这种微服务化的架构,仅需静默升级Sidecar即可,无需任何业务开发者的参与和协同,极大的提升了整体架构的灵活性和功能迭代可控性。</p>
<p>前面提到,目前有赞核心域使用的Dubbo RPC协议与Java语言特性耦合严重,不适合于跨语言调用的场景。基于此,有赞云定制域中,我们设计了基于HTTP1.1和HTTP/2协议的可拓展的RPC协议,用于跨语言调用的场景。其中HTTP/2协议由于其标准化、异步性、高性能、语义清晰等特性。</p>
<h4>3.1.4 调用流程</h4>
<p>讲了这么多,给大家一个比较清晰的调用流程图来理解一下。</p>
<p><img src="/img/bVbtUsb" alt="clipboard.png" title="clipboard.png"></p>
<p>举个例子,比如开发者实现了一个<strong>价格计算扩展点</strong>,当我们的核心业务逻辑进行到计算价格时:</p>
<ol>
<li>价格中心通过Dubbo调用Bep(扩展点网关),这样做的好处时内部应用本身使用Dubbo框架,无须改造,就按正常Dubbo服务处理就行;</li>
<li>
<p>Bep直连Side car,当Bep收到请求后进行协议转换,然后调用Side Car;</p>
<ul>
<li>如果对方应用是Java应用,那么还是按照原有的Dubbo协议进行调用,前文提到过,有赞内部已经做了一些Dubbo支持开发,这套方案早就存在,所以这是最低成本,对于Java开发者来说也是非常熟悉Dubbo,这种方式对于有赞和开发者来说比较合适。</li>
<li>如果对方应用不是Java应用,Bep会将Dubbo协议转换为Http协议调用Side Car。</li>
</ul>
</li>
<li>
<p>Side Car通过网络规则调用到有赞云开发者定制域机房的Side Car,这里有个问题,有赞云定制域机房的Side Car如何知道该调用那个App呢?</p>
<ul>
<li>首先定制域里是一个K8S的集群,当App发布的时候,通过K8S提供的能力进行服务注册;</li>
<li>有赞云引入了Istio的Pilot,并进行了一定的改造来进行容器中应用的服务发现;</li>
<li>还有一点就是前文提到,App和店铺之间有授权关系才能调用,所以当核心域发起Dubbo请求时系统知道是哪个店铺的操作,知道了店铺就知道是哪个App。</li>
</ul>
</li>
</ol>
<p>所以我们提供的多语言方案并不是一刀切,而是因地制宜,根据实际情况去做RPC的多语言方案,针对Java应用,我们使用有赞已有的技术能力放到有赞云中来实施;对于其他类型的应用,我们提供通用的Http1.1/2.0来拓展RPC协议,其中HTTP/2协议由于其标准化、异步性、高性能、语义清晰等特性能够满足我们的场景。</p>
<h4>3.1.5 RPC监控</h4>
<p><img src="/img/bVbtUsq" alt="clipboard.png" title="clipboard.png"></p>
<p>传统Dubbo监控一般会在应用依赖的Jar包里去做数据上报,也就是应用来上报监控数据,在做多语言时这样会有一些问题:</p>
<ol>
<li>每种语言框架都得去做上报,当上报协议发生变化时就得去更新各个框架,并且推动应用去升级;</li>
<li>应用本身做数据上报,如果这个逻辑出现问题,可能导致业务出错,应用异常;</li>
<li>框架层会占用太多应用资源;</li>
<li>多种语言得适配;</li>
<li>监控会随着业务的发展,底层系统的升级,经常性的变更。</li>
</ol>
<p>针对这几个问题,我们将Tracing、Metrics、Logging能力下层到Side Car,屏蔽多语言,通过这种方式,我们可以让开发者无感知的去升级底层能力,升级监控;应用层只需要关注服务实现;当一种新的语言加入时,我们的监控收集不需要做任何改造。</p>
<h3>3.2 应用框架</h3>
<p>讲了远程调用,那么应用如何去实现服务呢?我们希望给开发者提供极致的开发体验,而不是让开发者去感知太多协议层的内容,如果不管这些我们完全可以在有赞云文档中说明我们的协议细节,然后让开发者去理解协议去实现协议,但是这样体验太差了,可能开发了好几天还在调试协议,而没有进入能够真正产生价值的业务代码,这是完全没必要浪费的时间。</p>
<p>这就需要有个应用框架来屏蔽这个麻烦。</p>
<h3>3.2.1 扩展点调用</h3>
<p><img src="/img/bVbtUsw" alt="clipboard.png" title="clipboard.png"></p>
<p>不管是Java语言还是Php或者其他语言,有赞云都会提供应用框架,但是在设计之初这是一个艰难的抉择,提供应用框架意味着我们的成本会很高,开发成本和维护成本,但是我们开发的协议我们自己最清楚,出现问题有赞云的开发同学也最了解,从长期来看对于有赞云和开发者来说都是非常有益了,尤其是对于开发者,大大降低了开发者的开发成本以及和有赞云技术支持的沟通成本。</p>
<p>从上图可以看到,当Side Car调用应用时,框架层会解析协议,将协议还原,通过必要的一些校验,转换为当前语言可读的对象或者实体,通过规则路由调用到具体的扩展点实现。</p>
<p>一目了然,开发者就关注扩展点实现就行,在扩展点实现上,针对各个语言设计了各个语言的方式来进行声明和实现,比如Java需要采用注解、Php采用Facade等等。</p>
<h3>3.2.2 日志</h3>
<p>对于所有语言来说,日志是开发和运行过程中最需要的组件,日志是一个相对稳定的组件,经过这么多年的发展,已经很明确的能够定义日志内容包含哪些信息,同时日志也是应用主动调用去打印的过程,所以我们将日志上报能力封装在了框架里,因为他离业务更近。</p>
<p><img src="/img/bVbtUsO" alt="clipboard.png" title="clipboard.png"></p>
<p>天网,有赞云应用的日志平台,所有开发者的应用日志都会打印上传到天网进行计算和存储;</p>
<p>所以针对日志,天网提供了统一的日志协议和端口,应用只需要按照规范封装日志格式,就可以将日志打印到天网,应用的日志并不是存在在服务器本地文件上;</p>
<h3>3.2.2.1 Java应用</h3>
<p><img src="/img/bVbtUsP" alt="clipboard.png" title="clipboard.png"></p>
<pre><code class="java"> import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
...........................
..............................
private static final Logger logger = LoggerFactory.getLogger(Xxxxx.class);
............................
...............................
logger.info("test log");</code></pre>
<p>在应用框架层,已经封装好了日志协议和上报天网的逻辑,开发者只需要像平常使用Slf4j一样打印日志就行。</p>
<p>那么在实现上我们使用了Slf4j的规范,通过SPI实现了天网的日志管道。</p>
<h3>3.2.2.2 Php应用</h3>
<p><img src="/img/bVbtUsQ" alt="clipboard.png" title="clipboard.png"></p>
<p>Php框架使用Monolog,在Monolog里增加了日志处理器SkynetProcessor来封装日志协议,通过SocketHandler来打印日志到Rsyslog上。</p>
<h3>3.3 其他</h3>
<p>回到一开始多语言的思维导图,要支持多语言,其实要做很多事情。</p>
<p>我们在做这些事情的原则就是尽量让开发者少感知,保证整体系统的健壮性和扩展能力。</p>
<p>在做这些事情时最麻烦的可能就是升级,当我们发布一个新功能或者升级了协议后,推动所有应用去升级是一个漫长的周期,此时我们的底层可能得做无数的兼容逻辑,这样会导致系统无法往前发展,同时开发者也会很反感,频繁的去手动指定版本并去发布(此时没有修改一行业务代码、而且可能还得改造代码)。</p>
<p>关注其他的一些模块,本期暂时就先略过了。</p>
<h2>四、规划与展望</h2>
<p>规划的目标是希望提升开发者的体验,助力商家成功。</p>
<p>所以会开放更多扩展能力,真正做到全流程每个细节点都可以定制。</p>
<p>在开发体验上,需要实现WebIDE,方便开发调试,甚至让开发者不用感知项目,Faas也可能是一条很好的路。</p>
<p>在多语言上,尽量得做到统一底层,而不是新增一种语言给我们带来浩大的工作量。</p>
<p>同时乐见各路开发者的奇思妙想,开发出各种非常有意思、有价值的应用。</p>
<p>如果您想了解关于有赞云的更多信息,可以关注“有赞云”公众号,我们会第一时间发布最新消息哟~</p>
<p><img src="/img/bVbD0Z9" alt="有赞云公众号banner的副本.jpeg" title="有赞云公众号banner的副本.jpeg"></p>
8.5万个开发者,正在“改装”有赞
https://segmentfault.com/a/1190000021212644
2019-12-06T10:12:01+08:00
2019-12-06T10:12:01+08:00
有赞技术
https://segmentfault.com/u/youzantech
1
<p>11月27日,是有赞7周年纪念日。也是这一天,有赞云开发者大赛正式落下帷幕,冠亚季军终于揭晓。这场跑了近6个月的“技术马拉松”,跑出了诸多直指电商行业经营痛点的技术解决方案。而这背后,有赞的技术开发生态也在逐渐成熟。在有赞云的基础上,8.5万家开发者正在热火朝天地“改装”有赞。</p>
<p><strong>25个应用被商家热抢,只因戳中这些痛点</strong></p>
<p>本次开发者大赛入围决赛的25个应用在有赞云应用市场上架不到一个月,就被2000多个有赞商家热情抢购。之所以受到欢迎,是因为开发者的应用均戳中了商家经营痛点。例如: </p>
<p><strong>批发之痛。</strong>本次大赛冠军——上海业梓科技有限公司公司开发的“批发助手”,解决了B2B商家想通过有赞做批发的痛点。商家可在微信端批量出售商品、设置批发价、预收货款。</p>
<p><img src="/img/bVbA3NA" alt="1.png" title="1.png"></p>
<p><strong>在线选座之痛。</strong>亚军深圳市前海乐业技术有限公司开发的“选座通”,支持影剧院观众在微信端选择场次和座位。义乌最大的剧院“义乌文化广场剧院”接入选座通后,很多剧院场次提前2个月就销售一空,微信端订单数增长33%,营业额增长50%。</p>
<p><img src="/img/bVbA3Nz" alt="2.png" title="2.png"></p>
<p><strong>美妆转化率之痛。</strong>季军北京美到家科技有限公司抓住了美妆商家的痛点——因为线上渠道无法了解用户肤质,所以不能精准推荐产品。其参赛的“AI智能测肤解决方案”,只需要用户按要求上传一张面部照片,即可分析出用户的毛孔粗细度、脸颊肤色、鼻部黑头、额部水油分布等多维度肤质状况,并根据用户的不同肤质状况,推荐不同的商品,提高用户留存率和购买转化率。</p>
<p><img src="/img/bVbA3NB" alt="3.png" title="3.png"></p>
<p>跨境美妆商家“鲸小铺”是第一批使用智能测肤的商家,使用10天,用户就已经破万,留存率达30%,通过测肤推荐的商品,商品详情页链接转化率高达23.27%。 </p>
<p>此外,帮助商家在线开具电子发票的“云票儿发票<strong>”,</strong>一经上线就被餐饮连锁“呷哺呷哺”等商家接入,极大减少了手工电子票审核成本,成功实现降费增效。帮助商家实现按时段、按SKU、按购买件数花式打折的“折多多”,在接入某知名品牌服饰店铺后,单日销量暴增300%。 </p>
<p>“聚合第三方开发者的力量,帮助解决商家的个性化需求,这是有赞云的初衷。这次开发者大赛,通过有限时间的集中开发和竞争压力,把开发者的积极性全部激发出来了。”有赞CTO崔玉松表示。 </p>
<p><strong>8.5万开发者如何“改装”有赞?</strong></p>
<p>截至2019三季度,有赞云上的开发者总数已达8.5万家。当这些开发者同时在有赞云上做技术开发,意味着什么? </p>
<p>意味着有赞要随时准备好自己的核心产品架构被“改装”。 </p>
<p>商品管理、订单管理、用户管理、会员卡、优惠券、营销、积分……只要商家对某一经营场景有个性化需求,开发者就可以对有赞原来的标准流程进行改写。就像已经搭好的积木,要拆卸、修改其中一层。如果没有强大的底层架构和服务保障,积木就会垮塌。 </p>
<p>以西装定制场景为例,开发者杭州敏行网络科技有限公司需要对有赞标准的商品详情页进行改写,以支持商家在后台进行自主设置,例如支持顾客选择是否绣字,甚至精确到绣什么字体、什么颜色。如果原来的产品架构不够灵活、扩展性不强,是无法支持如此细节的修改的。 </p>
<p>“我们不能把软件写‘死’,比如就支持商家自定义40个维度,这样可扩展性太低了,我们要支持商家实现无限维度的自定义编辑,假如他们需要200个维度,我们就能承载200个维度。”有赞云前端开发工程师熊(花名)告诉记者。 </p>
<p>“有赞云提供了强大的基础能力做支撑,开发者就不需要关心基础设施的建设,不用关注系统稳定性、安全性等问题,可以把更多的精力投注在业务和开发本身,能快速将想法实现并商业化。”敏行创始人梁孝炜这样说道。 </p>
<p><strong>小应用,大客户</strong></p>
<p>你可能想象不到,一个开发者上架的小应用,很有可能就被有赞的大商家应用到具体经营场景中。</p>
<p>自有赞云应用市场在今年5月份改版上线以来,开发者已上架了600多个应用,覆盖21个类目,每月GMV增长300%,包括安踏、必胜客、呷哺呷哺、曹操出行、好想你、幸福西饼、肯德基、上海家化等在内的知名品牌都购买了不同场景的应用,实现店铺个性化定制。 </p>
<p><img src="/img/bVbA3NC" alt="4.png" title="4.png"></p>
<p>“在有赞云基础上,未来有赞满足商家需求的速度将提高10倍以上。”崔玉松这样表示。而这,也是有赞商家数量和规模同步狂飙的背景下,有赞要“轻装奔跑”的必选之路。</p>
活动预告 | 有赞移动技术沙龙
https://segmentfault.com/a/1190000021067762
2019-11-20T17:30:59+08:00
2019-11-20T17:30:59+08:00
有赞技术
https://segmentfault.com/u/youzantech
0
<p>有赞移动技术团队将于2019年12月7日, 举办“有赞移动技术沙龙”。</p>
<p>质量与效率是移动技术发展中永恒的主题,移动基础设施建设在其中起着不可忽视的作用。</p>
<p>有赞作为电商零售领域新兴的独角兽,随着近些年来业务的快速发展,移动领域功能越来越复杂,迭代速度越来越快。我们迫切需要一套好用的基建体系来保障和管理开发过程中的打包发布,权限审批,功能组件,效率工具,自动化测试,CICD,线上监控,数据统计等等方面。</p>
<p>本次移动技术沙龙聚焦质量与效率,通过分享有赞过去几年中在移动基础设施建设方面的探索与创新尝试,希望能够抛砖引玉,和大家一起探讨移动技术的未来发展之路。</p>
<p><img src="/img/bVbAyN7" alt="WechatIMG365.jpeg" title="WechatIMG365.jpeg">!</p>
<p><a href="https://link.segmentfault.com/?enc=LJ%2FC9OYqejzexKeiqvUWzA%3D%3D.2dNtxNn5Df%2BHp4C%2BIFgkZExCBWEUnaYzp8zaLWyUPGZWBS5hi6novPAQEzoTp%2BqKfZRxRXgYUErJTCIKx%2BFzLA%3D%3D" rel="nofollow">点击报名</a></p>
<p>关注“有赞coder”,获取更多技术干货。</p>
有赞前端质量保障体系
https://segmentfault.com/a/1190000019706355
2019-07-09T11:25:49+08:00
2019-07-09T11:25:49+08:00
有赞技术
https://segmentfault.com/u/youzantech
19
<h2>前言</h2>
<p>最近一年多一直在做前端的一些测试,从小程序到店铺装修,基本都是纯前端的工作,刚开始从后端测试转为前端测试的时候,对前端东西茫然无感,而且团队内没有人做过纯前端的测试工作,只能一边踩坑一边总结经验,然后将容易出现问题的点形成体系、不断总结摸索,最终形成了目前的一套前端测试解决方案。在此,将有赞的前端质量保障体系进行总结,希望和大家一起交流。</p>
<p>先来全局看下有赞前端的技术架构和针对每个不同的层次,主要做了哪些保障质量的事情:</p>
<p><img src="/img/bVbuTvQ?w=1518&h=848" alt="clipboard.png" title="clipboard.png"></p>
<p><img src="/img/bVbuQFG?w=1578&h=596" alt="clipboard.png" title="clipboard.png"></p>
<p>有赞的 Node 技术架构分为业务层、基础框架层、通用组件和基础服务层,我们日常比较关注的是基础框架、通用组件和业务层代码。Node 业务层做了两件事情,一是提供页面渲染的 client 层,用于和 C 端用户交互,包括样式、行为 js 等;二是提供数据服务的 server 层,用于组装后台提供的各种接口,完成面向 C 端的接口封装。</p>
<p>对于每个不同的层,我们都做了一些事情来保障质量,包括:</p>
<ul>
<li>针对整个业务层的 UI 自动化、核心接口|页面拨测;</li>
<li>针对 client 层的 sentry 报警;</li>
<li>针对 server 层的接口测试、业务报警;</li>
<li>针对基础框架和通用组件的单元测试;</li>
<li>针对通用组件变更的版本变更报警;</li>
<li>针对线上发布的流程规范、用例维护等。</li>
</ul>
<p>下面就来分别讲一下这几个维度的质量保障工作。</p>
<h2>一、UI自动化</h2>
<p>很多人会认为,UI 自动化维护成本高、性价比低,但是为什么在有赞的前端质量保证体系中放在了最前面呢? </p>
<p>前端重用户交互,单纯的接口测试、单元测试不能真实反映用户的操作路径,并且从以往的经验中总结得出,因为各种不可控因素导致的发布 A 功能而 B 功能无法使用,特别是核心简单场景的不可用时有出现,所以每次发布一个应用前,都会将此应用提供的核心功能执行一遍,那随着业务的不断积累,需要回归的测试场景也越来越多,导致回归的工作量巨大。为了降低人力成本,我们亟需通过自动化手段释放劳动力,所以将核心流程回归的 UI 自动化提到了最核心地位。</p>
<p>当然,UI 自动化的最大痛点确实是维护成本,为降低维护成本,我们将页面分为组件维度、页面维度,并提供统一的包来处理公用组件、特殊页面的通用逻辑,封装通用方法等,例如初始化浏览器信息、环境选择、登录、多网点切换、点击、输入、获取元素内容等等,业务回归用例只需要关注自己的用例操作步骤即可。</p>
<h3>1.框架选择</h3>
<p>-- puppeteer<sup>[1]</sup>,它是由 Chrome 维护的 Node 库,基于 DevTools 协议来驱动 chrome 或者 chromium 浏览器运行,支持 headless 和 non-headless 两种方式。官网提供了非常丰富的文档,简单易学。 </p>
<p><em>UI 自动化框架有很多种,包括 selenium、phantom;对比后发现 puppeteer 比较轻量,只需要增加一个 npm 包即可使用;它是基于事件驱动的方式,比 selenium 的等待轮询更稳当、性能更佳;另外,它是 chrome 原生支持,能提供所有 chrome 支持的 api,同时我们的业务场景只需要覆盖 chrome,所以它是最好的选择。</em></p>
<p>-- mocha<sup>[2]</sup> + mochawesome<sup>[3]</sup>,mocha 是比较主流的测试框架,支持 beforeEach、before、afterEach、after 等钩子函数,assert 断言,测试套件,用例编排等。 <br>mochawesome 是 mocha 测试框架的第三方插件,支持生成漂亮的 html/css 报告。</p>
<p><em>js 测试框架同样有很多可以选择,mocha、ava、Jtest 等等,选择 mocha 是因为它更灵活,很多配置可以结合第三方库,比如 report 就是结合了 mochawesome 来生成好看的 html 报告;断言可以用 powser-assert 替代。</em></p>
<h3>2.脚本编写</h3>
<ul>
<li>
<p>封装基础库</p>
<ul>
<li>封装 pc 端、h5 端浏览器的初始化过程</li>
<li>封装 pc 端、h5 端登录统一处理</li>
<li>封装页面模型和组件模型</li>
<li>封装上传组件、日期组件、select 组件等的统一操作方法</li>
<li>封装 input、click、hover、tap、scrollTo、hover、isElementShow、isElementExist、getElementVariable 等方法</li>
<li>提供根据 “html 标签>>页面文字” 形式获取页面元素及操作方法的统一支持</li>
<li>封装 baseTest,增加用例开始、结束后的统一操作</li>
<li>封装 assert,增加断言日志记录</li>
</ul>
</li>
<li>
<p>业务用例</p>
<ul>
<li>安装基础库</li>
<li>编排业务用例</li>
</ul>
</li>
</ul>
<h3>3.执行逻辑</h3>
<ul>
<li>
<p>分环境执行</p>
<ul><li>增加预上线环境代码变更触发、线上环境自动执行</li></ul>
</li>
<li>
<p>监控源码变更</p>
<ul>
<li>增加 gitlab webhook,监控开发源码合并 master 时自动在预上线环境执行</li>
<li>增加 gitlab webhook,监控测试用例变更时自动在生产环境执行</li>
</ul>
</li>
<li>
<p>每日定时执行</p>
<ul><li>增加 crontab,每日定时执行线上环境</li></ul>
</li>
</ul>
<p><img src="/img/bVbuQFL?w=1558&h=878" alt="clipboard.png" title="clipboard.png"></p>
<p><img src="/img/bVbuQFV?w=1930&h=1260" alt="clipboard.png" title="clipboard.png"></p>
<p><img src="/img/bVbuQF6?w=2012&h=1266" alt="clipboard.png" title="clipboard.png"></p>
<p><img src="/img/bVbuQF9?w=2548&h=1374" alt="clipboard.png" title="clipboard.png"><br>QF6)</p>
<h2>二、接口测试</h2>
<p>接口测试主要针对于 Node 的 server 层,根据我们的开发规范,Node 不做复杂的业务逻辑,但是需要将服务化应用提供 dubbo 接口进行一次转换,或将多个 dubbo 接口组合起来,提供一个可供 h5/小程序渲染数据的 http 接口,转化过程就带来了各种数据的获取、组合、转换,形成了新的端到端接口。这个时候单单靠服务化接口的自动化已经不能保障对上层接口的全覆盖,所以我们针对 Node 接口也进行自动化测试。为了使用测试内部统一的测试框架,我们通过 java 去请求 Node 提供的 http 接口,那么当用例都写好之后,该如何评判接口测试的质量?是否完全覆盖了全部业务逻辑呢?此时就需要一个行之有效的方法来获取到测试的覆盖情况,以检查有哪些场景是接口测试中未覆盖的,做到更好的查漏补缺。</p>
<p>istanbul<sup>[4]</sup> 是业界比较易用的 js 覆盖率工具,它利用模块加载的钩子计算语句、行、方法和分支覆盖率,以便在执行测试用例时透明的增加覆盖率。它支持所有类型的 js 覆盖率,包括单元测试、服务端功能测试以及浏览器测试。 </p>
<p>但是,我们的接口用例写在 Java 代码中,通过 Http 请求的方式到达 Node 服务器,非 js 单测,也非浏览器功能测试,如何才能获取到 Node 接口的覆盖率呢?</p>
<p>解决办法是增加 cover 参数:--handle-sigint,通过增加 --handle-sigint 参数启动服务,当服务接收到一个 SIGINT 信号(linux 中 SIGINT 关联了 Ctrl+C),会通知 istanbul 生成覆盖率。这个命令非常适合我们,并且因此形成了我们接口覆盖率的一个模型:</p>
<pre><code>1. istanbule --handle-sigint 启动服务
2. 执行测试用例
3. 发送 SIGINT结束istanbule,得到覆盖率</code></pre>
<p>最终,解决了我们的 Node 接口覆盖率问题,并通过 jenkins 持续集成来自动构建</p>
<p><img src="/img/bVbuQGc?w=1602&h=122" alt="clipboard.png" title="clipboard.png"></p>
<p><img src="/img/bVbuQGd?w=1190&h=256" alt="clipboard.png" title="clipboard.png"></p>
<p><img src="/img/bVbuQGg?w=2542&h=1296" alt="clipboard.png" title="clipboard.png"></p>
<p>当然,在获取覆盖率的时候有需求文件是不需要统计的,可以通过在根路径下增加 .istanbule.yml 文件的方式,来排除或者指定需要统计覆盖率的文件</p>
<pre><code>verbose: false
instrumentation:
root: .
extensions:
- .js
default-excludes: true
excludes:['**/common/**','**/app/constants/**','**/lib/**']
embed-source: false
variable: __coverage__
compact: true
preserve-comments: false
complete-copy: false
save-baseline: false
baseline-file: ./coverage/coverage-baseline.json
include-all-sources: false
include-pid: false
es-modules: false
reporting:
print: summary
reports:
- lcov
dir: ./coverage
watermarks:
statements: [50, 80]
lines: [50, 80]
functions: [50, 80]
branches: [50, 80]
report-config:
clover: {file: clover.xml}
cobertura: {file: cobertura-coverage.xml}
json: {file: coverage-final.json}
json-summary: {file: coverage-summary.json}
lcovonly: {file: lcov.info}
teamcity: {file: null, blockName: Code Coverage Summary}
text: {file: null, maxCols: 0}
text-lcov: {file: lcov.info}
text-summary: {file: null}
hooks:
hook-run-in-context: false
post-require-hook: null
handle-sigint: false
check:
global:
statements: 0
lines: 0
branches: 0
functions: 0
excludes: []
each:
statements: 0
lines: 0
branches: 0
functions: 0
excludes: []</code></pre>
<h2>三、单元测试</h2>
<p>单元测试在测试分层中处于金字塔最底层的位置,单元测试做的比较到位的情况下,能过滤掉大部分的问题,并且提早发现 bug,也可以降低 bug 成本。推行一段时间的单测后发现,在有赞的 Node 框架中,业务层的 server 端只做接口组装,client 端面向浏览器,都不太适合做单元测试,所以我们只针对基础框架和通用组件进行单测,保障基础服务可以通过单测排除大部分的问题。比如基础框架中店铺通用信息服务,单测检查店铺信息获取;比如页面级商品组件,单测检查商品组件渲染的 html 是否和原来一致。</p>
<p>单测方案试行了两个框架:</p>
<ul>
<li>Jest<sup>[5]</sup>
</li>
<li>ava<sup>[6]</sup>
</li>
</ul>
<p>比较推荐的是 Jest 方案,它支持 Matchers 方式断言;支持 Snapshot Testing,可测试组件类代码渲染的 html 是否正确;支持多种 mock,包括 mock 方法实现、mock 定时器、mock 依赖的 module 等;支持 istanbule,可以方便的获取覆盖率。</p>
<p>总之,前端的单测方案也越来越成熟,需要前端开发人员更加关注 js 单测,将 bug 扼杀在摇篮中。</p>
<h2>四、基础库变更报警</h2>
<p>上面我们已经对基础服务和基础组件进行了单元测试,但是单测也不能完全保证基础库的变更完全没有问题,伴随着业务层引入新版本的基础库,bug 会进一步带入到业务层,最终影响 C 端用户的正常使用。那如何保障每次业务层引入新版本的基础库之后能做到全面的回归?如何让业务测试同学对基础库变更更加敏感呢?针对这种情况,我们着手做了一个基础库版本变更的小工具。实现思路如下:</p>
<pre><code>1. 对比一次 master 代码的提交或 merge 请求,判断 package.json 中是否有特定基础库版本变更
2. 将对应基础库的前后两个版本的代码对比发送到测试负责人
3. 根据 changelog 判断此次回归的用例范围
4. 增加 gitlab webhook,只有合并到合并发布分支或者 master 分支的代码才触发检查</code></pre>
<p>这个小工具的引入能及时通知测试人员针对什么需求改动了基础组件,以及这次基础组件的升级主要影响了哪些方面,这样能避免相对黑盒的测试。</p>
<p>第一版实现了最简功能,后续再深挖需求,可以做到前端代码变更的精准测试。</p>
<p><img src="/img/bVbuQGp?w=1664&h=406" alt="clipboard.png" title="clipboard.png"></p>
<h2>五、sentry报警</h2>
<p>在刚接触前端测试的时候,js 的报错没有任何追踪,对于排查问题和定位问题有很大困扰。因此我们着手引入了 sentry 报警监控,用于监控线上环境 js 的运行情况。</p>
<p>sentry<sup>[7]</sup> 是一款开源的错误追踪工具,它可以帮助开发者实时监控和修复崩溃。</p>
<p>开始我们接入的方式比较简单粗暴,直接全局接入,带来的问题是报警信息非常庞大,全局上报后 info、warn 信息都会打出来。</p>
<p>更改后,使用 sentry 的姿势是:</p>
<ul>
<li>
<p>sentry 的全局信息上报,并进行筛选</p>
<ul>
<li>错误类型: TypeError 或者 ReferenceError</li>
<li>错误出现用户 > 1k</li>
<li>错误出现在 js 文件中</li>
<li>出现错误的店铺 > 2家</li>
</ul>
</li>
<li>增加核心业务异常流程的主动上报</li>
</ul>
<p>最终将筛选后的错误信息通过邮件的形式发送给告警接收人,在固定的时间集中修复。</p>
<p><img src="/img/bVbuTxu?w=2482&h=1374" alt="clipboard.png" title="clipboard.png"></p>
<p><img src="/img/bVbuTxE?w=2526&h=1366" alt="clipboard.png" title="clipboard.png"></p>
<h2>六、业务报警</h2>
<p>除了 sentry 监控报警,Node 接口层的业务报警同样是必不可少的一部分,它能及时发现 Node 提供的接口中存在的业务异常。这部分是开发和运维同学做的,包括在 Node 框架底层接入日志系统;在业务层正确的上报错误级别、错误内容、错误堆栈信息;在日志系统增加合理的告警策略,超过阈值之后短信、电话告警,以便于及时发现问题、排查问题。 </p>
<p>业务告警是最能快速反应生产环境问题的一环,如果某次发布之后发生告警,我们第一时间选择回滚,以保证线上的稳定性。</p>
<h2>七、约定规范</h2>
<p>除了上述的一些测试和告警手段之外,我们也做了一些流程规范、用例维护等基础建设,包括:</p>
<ul>
<li>
<p>发布规范</p>
<ul>
<li>多个日常分支合并发布</li>
<li>限制发布时间</li>
<li>规范发布流程</li>
<li>整理自测核心检查要点</li>
</ul>
</li>
<li>
<p>基线用例库</p>
<ul>
<li>不同业务 P0 核心用例定期更新</li>
<li>项目用例定期更新到业务回归用例库</li>
<li>线上问题场景及时更新到回归用例库</li>
</ul>
</li>
</ul>
<p>目前有赞的前端测试套路基本就是这样,当然有些平时的努力没有完全展开,例如接口测试中增加返回值结构体对比;增加线上接口或页面的拨测<sup>[8]</sup>;给开发进行自测用例设计培训等等。也还有很多新功能探索中,如接入流量对比引擎,将线上流量导到预上线环境,在代码上线前进行对比测试;增加UI自动化的截图对比;探索小程序的UI自动化等等。</p>
<p>参考链接:</p>
<ul>
<li>[1] <a href="https://link.segmentfault.com/?enc=if%2F3NeItdCSOZPCI2fE1Aw%3D%3D.TwZ7cndasisvroZTwJ9AXN4Gl9PV%2FO086yjfG%2B1r4yknAXEya6sQSAqW%2Be6zbKfG" rel="nofollow">https://github.com/GoogleChro...</a>
</li>
<li>[2] <a href="https://link.segmentfault.com/?enc=KphmleHFqqnyyUGuZvxPPQ%3D%3D.%2FvI0ktLvIDDB4VrMOgASfHZwVcUA%2B5RRjg3T%2B%2FWmcSP9mwMnZ%2BvnScWtVk7NRWt2" rel="nofollow">https://www.npmjs.com/package...</a>
</li>
<li>[3] <a href="https://link.segmentfault.com/?enc=5ftCEvejbtyH87HoMM%2FhpQ%3D%3D.mFI4etcwYwsky121abIHhgVrRFbvYiHxuwIPzcoTlQPFxSUhsOOWrkTdqL%2FfOcFI" rel="nofollow">https://www.npmjs.com/package...</a>
</li>
<li>[4] <a href="https://link.segmentfault.com/?enc=bcdFrBHSW4w3q8bakozy7w%3D%3D.o1D0VLJE%2BG8iqx%2BAP5N63td7Tl2x04G8zPlg2TzTZ6x51dONK7PZCW%2F8tUC%2BLavs" rel="nofollow">https://github.com/gotwarlost...</a>
</li>
<li>[5] <a href="https://link.segmentfault.com/?enc=DwCnnxfmikvyDZxS%2BrleWg%3D%3D.MuK%2B7J%2BdfT47fsMjmzf24ArXy6%2F5YsfRTgYeaCjcFwZWnMk9mZ7AcHngVUcHsqRE" rel="nofollow">https://github.com/facebook/jest</a>
</li>
<li>[6] <a href="https://link.segmentfault.com/?enc=fQRuCH26sEhqHIZ%2FG34Fxg%3D%3D.2NJ8iFcje8opumotDRCx7auceQPQFNTrSgN4Lw41pEk%3D" rel="nofollow">https://github.com/avajs/ava</a>
</li>
<li>[7] <a href="https://link.segmentfault.com/?enc=%2BJ5OF3m%2F0zj7oXYiI7Mtiw%3D%3D.hVid5KIa07Ynl%2FGp76kEHWnt7ZJlEQOnHa8pUBXVU%2FE%3D" rel="nofollow">https://docs.sentry.io</a>
</li>
<li>[8] <a href="https://link.segmentfault.com/?enc=o7JxZizTepO6nD76MLfoBA%3D%3D.sXlk6qHEc3uN7iAbooBdzGpho2qxsF%2Fdnbbpm4EFM7zcwSJ2VYozMQSlv0wN5xO%2Fjybp8dnOQPG7rkqIqJfWXg%3D%3D" rel="nofollow">https://tech.youzan.com/youza...</a>
</li>
</ul>
<p><img src="/img/remote/1460000019706692" alt="" title=""></p>
有赞零售小票打印图片二值化方案
https://segmentfault.com/a/1190000019693627
2019-07-08T10:36:25+08:00
2019-07-08T10:36:25+08:00
有赞技术
https://segmentfault.com/u/youzantech
6
<blockquote>作者:王前</blockquote>
<h3>一、背景</h3>
<p>小票打印是零售商家的基础功能,在小票信息中,必然会存在一些相关店铺的信息。比如,logo 、店铺二维码等。对于商家来说,上传 logo 及店铺二维码时,基本都是彩图,但是小票打印机基本都是只支持黑白二值图打印。为了商家的服务体验,我们没有对商家上传的图片进行要求,商家可以根据实际情况上传自己的个性化图片,因此就需要我们对商家的图片进行二值图处理后进行打印。</p>
<p>这次文章是对<a href="https://link.segmentfault.com/?enc=L%2F7KmUHLcJnMgZzzb6ZeQw%3D%3D.EJqzhwz8B9PJQDPcTZrlkyvR%2BJ7kDVlpEJWQwhIMN9Lag%2FFJVbZyg865AEpsIfbw" rel="nofollow">《有赞零售小票打印跨平台解决方案》</a>中的图片的二值图处理部分的解决方案的说明。</p>
<h3>二、图像二值化处理流程</h3>
<p>图像二值化就是将图像上的像素点的灰度值(如果是 RGB 彩图,则需要先将像素点的 RGB 转成灰度值)设置为 0 或 255 ,也就是将整个图像呈现出明显的黑白效果的过程。</p>
<p>其中划分 0 和 255 的中间阈值 T 是二值化的核心,一个准确的阈值可以得到一个较好的二值图。</p>
<p><img src="/img/bVbuNhJ?w=810&h=455" alt="clipboard.png" title="clipboard.png"></p>
<p>二值化整体流程图:</p>
<p><img src="/img/bVbuNhS?w=221&h=557" alt="clipboard.png" title="clipboard.png"></p>
<p>从上面的流程图中可以看出,获取灰度图和计算阈值 T 是二值化的核心步骤。</p>
<h3>三、以前的解决方案</h3>
<p>以前使用的方案是,首先将图像处理成灰度图,然后再基于 OTSU(大津法、最大类间方差法)算法求出分割 0 和 255 的阈值 T ,然后根据 T 对灰度值进行二值化处理,得到二值图像。</p>
<p>我们的所有算法都有使用 C 语言实现,目的为了跨平台通用性。</p>
<p>流程图:</p>
<p><img src="/img/bVbuNlx?w=221&h=630" alt="clipboard.png" title="clipboard.png"></p>
<p><strong>灰度算法:</strong></p>
<p>对于 RGB 彩色转灰度,有一个很著名的公式:</p>
<p><center>Gray = R <em> 0.299 + G </em> 0.587 + B * 0.114</center></p>
<p>这种算法叫做 Luminosity,也就是亮度算法。目前这种算法是最常用的,里面的三个数据都是经验值或者说是实验值。由来请参见 <a href="https://link.segmentfault.com/?enc=m7bUU0Rn2WY5bTTPVa8R8w%3D%3D.0siSxS2Q1Jn2UwJ4JP7%2FIGdGP5MfC%2BcHz7eiWv%2BKR4uQr9hFOU2gjL2OEODxpr%2FC" rel="nofollow">wiki</a> 。</p>
<p>然而实际应用时,大家都希望避免低速的浮点运算,为了提高效率将上述公式变形成整数运算和移位运算。这里将采用移位运算公式:<br><center>Gray = (R <em> 38 + G </em> 75 + B * 15) >> 7</center></p>
<p>如果想了解具体由来,可以自行了解,这里不做过多解释。</p>
<p>具体实现算法如下:</p>
<pre><code class="c">/**
获取灰度图
@param bit_map 图像像素数组地址( ARGB 格式)
@param width 图像宽
@param height 图像高
@return 灰度图像素数组地址
*/
int * gray_image(int *bit_map, int width, int height) {
double pixel_total = width * height; // 像素总数
if (pixel_total == 0) return NULL;
// 灰度像素点存储
int *gray_pixels = (int *)malloc(pixel_total * sizeof(int));
memset(gray_pixels, 0, pixel_total * sizeof(int));
int *p = bit_map;
for (u_int i = 0; i < pixel_total; i++, p++) {
// 分离三原色及透明度
u_char alpha = ((*p & 0xFF000000) >> 24);
u_char red = ((*p & 0xFF0000) >> 16);
u_char green = ((*p & 0x00FF00) >> 8);
u_char blue = (*p & 0x0000FF);
u_char gray = (red*38 + green*75 + blue*15) >> 7;
if (alpha == 0 && gray == 0) {
gray = 0xFF;
}
gray_pixels[i] = gray;
}
return gray_pixels;
}</code></pre>
<p>该算法中,主要是为了统一各平台的兼容性,入参要求传入 ARGB 格式的 bitmap 。为什么使用 int 而不是用 unsigned int,是因为在 java 中没有无符号数据类型,使用 int 具有通用性。</p>
<p><strong>OTSU 算法:</strong></p>
<p>OTSU 算法也称最大类间差法,有时也称之为大津算法,由大津于 1979 年提出,被认为是图像分割中阈值选取的最佳算法,计算简单,不受图像亮度和对比度的影响,因此在数字图像处理上得到了广泛的应用。它是按图像的灰度特性,将图像分成背景和前景两部分。因方差是灰度分布均匀性的一种度量,背景和前景之间的类间方差越大,说明构成图像的两部分的差别越大,当部分前景错分为背景或部分背景错分为前景都会导致两部分差别变小。因此,使类间方差最大的分割意味着错分概率最小。</p>
<p>原理:</p>
<p>对于图像 I ( x , y ) ,前景(即目标)和背景的分割阈值记作 T ,属于前景的像素点数占整幅图像的比例记为 ω0 ,其平均灰度 μ0 ;背景像素点数占整幅图像的比例为 ω1 ,其平均灰度为 μ1 。图像的总平均灰度记为 μ ,类间方差记为 g 。</p>
<p>假设图像的背景较暗,并且图像的大小为 M × N ,图像中像素的灰度值小于阈值 T 的像素个数记作 N0 ,像素灰度大于等于阈值 T 的像素个数记作 N1 ,则有:</p>
<pre><code>ω0 = N0 / M × N (1)
ω1 = N1 / M × N (2)
N0 + N1 = M × N (3)
ω0 + ω1 = 1 (4)
μ = ω0 * μ0 + ω1 * μ1 (5)
g = ω0 * (μ0 - μ)^2 + ω1 * (μ1 - μ)^2 (6)</code></pre>
<p>将式 (5) 代入式 (6) ,得到等价公式:</p>
<pre><code>g = ω0 * ω1 * (μ0 - μ1)^2 (7)
</code></pre>
<p>公式 (7) 就是类间方差计算公式,采用遍历的方法得到使类间方差 g 最大的阈值 T ,即为所求。</p>
<p>因为 OTSU 算法求阈值的基础是灰度直方图数据,所以使用 OTSU 算法的前两步:</p>
<p>1、获取原图像的灰度图</p>
<p>2、灰度直方统计</p>
<p>这里需要多次对图像进行遍历处理,如果每一步都单独处理,会增加不少遍历次数,所以这里做了步骤整合处理,减少不必要的遍历,提高性能。</p>
<p>具体实现算法如下:</p>
<pre><code class="c">/**
OTSU 算法获取二值图
@param bit_map 图像像素数组地址( ARGB 格式)
@param width 图像宽
@param height 图像高
@param T 存储计算得出的阈值
@return 二值图像素数组地址
*/
int * binary_image_with_otsu_threshold_alg(int *bit_map, int width, int height, int *T) {
double pixel_total = width * height; // 像素总数
if (pixel_total == 0) return NULL;
unsigned long sum1 = 0; // 总灰度值
unsigned long sumB = 0; // 背景总灰度值
double wB = 0.0; // 背景像素点比例
double wF = 0.0; // 前景像素点比例
double mB = 0.0; // 背景平均灰度值
double mF = 0.0; // 前景平均灰度值
double max_g = 0.0; // 最大类间方差
double g = 0.0; // 类间方差
u_char threshold = 0; // 阈值
double histogram[256] = {0}; // 灰度直方图,下标是灰度值,保存内容是灰度值对应的像素点总数
// 获取灰度直方图和总灰度
int *gray_pixels = (int *)malloc(pixel_total * sizeof(int));
memset(gray_pixels, 0, pixel_total * sizeof(int));
int *p = bit_map;
for (u_int i = 0; i < pixel_total; i++, p++) {
// 分离三原色及透明度
u_char alpha = ((*p & 0xFF000000) >> 24);
u_char red = ((*p & 0xFF0000) >> 16);
u_char green = ((*p & 0x00FF00) >> 8);
u_char blue = (*p & 0x0000FF);
u_char gray = (red*38 + green*75 + blue*15) >> 7;
if (alpha == 0 && gray == 0) {
gray = 0xFF;
}
gray_pixels[i] = gray;
// 计算灰度直方图分布,Histogram 数组下标是灰度值,保存内容是灰度值对应像素点数
histogram[gray]++;
sum1 += gray;
}
// OTSU 算法
for (u_int i = 0; i < 256; i++)
{
wB = wB + histogram[i]; // 这里不算比例,减少运算,不会影响求 T
wF = pixel_total - wB;
if (wB == 0 || wF == 0)
{
continue;
}
sumB = sumB + i * histogram[i];
mB = sumB / wB;
mF = (sum1 - sumB) / wF;
g = wB * wF * (mB - mF) * (mB - mF);
if (g >= max_g)
{
threshold = i;
max_g = g;
}
}
for (u_int i = 0; i < pixel_total; i++) {
gray_pixels[i] = gray_pixels[i] <= threshold ? 0xFF000000:0xFFFFFFFF;
}
if (T) {
*T = threshold; // OTSU 算法阈值
}
return gray_pixels;
}</code></pre>
<p>测试执行时间数据:</p>
<p>iPhone 6: imageSize:260, 260; OTSU 使用时间:0.005254; 5次异步处理使用时间:0.029240</p>
<p>iPhone 6: imageSize:620, 284; OTSU 使用时间:0.029476; 5次异步处理使用时间:0.050313</p>
<p>iPhone 6: imageSize:2560,1440; OTSU 使用时间:0.200595; 5次异步处理使用时间:0.684509</p>
<p>经过测试,该算法处理时间都是毫秒级别的,而且一般我们的图片大小都不大,所以性能没问题。</p>
<p>处理后的效果:</p>
<p>经过 OTSU 算法处理过的二值图基本可以满足大部分商家 logo 。</p>
<p><img src="/img/bVbuNlz?w=1208&h=434" alt="clipboard.png" title="clipboard.png"></p>
<p>不过对于实际场景来说还有些不足,比如商家的 logo 颜色差别比较大的时候,可能打印出来的图片会和商家意愿的不太一致。比如如下 logo :</p>
<p><img src="/img/bVbuNlA?w=1192&h=446" alt="clipboard.png" title="clipboard.png"></p>
<p>上面 logo 对于算法来说,黄色的灰度值比阈值小,所以二值化变成了白色,但是对于商家来说,logo 上红色框内信息缺失了一部分,可能不能满足商家需求。</p>
<ul><li>
<p>存在问题总结</p>
<ul>
<li>算法单一,对于不同图片处理结果可能与预期不一致</li>
<li>每次打印都对图片进行处理,没有缓存机制</li>
</ul>
</li></ul>
<h3>四、新的解决方案</h3>
<p>针对以前使用的方案中存在的两个问题,新的方案中加入了具体优化。</p>
<h4>4.1 问题一 (算法单一,对于不同图片处理结果可能与预期不一致)</h4>
<p>加入多算法求阈值 T ,然后根据每个算法得出的二值图和原图的灰度图进行对比,相识度比较高的作为最优阈值 T 。</p>
<p>流程图:</p>
<p><img src="/img/bVbuNmd?w=608&h=833" alt="clipboard.png" title="clipboard.png"></p>
<p>整个流程当中会并行三个算法进行二值图处理,同时获取二值图的图片指纹 hashCode ,与原图图片指纹 hashCode 进行对比,获取与原图最为相近的二值图作为最优二值图。</p>
<p>其中的OTSU算法上面已经说明,这次针对平均灰度算法和双峰平均值算法进行解析。</p>
<p><strong>平均灰度算法:</strong></p>
<p>平均灰度算法其实很简单,就是将图片灰度处理后,求一下灰度图的平均灰度。假设总灰度为 sum ,总像素点为 pixel_total ,则阈值 T :</p>
<p><center> T = sum / pixel_total </center></p>
<p>具体实现算法如下:</p>
<pre><code class="c">/**
平均灰度算法获取二值图
@param bit_map 图像像素数组地址( ARGB 格式)
@param width 图像宽
@param height 图像高
@param T 存储计算得出的阈值
@return 二值图像素数组地址
*/
int * binary_image_with_average_gray_threshold_alg(int *bit_map, int width, int height, int *T) {
double pixel_total = width * height; // 像素总数
if (pixel_total == 0) return NULL;
unsigned long sum = 0; // 总灰度
u_char threshold = 0; // 阈值
int *gray_pixels = (int *)malloc(pixel_total * sizeof(int));
memset(gray_pixels, 0, pixel_total * sizeof(int));
int *p = bit_map;
for (u_int i = 0; i < pixel_total; i++, p++) {
// 分离三原色及透明度
u_char alpha = ((*p & 0xFF000000) >> 24);
u_char red = ((*p & 0xFF0000) >> 16);
u_char green = ((*p & 0x00FF00) >> 8);
u_char blue = (*p & 0x0000FF);
u_char gray = (red*38 + green*75 + blue*15) >> 7;
if (alpha == 0 && gray == 0) {
gray = 0xFF;
}
gray_pixels[i] = gray;
sum += gray;
}
// 计算平均灰度
threshold = sum / pixel_total;
for (u_int i = 0; i < pixel_total; i++) {
gray_pixels[i] = gray_pixels[i] <= threshold ? 0xFF000000:0xFFFFFFFF;
}
if (T) {
*T = threshold;
}
return gray_pixels;
}</code></pre>
<p><strong>双峰平均值算法:</strong></p>
<p>此方法实用于具有明显双峰直方图的图像,其寻找双峰的谷底作为阈值,但是该方法不一定能获得阈值,对于那些具有平坦的直方图或单峰图像,该方法不合适。该函数的实现是一个迭代的过程,每次处理前对直方图数据进行判断,看其是否已经是一个双峰的直方图,如果不是,则对直方图数据进行半径为 1(窗口大小为 3 )的平滑,如果迭代了一定的数量比如 1000 次后仍未获得一个双峰的直方图,则函数执行失败,如成功获得,则最终阈值取双峰的平均值作为阈值。因此实现该算法应有的步骤:</p>
<p>1、获取原图像的灰度图</p>
<p>2、灰度直方统计</p>
<p>3、平滑直方图</p>
<p>4、求双峰平均值作为阈值 T </p>
<p>其中第三步平滑直方图的过程是一个迭代过程,具体流程图:</p>
<p><img src="/img/bVbuNmq?w=310&h=657" alt="clipboard.png" title="clipboard.png"></p>
<p>具体实现算法如下:</p>
<pre><code class="c">// 判断是否是双峰直方图
int is_double_peak(double *histogram) {
// 判断直方图是存在双峰
int peak_count = 0;
for (int i = 1; i < 255; i++) {
if (histogram[i - 1] < histogram[i] && histogram[i + 1] < histogram[i]) {
peak_count++;
if (peak_count > 2) return 0;
}
}
return peak_count == 2;
}
/**
双峰平均值算法获取二值图
@param bit_map 图像像素数组地址( ARGB 格式)
@param width 图像宽
@param height 图像高
@param T 存储计算得出的阈值
@return 二值图像素数组地址
*/
int * binary_image_with_average_peak_threshold_alg(int *bit_map, int width, int height, int *T) {
double pixel_total = width * height; // 像素总数
if (pixel_total == 0) return NULL;
// 灰度直方图,下标是灰度值,保存内容是灰度值对应的像素点总数
double histogram1[256] = {0};
double histogram2[256] = {0}; // 求均值的过程会破坏前面的数据,因此需要两份数据
u_char threshold = 0; // 阈值
// 获取灰度直方图
int *gray_pixels = (int *)malloc(pixel_total * sizeof(int));
memset(gray_pixels, 0, pixel_total * sizeof(int));
int *p = bit_map;
for (u_int i = 0; i < pixel_total; i++, p++) {
// 分离三原色及透明度
u_char alpha = ((*p & 0xFF000000) >> 24);
u_char red = ((*p & 0xFF0000) >> 16);
u_char green = ((*p & 0x00FF00) >> 8);
u_char blue = (*p & 0x0000FF);
u_char gray = (red*38 + green*75 + blue*15) >> 7;
if (alpha == 0 && gray == 0) {
gray = 0xFF;
}
gray_pixels[i] = gray;
// 计算灰度直方图分布,Histogram数组下标是灰度值,保存内容是灰度值对应像素点数
histogram1[gray]++;
histogram2[gray]++;
}
// 如果不是双峰,则通过三点求均值来平滑直方图
int times = 0;
while (!is_double_peak(histogram2)) {
times++;
if (times > 1000) { // 这里使用 1000 次,考虑到过多次循环可能会存在性能问题
return NULL; // 似乎直方图无法平滑为双峰的,返回错误代码
}
histogram2[0] = (histogram1[0] + histogram1[0] + histogram1[1]) / 3; // 第一点
for (int i = 1; i < 255; i++) {
histogram2[i] = (histogram1[i - 1] + histogram1[i] + histogram1[i + 1]) / 3; // 中间的点
}
histogram2[255] = (histogram1[254] + histogram1[255] + histogram1[255]) / 3; // 最后一点
memcpy(histogram1, histogram2, 256 * sizeof(double)); // 备份数据,为下一次迭代做准备
}
// 求阈值T
int peak[2] = {0};
for (int i = 1, y = 0; i < 255; i++) {
if (histogram2[i - 1] < histogram2[i] && histogram2[i + 1] < histogram2[i]) {
peak[y++] = i;
}
}
threshold = (peak[0] + peak[1]) / 2;
for (u_int i = 0; i < pixel_total; i++) {
gray_pixels[i] = gray_pixels[i] <= threshold ? 0xFF000000:0xFFFFFFFF;
}
if (T) {
*T = threshold;
}
return gray_pixels;
}</code></pre>
<p>测试执行时间数据:</p>
<p>iPhone 6: imageSize:260, 260; average_peak 使用时间:0.035254</p>
<p>iPhone 6: imageSize:800, 800; average_peak 使用时间:0.101282</p>
<p>经过测试,该算法在图片比较小的时候,还算可以,如果图片比较大会存在较大性能消耗,而且根据图片色彩分布不同也可能造成多次循环平滑,也会影响性能。对于 logo 来说,我们处理的时候做了压缩,一般都是很大,所以处理时间也在可以接受返回内,而且进行处理和对比时,是在异步线程中,不会影响主流程。</p>
<p><strong>图片指纹 hashCode :</strong></p>
<p>图片指纹 hashCode ,可以理解为图片的唯一标识。一个简单的图片指纹生成步骤需要以下几步:</p>
<p>1、图片缩小尺寸一般缩小到 8 * 8 ,一共 64 个像素点。</p>
<p>2、将缩小的图片转换成灰度图。</p>
<p>3、计算灰度图的平均灰度。</p>
<p>4、灰度图的每个像素点的灰度与平均灰度比较。大于平均灰度,记为 1 ;小于平均灰度,记为 0。</p>
<p>5、计算哈希值,第 4 步的结果可以构成一个 64 为的整数,这个 64 位的整数就是该图片的指纹 hashCode 。</p>
<p>6、对比不同图片生成的指纹 hashCode ,计算两个 hashCode 的 64 位中有多少位不一样,即“汉明距离”,差异越少图片约相近。</p>
<p>由于使用该算法生成的图片指纹具有差异性比较大,因为对于 logo 来说处理后的二值图压缩到 8 <em> 8 后的相似性很大,所以使用 8 </em> 8 生成 hashCode 误差性比较大,经过试验,确实如此。所以,在此基础上,对上述中的 1、5、6 步进行了改良,改良后的这几步为:</p>
<p>1、图片缩小尺寸可自定义(必须是整数),但是最小像素数要为 64 个,也就是 width * height >= 64 。建议为 64 的倍数,为了减少误差。</p>
<p>5、哈希值不是一个 64 位的整数,而是一个存储 64 位整数的数组,数组的长度就是像素点数量对 64 的倍数(取最大的整数倍)。这样每生成一个 64 位的 hashCode 就加入到数组中,该数组就是图片指纹。</p>
<p>6、对比不同指纹时,遍历数组,对每一个 64 为整数进行对比不同位数,最终结果为,每一个 64 位整数的不同位数总和。</p>
<p>在我们对商家 logo 测试实践中发现,采用 128 * 128 的压缩,可以得到比较满意的结果。</p>
<p>最优算法为 OTSU 算法例子:</p>
<p><img src="/img/bVbuNmB?w=1644&h=518" alt="clipboard.png" title="clipboard.png"></p>
<p>最优算法为平均灰度算法例子:</p>
<p><img src="/img/bVbuNmC?w=1646&h=534" alt="clipboard.png" title="clipboard.png"></p>
<p>最优算法为双峰均值算法例子:</p>
<p><img src="/img/bVbuNmP?w=1682&h=462" alt="clipboard.png" title="clipboard.png"></p>
<p>实际实验中,发现真是中选择双峰均值的概率比较低,也就是绝大多数的 logo 都是在 OTSU 和平均灰度两个算法之间选择的。所以,后续可以考虑加入选择统计,如果双峰均值概率确实特别低且结果与其他两种差不多大,那就可以去掉该方法。</p>
<h4>4.2 问题二 (每次打印都对图片进行处理,没有缓存机制)</h4>
<p>加入缓存机制,一般店铺的 logo 和店铺二维码都是固定的,很少会更换,所以,在进入店铺和修改店铺二维码时可以对其进行预处理,并缓存处理后的图片打印指令,后续打印时直接拿缓存使用即可。</p>
<p>由于缓存的内容是处理后的打印指令字符串,所以使用 NSUserDefaults 进行存储。</p>
<p><img src="/img/bVbuNmT?w=796&h=188" alt="clipboard.png" title="clipboard.png"></p>
<p>缓存策略流程图:</p>
<p><img src="/img/bVbuNm1?w=371&h=643" alt="clipboard.png" title="clipboard.png"></p>
<p>这里面为什么只有修改店铺二维码,而没有店铺 logo ?因为在我们 app 中,logo 是不可修改的,只能在 pc 后台修改,而登录店铺后,本地就可以直接拿到店铺信息;店铺二维码是在小票模板设置里自行上传的图片,所以商家在 app 中是可以自行修改店铺二维码的。</p>
<p>打印时图片处理流程图:</p>
<p><img src="/img/bVbuNm8?w=291&h=800" alt="clipboard.png" title="clipboard.png"></p>
<p>在新流程中,如果缓存中没有查到,则会走老方案去处理图片。原因是考虑到,这时候是商家实时打印小票,如果选用新方案处理,恐怕时间会加长,使用户体验降低。老方案已经在线上跑了很久,所以使用老的方案处理也问题不大。</p>
<h3>五、未来期望与规划</h3>
<p>在后续规划中加入几点优化:</p>
<ul>
<li>添加新流程处理统计,对商家 logo 和店铺二维码处理后的最优算法进行统计,为后续优化做数据准备。</li>
<li>处理后的结果如果商家不满意,商家可以自主选择处理二值图的阈值 T ,达到满意为止。</li>
<li>图片更新不及时问题,PC 后台修改了图片无法及时更新本地缓存。</li>
<li>图片精细化处理,针对二维码可以采用分块处理算法。</li>
</ul>
<p>其中第二点,商家自主选择阈值 T ,预览效果如下:</p>
<p><img src="/img/bVbuNnc?w=339&h=303" alt="clipboard.png" title="clipboard.png"></p>
<h2>参考链接</h2>
<ul>
<li>
<a href="https://link.segmentfault.com/?enc=sfWL1ha6X6oAetnJxFS7aA%3D%3D.c0Gwyco5wYc73cOoD83dCD0hi49zK3U8aqw9DcpzL3xE29UQXezFt297I7Vt9WrWmdedZS7HHzr9qx%2FERo27%2Fg%3D%3D" rel="nofollow">灰度计算公式</a>, by zyl910</li>
<li>
<a href="https://link.segmentfault.com/?enc=hcV2voFGKYMgW6zsX52zPQ%3D%3D.t%2FR39looqebA14MgziDoQRd5GBL9V92jwihsz46HUy1igUPoRffHykOdmDiEEu0NZJDsc6MT4VgVqnAluNulAlA5DkmY%2BbcEf5auMea3Cpo%3D" rel="nofollow">大津算法</a>, by wiki</li>
<li>
<a href="https://link.segmentfault.com/?enc=DxZm%2FMx95N8%2Ft0OND%2FP9dQ%3D%3D.kpZbJAL9a%2BYMMck1LU8o825r%2BEu4vPXPJonBK3JLgRVvPgEwvyLmkubi2QIoFzdJhRpJ64Js6vWZqw%2B%2F9EnWoA%3D%3D" rel="nofollow">图像处理算法</a>, by laviewpbt</li>
</ul>
<p>![图片上传中...]</p>
有赞云如何支持多语言
https://segmentfault.com/a/1190000019483954
2019-06-14T18:01:35+08:00
2019-06-14T18:01:35+08:00
有赞技术
https://segmentfault.com/u/youzantech
6
<h2>一、背景</h2>
<p>有赞是Saas公司,向商家提供了全方位的软件服务,支撑商家进行采购、店铺、商品、营销、订单、物流等等管理服务。</p>
<p>在这个软件服务里,能够满足大部分的商家,为商家保驾护航。</p>
<p>Saas形成是追求共性的过程,Saas生态化是求同存异的过程,所以当我们能够满足大部分客户需求时,我们得考虑大客户的个性化需求场景。</p>
<h3>1.1 客户分析</h3>
<p>上面讲到我们要求同存异,我们要满足个性化需求,这里简单讲解一下大客户的价值,下面就不区分优势和劣势了,都放一起:</p>
<ul>
<li>
<p>中小客户</p>
<ul>
<li>企业规模小,付费能力相对弱一些;</li>
<li>企业周期短,无法很好的保证续费;</li>
<li>大部分停留使用产品基本能力,说明软件可替代性强,难形成粘性;</li>
<li>但是数量庞大;</li>
<li>获客成本相对低一些;</li>
</ul>
</li>
<li>
<p>大客户</p>
<ul>
<li>企业规模大,付费能力强;</li>
<li>发展稳定,企业周期长,能够保证续费;</li>
<li>有利于形成标杆案例,用于推广;</li>
<li>只要能实现需求,对资金要求和价格不敏感;</li>
<li>定制需求多,一旦定制,替代成本高,粘性好;</li>
</ul>
</li>
</ul>
<p>简单罗列了一下,当然其他点还有很多,对比会发现,Saas会满足大部分客户的需求,尤其是中小客户,而且中小客户量可能会达到90%以上,但是中小客户续费能力弱会导致销售成本高,同时无法形成标杆,很难有行业影响力;大客户付费能力很强,只要能够满足需求,可能不太会对相对高的价格说不,但是基本上每个大客户都有定制需求,而且一旦达成合作,可以稳定的续费。</p>
<h3>1.2 有赞云是干嘛的</h3>
<p>前面简单的分析了一下中小客户和大客户,由于本篇的重点在于多语言,所以就不过多的讲解,实际上一些中小客户也有定制需求,希望自己的店铺标新立异,希望能够用低成本并且快速满足自己的需求,当成长为大客户时再有更多的需求。</p>
<p>Saas传统情况下是怎么做定制的呢?</p>
<pre><code class="java">//核心业务流程
............
...........
if (店铺Id.equeals(0023423)) {
//do custom logic
} else if (店铺Id.equeals(0056673)) {
//do custom logic
} else {
//do stardand logic
}
............
...........</code></pre>
<p>作为一个技术人员,可能会对上述代码很熟悉,客户是上帝,当客户提各种各样的需求时,如果不满足可能会导致客户远走他乡,如果去满足,那么我们的核心业务代码耦合性会很高,而且代码扩展性很差,定制代码越来越多,越来越约束业务的发展,导致系统出现瓶颈;当然上面的代码有点直白,一般情况下会运用规则引擎、会独立一个定制应用来处理恶心的业务,但是问题依旧;更难受的一点是客户要定制页面,难道在node层做业务判断吗?看到这里相信大家都会说不,这个系统将没法扩展,业务没办法大步向前走。</p>
<p>这个时候有赞云出现了,所以有赞云的初衷是解决客户的个性定制需求,让各种各样的客户都能够和有赞合作,有赞提供的软件可以应用于各行各业,覆盖所有场景。</p>
<p><img src="/img/bVbtUrO?w=1446&h=714" alt="clipboard.png" title="clipboard.png"></p>
<p>图相对抽象,但是更好理解。从图中的箭头可以看出,有赞云就是核心业务的扩展,我们的核心业务系统将交易流程输出到有赞云,开发者或者商家可以自己定制<strong>交易流程</strong>,如<strong>卖电影票</strong>的商家可以在下单流程中加一个选座的流程;同时在流程中的各个业务关键点输出<strong>扩展能力</strong>,让开发者可以去实现这个扩展能力,如<strong>价格计算</strong>,原来价格计算是核心系统做的事情,现在开发者可以自己写价格计算逻辑,这个价格计算的结果可能就是商品的实际成交价格;<strong>前端页面组件化</strong>,开发者可以定制自己的组件,修改原有组件的行为。当然还有很多很多更加复杂的定制。</p>
<p>所以有赞云是一个平台,提供了定制能力,并且把定制能力市场化,开发者开发各种各样的<strong>工具型App</strong>,实现了各种奇思妙想的功能,然后发布到有赞云的应用市场;商家浏览应用市场,发现某个或者几个应用的功能和自己的需求很匹配,然后购买应用,快速又低成本的完成了自己的店铺的定制。除此之外,我们还提供<strong>行业解决方案定制</strong>,如果你足够有能力,你可以定制整个行业的方案,比如有赞除了微商城还有有赞教育,你可以定制一套有赞教育,这是不可思议的;大客户往往希望能够私有定制,因为大客户的定制往往很特殊,不能推广,所以大客户找开发者可以开发<strong>自用型App</strong>,最大程度的完成自己的需求。</p>
<h4>1.2.1 工具型应用</h4>
<p><img src="/img/bVbtUrP?w=1060&h=472" alt="clipboard.png" title="clipboard.png"></p>
<p>这里有两个角色两种资源,开发者和商家,店铺和应用。</p>
<p>开发者开发完App上架应用市场,商家购买应用,购买后会产生商家店铺和应用的授权关系,此时店铺可以使用应用的功能,比如<strong>某应用实现了国际地址,那么该店铺的买家在下单过程中选择地址时可以选择全球地址了。</strong></p>
<h4>1.2.2 自用型应用</h4>
<p>自用型顾名思义是自己使用的,此时商家可以自己找开发者为自己开发一个应用来实现自己店铺的定制,该应用的所有权也属于商家。</p>
<h4>1.2.3 行业解决方案</h4>
<p>这是一整套方案,前面提到,你可以开发一个行业的软件,比如打车、外卖等等,和工具型应用一样需要上架应用市场,然后商家购买。</p>
<h4>1.2.4 有赞云的影响</h4>
<p><strong>将会全面助力商家成功,大大提升商家成功发展,给商家更多可能性;</strong></p>
<p><strong>给开发者提供平台,拓展开发者的收入,提升开发能力,拓展影响力。</strong></p>
<h2>二、什么是多语言</h2>
<h3>2.1 应用</h3>
<p>什么是多语言,多语言应用在什么地方,离不开一个载体,那就是应用。</p>
<p>应用是开发者开发定制功能的实体,它包含代码、控制台、组件(mysql、redis等等)、权限、业务配置等等。</p>
<p>同时这个应用是部署在有赞云提供的容器里。</p>
<p>那么为什么要部署在有赞云提供的容器里:</p>
<ol>
<li>如果部署在私有机房和外部的云,很难保证公网的稳定和延时;</li>
<li>我们需要保证应用的质量,防止违规违法现象出现;</li>
<li>部署在有赞云的容器里,和有赞核心应用更近,可以通过RPC的方式进行调用;</li>
<li>这种方式可以监控整个链路,出问题容易排查;</li>
<li>可靠性、稳定性、安全性有保障,这是对商家的保障。</li>
</ol>
<h3>2.2 多语言</h3>
<p>我们希望通过这种模式形成一种生态,支持各种语言进行开发的开发者,所以我们也上线了开发者社区,方便开发者学习、交流、更好地与有赞云沟通、与商家沟通。</p>
<p>多语言,此语言非彼语言,不是国家化支持多个国家语言的意思哈。</p>
<p>多语言是指我们提供一些规范、协议、框架、基础设施等等来支持开发者进行如Java、Php、Python、NodeJs等等语言的应用开发,并且我们的调用。</p>
<p><img src="/img/bVbtUrS?w=1484&h=460" alt="clipboard.png" title="clipboard.png"></p>
<p>如上图所示,多语言包含很多模块,会着重挑几块讲一下,事实上每一块内容都可以写好几篇文章。</p>
<h2>三、如何实现多语言</h2>
<p>如何实现多语言,这是一个好问题,也是一个难题,至少做有赞云是如此,这是一个全新的模式,虽然多语言调用有多种解决方案,但是对于有赞云这种模式来说没有前路可参考。</p>
<p>一开始可能还不知道如何上手,思维导图是个很好的工具,能够帮助梳理多语言该做哪些事情,如上图所示,当然下面细分的内容还有很多。</p>
<h3>3.1 远程调用</h3>
<p>我们先来看远程调用,毕竟这个是主链路,没有这个能力,我们的扩展能力就无法实现。</p>
<p>相信大部分Java开发同学都在用dubbo或者spring cloud体系,但是这些目前基本能够解决Java应用之间的RPC调用,作为一个公司来说,我们可以去限定大家统一用某些技术栈,统一模型。但是有赞云的用户是开发者,在有赞云里部署的应用是各个语言开发的。</p>
<h4>3.1.1 技术选型</h4>
<p>首先我们需要保证远程调用时低延时的,支持多种开发语言,开发者不用感知服务的注册、发现;需要用监控和链路追踪能力;有赞云需要在开发者不感知或者不影响开发者实际开发体验的情况下去添加功能、完善链路并持续升级。</p>
<p>Service Mesh的理念非常符合我们的场景,接下来看看我们的多语言实践。</p>
<h4>3.1.2 内部RPC</h4>
<p>相信大部分公司的RPC框架使用了Dubbo,在Dubbo的基础上进行二次开发来满足自己公司的场景,有赞也是如此。</p>
<p><img src="/img/bVbtUrW?w=1060&h=824" alt="clipboard.png" title="clipboard.png"></p>
<p>这张图相信大家都很熟悉,有consumer、provider、registry、monitor,但是这里有几个问题:</p>
<ol>
<li>Consumer和Provider需要Java应用;</li>
<li>Consumer和Provider的行为耦合在业务应用里;</li>
<li>架构上对跨机房、跨网络的调用没做支持。</li>
</ol>
<p>有赞公司内部对Dubbo进行了二次开发来支持前面提到的几个问题,对于Java应用来说,基本上和图中的架构类似。所以可以理解为有赞原本Java应用之间通过Dubbo来做RPC(这是现状),然后现在要实现<strong>跨网络并且调用多种语言</strong>的应用(目标)。</p>
<p>当然有赞内部本身就演进过对特定语言的调用方案。</p>
<h4>3.1.3 部署架构</h4>
<p><img src="/img/bVbtUr2?w=1552&h=496" alt="clipboard.png" title="clipboard.png"></p>
<p>如图,有赞内部核心域和有赞开发者定制域属于两个独立的网络,两个网络之间不能进行相互访问。</p>
<p>上图中的Side Car是完全由有赞自主研发的组件,取名叫Tether,他完成了服务的请求转发,与有赞监控、日志对接,实现了对服务化调用的监控和报警,并将服务发现、负载均衡、后端服务的优雅下线等等,全部都下沉到Tether层处理,所以可以理解为Tether就是Service Mesh中的Side Car。</p>
<h4>3.1.3 Mesh实践</h4>
<p>从上图中会发现,我们在核心域调用App的过程中没有采用Dubbo直连的方式进行RPC行为,而是通过了Side car进行转发。</p>
<p><img src="/img/bVbdAL3?w=661&h=421" alt="clipboard.png" title="clipboard.png"></p>
<p>Service Mesh架构图,如果将前面的部署架构图两边的网络域换成两个服务器,会发现和Service Mesh图类似。</p>
<p>Service Mesh的理念是将服务的注册、发现、服务治理、服务调用等等能力作为基础设施,让应用不用去感知业务逻辑之外的内容,这也是云原生的一部分。</p>
<p>因为如果让应用去做这些事情,很多事情变得不可控,比如服务协议版本不一致、出现Bug时不能统一升级。而现在有赞云的这种微服务化的架构,仅需静默升级Sidecar即可,无需任何业务开发者的参与和协同,极大的提升了整体架构的灵活性和功能迭代可控性。</p>
<p>前面提到,目前有赞核心域使用的Dubbo RPC协议与Java语言特性耦合严重,不适合于跨语言调用的场景。基于此,有赞云定制域中,我们设计了基于HTTP1.1和HTTP/2协议的可拓展的RPC协议,用于跨语言调用的场景。其中HTTP/2协议由于其标准化、异步性、高性能、语义清晰等特性。</p>
<h4>3.1.4 调用流程</h4>
<p>讲了这么多,给大家一个比较清晰的调用流程图来理解一下。</p>
<p><img src="/img/bVbtUsb?w=1780&h=636" alt="clipboard.png" title="clipboard.png"></p>
<p>举个例子,比如开发者实现了一个<strong>价格计算扩展点</strong>,当我们的核心业务逻辑进行到计算价格时:</p>
<ol>
<li>价格中心通过Dubbo调用Bep(扩展点网关),这样做的好处时内部应用本身使用Dubbo框架,无须改造,就按正常Dubbo服务处理就行;</li>
<li>
<p>Bep直连Side car,当Bep收到请求后进行协议转换,然后调用Side Car;</p>
<ul>
<li>如果对方应用是Java应用,那么还是按照原有的Dubbo协议进行调用,前文提到过,有赞内部已经做了一些Dubbo支持开发,这套方案早就存在,所以这是最低成本,对于Java开发者来说也是非常熟悉Dubbo,这种方式对于有赞和开发者来说比较合适。</li>
<li>如果对方应用不是Java应用,Bep会将Dubbo协议转换为Http协议调用Side Car。</li>
</ul>
</li>
<li>
<p>Side Car通过网络规则调用到有赞云开发者定制域机房的Side Car,这里有个问题,有赞云定制域机房的Side Car如何知道该调用那个App呢?</p>
<ul>
<li>首先定制域里是一个K8S的集群,当App发布的时候,通过K8S提供的能力进行服务注册;</li>
<li>有赞云引入了Istio的Pilot,并进行了一定的改造来进行容器中应用的服务发现;</li>
<li>还有一点就是前文提到,App和店铺之间有授权关系才能调用,所以当核心域发起Dubbo请求时系统知道是哪个店铺的操作,知道了店铺就知道是哪个App。</li>
</ul>
</li>
</ol>
<p>所以我们提供的多语言方案并不是一刀切,而是因地制宜,根据实际情况去做RPC的多语言方案,针对Java应用,我们使用有赞已有的技术能力放到有赞云中来实施;对于其他类型的应用,我们提供通用的Http1.1/2.0来拓展RPC协议,其中HTTP/2协议由于其标准化、异步性、高性能、语义清晰等特性能够满足我们的场景。</p>
<h4>3.1.5 RPC监控</h4>
<p><img src="/img/bVbtUsq?w=684&h=832" alt="clipboard.png" title="clipboard.png"></p>
<p>传统Dubbo监控一般会在应用依赖的Jar包里去做数据上报,也就是应用来上报监控数据,在做多语言时这样会有一些问题:</p>
<ol>
<li>每种语言框架都得去做上报,当上报协议发生变化时就得去更新各个框架,并且推动应用去升级;</li>
<li>应用本身做数据上报,如果这个逻辑出现问题,可能导致业务出错,应用异常;</li>
<li>框架层会占用太多应用资源;</li>
<li>多种语言得适配;</li>
<li>监控会随着业务的发展,底层系统的升级,经常性的变更。</li>
</ol>
<p>针对这几个问题,我们将Tracing、Metrics、Logging能力下层到Side Car,屏蔽多语言,通过这种方式,我们可以让开发者无感知的去升级底层能力,升级监控;应用层只需要关注服务实现;当一种新的语言加入时,我们的监控收集不需要做任何改造。</p>
<h3>3.2 应用框架</h3>
<p>讲了远程调用,那么应用如何去实现服务呢?我们希望给开发者提供极致的开发体验,而不是让开发者去感知太多协议层的内容,如果不管这些我们完全可以在有赞云文档中说明我们的协议细节,然后让开发者去理解协议去实现协议,但是这样体验太差了,可能开发了好几天还在调试协议,而没有进入能够真正产生价值的业务代码,这是完全没必要浪费的时间。</p>
<p>这就需要有个应用框架来屏蔽这个麻烦。</p>
<h3>3.2.1 扩展点调用</h3>
<p><img src="/img/bVbtUsw?w=486&h=840" alt="clipboard.png" title="clipboard.png"></p>
<p>不管是Java语言还是Php或者其他语言,有赞云都会提供应用框架,但是在设计之初这是一个艰难的抉择,提供应用框架意味着我们的成本会很高,开发成本和维护成本,但是我们开发的协议我们自己最清楚,出现问题有赞云的开发同学也最了解,从长期来看对于有赞云和开发者来说都是非常有益了,尤其是对于开发者,大大降低了开发者的开发成本以及和有赞云技术支持的沟通成本。</p>
<p>从上图可以看到,当Side Car调用应用时,框架层会解析协议,将协议还原,通过必要的一些校验,转换为当前语言可读的对象或者实体,通过规则路由调用到具体的扩展点实现。</p>
<p>一目了然,开发者就关注扩展点实现就行,在扩展点实现上,针对各个语言设计了各个语言的方式来进行声明和实现,比如Java需要采用注解、Php采用Facade等等。</p>
<h3>3.2.2 日志</h3>
<p>对于所有语言来说,日志是开发和运行过程中最需要的组件,日志是一个相对稳定的组件,经过这么多年的发展,已经很明确的能够定义日志内容包含哪些信息,同时日志也是应用主动调用去打印的过程,所以我们将日志上报能力封装在了框架里,因为他离业务更近。</p>
<p><img src="/img/bVbtUsO?w=910&h=596" alt="clipboard.png" title="clipboard.png"></p>
<p>天网,有赞云应用的日志平台,所有开发者的应用日志都会打印上传到天网进行计算和存储;</p>
<p>所以针对日志,天网提供了统一的日志协议和端口,应用只需要按照规范封装日志格式,就可以将日志打印到天网,应用的日志并不是存在在服务器本地文件上;</p>
<h3>3.2.2.1 Java应用</h3>
<p><img src="/img/bVbtUsP?w=1144&h=552" alt="clipboard.png" title="clipboard.png"></p>
<pre><code class="java"> import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
...........................
..............................
private static final Logger logger = LoggerFactory.getLogger(Xxxxx.class);
............................
...............................
logger.info("test log");</code></pre>
<p>在应用框架层,已经封装好了日志协议和上报天网的逻辑,开发者只需要像平常使用Slf4j一样打印日志就行。</p>
<p>那么在实现上我们使用了Slf4j的规范,通过SPI实现了天网的日志管道。</p>
<h3>3.2.2.2 Php应用</h3>
<p><img src="/img/bVbtUsQ?w=1192&h=552" alt="clipboard.png" title="clipboard.png"></p>
<p>Php框架使用Monolog,在Monolog里增加了日志处理器SkynetProcessor来封装日志协议,通过SocketHandler来打印日志到Rsyslog上。</p>
<h3>3.3 其他</h3>
<p>回到一开始多语言的思维导图,要支持多语言,其实要做很多事情。</p>
<p>我们在做这些事情的原则就是尽量让开发者少感知,保证整体系统的健壮性和扩展能力。</p>
<p>在做这些事情时最麻烦的可能就是升级,当我们发布一个新功能或者升级了协议后,推动所有应用去升级是一个漫长的周期,此时我们的底层可能得做无数的兼容逻辑,这样会导致系统无法往前发展,同时开发者也会很反感,频繁的去手动指定版本并去发布(此时没有修改一行业务代码、而且可能还得改造代码)。</p>
<p>关注其他的一些模块,本期暂时就先略过了。</p>
<h2>四、规划与展望</h2>
<p>规划的目标是希望提升开发者的体验,助力商家成功。</p>
<p>所以会开放更多扩展能力,真正做到全流程每个细节点都可以定制。</p>
<p>在开发体验上,需要实现WebIDE,方便开发调试,甚至让开发者不用感知项目,Faas也可能是一条很好的路。</p>
<p>在多语言上,尽量得做到统一底层,而不是新增一种语言给我们带来浩大的工作量。</p>
<p>同时乐见各路开发者的奇思妙想,开发出各种非常有意思、有价值的应用。基于此,有赞云在6月5日上线了有赞云开发者大赛,面向全国发出"英雄帖",一个优质应用可奖励40万,欢迎大家报名参加。<a href="https://link.segmentfault.com/?enc=niSDAPNZA3CIgOxe5EN5Zg%3D%3D.fVf3BXNqIdvuGmYbqrRc2Y9DZC4EKdZ4YiWus9Grp8bggfGj3Mepv4lBxHI2%2FakG" rel="nofollow">https://www.youzanyun.com/con...</a><br><img src="/img/bVbtUth?w=1800&h=636" alt="" title=""></p>
Vant 2.0 发布:持之以恒,不乱节奏
https://segmentfault.com/a/1190000019465448
2019-06-13T10:55:40+08:00
2019-06-13T10:55:40+08:00
有赞技术
https://segmentfault.com/u/youzantech
19
<blockquote>持之以恒,不乱节奏,对于长期作业实在至为重要。一旦节奏得以设定,其余的问题便可迎刃而解。 -- 村上春树</blockquote>
<p>维护组件库就像跑马拉松,开源只是从起点迈出第一步,困难的是持之以恒地跑下去。</p>
<p>自 2017 年开源以来,Vant 已经跑了两年多时间,未曾停歇。在 2018 年我们发布了 <a href="https://link.segmentfault.com/?enc=S42kLHLxtoL299b4Tz7jtg%3D%3D.xSzzCL6IezvOglN0H72weZhoxq3i%2B%2F136swtAMWGwKhvuym%2BxXRomhIZzw6vosr8" rel="nofollow">1.0 版本</a>和<a href="https://link.segmentfault.com/?enc=9Pn9%2BtYhkajs1vgo%2BVg4JQ%3D%3D.IKWIw8yiKcgY8qhw5MmbMc8IAJZmNps%2BmmmGiIFGlWCC4AYZz2pOpUSZnFMWiVya" rel="nofollow">小程序版</a>,并持续迭代了 100 多个小版本。</p>
<p>对于版本迭代,我们更倾向于<strong>小步快跑</strong>,保持每周更新一个版本的节奏,及时解决大家的问题和需求。但是写代码偶尔也需要一点"仪式感",因此我们集中开发了一个多月的时间,将社区中反馈较多的需求一网打尽,为大家带来本次发布的 <strong>Vant 2.0 版本</strong>。</p>
<h3>回顾</h3>
<p>在介绍 2.0 版本之前,先看一下我们到目前为止的成绩吧~</p>
<ul>
<li>发布 <strong>220</strong> 个版本</li>
<li>合并 <strong>2100</strong> 个 PR</li>
<li>处理 <strong>3000</strong> 个 issue</li>
<li>累计 <strong>18000</strong> 个 star</li>
<li>累计 <strong>1000000</strong> 下载量(npm & cnpm)</li>
</ul>
<p>上面是 <a href="https://link.segmentfault.com/?enc=QmuS5tLEw5s3wJDsdYmfTA%3D%3D.ctTH5TKojwDf6Sb%2FJNR5QHuQJZPgMZKTAFBF0tsu0Pk%3D" rel="nofollow">vant</a> 和 <a href="https://link.segmentfault.com/?enc=IEb3gqDv5luc1fdyAJNaVA%3D%3D.F%2FJc70TLSNIvdnAgj2wGfmgDaoO242dreU5pVu4qvazSWh7d9JA1XprJ8HD2B49m" rel="nofollow">vant-weapp</a> 两个仓库的合并数据。值得一提的是 Vant 的 issue 处理比例在 <strong>98%</strong> 左右,大部分 issue 都会在 1~3 天内得到回复,感谢所有帮助我们回复 issue 的同学们。</p>
<h3>内容介绍</h3>
<h4>新组件</h4>
<p>在 2.0 版本中,我们引入了社区中呼声最高的四个组件,分别是:</p>
<ul>
<li>
<strong>Image 图片</strong>,类似于小程序原生的 Image 标签,支持多种图片裁剪模式</li>
<li>
<strong>IndexBar 索引栏</strong>,通讯录中的字母索引栏,用于长列表快速索引</li>
<li>
<strong>Skeleton 骨架屏</strong>,在待加载区域展示的占位区块,提供界面加载过程中的过渡效果</li>
<li>
<strong>DropdownMenu 下拉菜单</strong>,用于列表的分类选择、筛选及排序</li>
</ul>
<p><img src="/img/remote/1460000019465451?w=3886&h=1728" alt="" title=""></p>
<h4>新文档</h4>
<p>文档方面,我们重新设计了文档站点,用<strong>卡片</strong>的方式组织段落,更加直观。对一些较为复杂的组件,我们对示例进行细粒度的拆分,添加更多的用法介绍,以帮助大家更快地上手使用。</p>
<p>此外,文档站点也支持了<strong>搜索</strong>和<strong>版本切换</strong>。</p>
<p><img src="/img/remote/1460000019465452" alt="" title=""></p>
<h4>样式定制</h4>
<p>移动端 UI 风格多变,对组件的可定制性要求较高。从 2.0 版本开始,Vant 中的所有组件都支持通过 <strong>Less 变量</strong>进行样式定制。同时我们新增了多个样式相关的 Props,便于快速定制组件风格。</p>
<p><img src="/img/remote/1460000019465453" alt="" title=""></p>
<h4>更轻量</h4>
<p>轻量化是 Vant 的核心开发理念之一。在过去一年多时间里,我们新增了若干个组件和数百项功能,而<strong>代码包体积从 1.0 版本的 169kb 降低到了 2.0 版本的 161kb</strong>(45kb gzipped),平均每个组件体积下降 13%,这主要得益于组件内部逻辑的重构和复用。</p>
<p>在未来的 Vue 3.0 版本中,会提供 Function-based API 这一更优的逻辑复用方式,预计能帮助 Vant 进一步优化代码包体积。</p>
<h4>不兼容更新</h4>
<p>2.0 版本中包含少量不兼容更新,主要是命名调整和移除个别属性。对于正在使用 1.x 版本的项目,请按照<a href="https://link.segmentfault.com/?enc=3XnwjrN5jv%2FVCBd9a35qDw%3D%3D.11g7o3v54M5YSwxygJU%2BLvTbjJFxAWt6Nh%2FFz7GPrtUsw2W89J4DC9kDpkNnWLwj" rel="nofollow">更新日志</a>依次检查,大部分项目可以<strong>无痛升级</strong>。</p>
<h4>其他改动</h4>
<p>除上述内容外,2.0 版本还包含<strong>无障碍访问优化和 70 项功能更新</strong>,想了解更多,请移步:<a href="https://link.segmentfault.com/?enc=NzSpxrTxox%2BK1qdrt2judw%3D%3D.LnJQCCsxZzOGc6BKpUNQd8M721z%2FcU4TLIyVP95HqcdXz0cY20PcZn0eMwm7md31" rel="nofollow">完整更新日志</a>。</p>
<hr>
<h3>后续计划</h3>
<p>我们计划在今年下半年推出 <strong>VantWeapp 1.0 版本</strong>,目标是对标 Vant 2.0 版本,将大部分新组件和新功能同步到小程序端。</p>
<p>同时,Vant 3.0 版本也在酝酿当中,不出意外的话,<strong>3.0 版本会基于 Vue 3.0 实现,并争取和 Vue 3.0 同期发布</strong>。</p>
<p>对于 Vant 1.x 版本,后续会进入维护期,跟进问题修复,但不再引入功能性改动。</p>
<p><img src="/img/remote/1460000019465454" alt="" title=""></p>
<p>期待大家对新版本的反馈!</p>
<h3>链接</h3>
<ul>
<li><a href="https://link.segmentfault.com/?enc=qJl5qK5KVqnQaG%2Bn9ZzGRQ%3D%3D.MlxOxafIT5aI9Xs4Du9iCiOqe1NjA3KADlFMWJaryZw%3D" rel="nofollow">Vant</a></li>
<li><a href="https://link.segmentfault.com/?enc=sFXD1LNkLk1YQPgg9%2B8eFg%3D%3D.%2FvWrBTlXwjVoRtV%2FSlnuDlh5EApTn2UUvbyTPGs3CRjU0hVL8Tbk7BMgsC4sRsBG" rel="nofollow">Vant Demo</a></li>
<li><a href="https://link.segmentfault.com/?enc=x%2FdNi6NabJd6UE8R2z2XsA%3D%3D.8Zhvo0NGthZSoytaBWBq4fJw0QUThTCtzV70xY0alI8ar0C6ZGO5vZxyBRfWmTYP" rel="nofollow">Vant Weapp</a></li>
</ul>
<p><img src="/img/remote/1460000019465455" alt="" title=""></p>
深入浅出MySQL crash safe
https://segmentfault.com/a/1190000019433055
2019-06-10T15:35:53+08:00
2019-06-10T15:35:53+08:00
有赞技术
https://segmentfault.com/u/youzantech
12
<h3>一 前言</h3>
<p>MySQL 主从架构已经被广泛应用,保障主从复制关系的稳定性是大家一直关注的焦点。MySQL 5.6 针对主从复制稳定性提供了新特性: slave 支持 crash-safe。该功能可以解决之前版本中系统异常断电可能导致 relay_log.info 位点信息不准确的问题。<br>本文将从原理,参数,新的问题等几个方面对该特性进行介绍。</p>
<h3>二 crash-unsafe</h3>
<p>在了解 slave crash-safe 之前,我们先分析 MySQL 5.6 之前的版本出现 slave <strong>crash-unsafe</strong> 的原因。我们知道在一套主从结构体系中,slave 包含两个线程:即 IO thread 和 SQL thread。两个线程的执行进度(偏移量)都保存在文件中。</p>
<blockquote>IO thread 负责从 master 拉取 binlog 文件并保存到本地的 relay-log 文件中。 <p>SQL thread 负责执行重复 sql,执行 relay-log 记录的日志。</p>
</blockquote>
<p>crash-unsafe 情况下 SQL_thread 的 的工作模式:</p>
<pre><code>START TRANSACTION;
Statement 1
...
Statement N
COMMIT;
Update replication info files (master.info, relay_log.info)</code></pre>
<p>IO thread 的执行状态信息保存在 master.info 文件, SQL thread 的执行状态信息保存在 relay-log.info 文件。slave 运行正常的情况下,记录位点没有问题。但是每当系统发生 crash,存储的偏移量可能是不准确的(需要注意的是这些文件被修改后不是同步写入磁盘的)。因为应用 binlog 和更新位点信息到文件并不是<strong>原子操作</strong>,而是两个独立的步骤。比如 SQL thread 已经应用 relay-log.01 的4个事务</p>
<pre><code> trx1(pos:10)
trx2(pos:20)
trx3(pos:30)
trx4(pos:40) </code></pre>
<p>但是 SQL thread 更新位点 (relay-log.01,30) 到 relay-log.info 文件中,slave 实例重启的时候 sql thread 会重复执行事务 trx4,于是乎,大家就看到比较常见的复制报错 error 1062,error 1032。</p>
<p>MySQL 5.5 通过两个参数来<strong>缓解该问题</strong>,使用 sync_master_info=1 和sync_replay_log_info=1 来保证 Slave 的两个线程每次写一个事务就分别向两个文件同步一次 IO thread 和 SQL thread 当前执行的位点信息。当然同步操作不是免费的,频繁更新磁盘文件需要消耗性能。</p>
<p>但是,即使设置了 sync_master_info=1 和 sync_relay_info=1,问题还是会出现,因为复制信息是在 transactions 提交后写入的,<strong>如果 crash 发生在事务提交和 OS 写文件之间,那么 relay-log.info 就可能是错误的</strong>。当 slave 从新启动的时候,最后那个事务可能会被执行两次.具体的影响取决于事务的具体操作.复制可能会继续运行比如 update/delete,或者报错 比如 insert 操作,此时主从数据的一致性可能会被破坏。</p>
<h3>三 crash-safe 特性</h3>
<h4>3.1 保障 apply log 和更新位点信息操作的原子性</h4>
<p>通过上面的分析,我们知道 slave crash-unsafe 的原因在于应用 binlog 和更新文件的非原子性。MySQL 5.6 版本通过将更新位点信息存放到表中,并且和正常的事务一起执行,进而保障 <strong>apply binlog 的事务和更新 relay info 信息到 slave_relay_log_info 的原子性</strong>.</p>
<p>就是把 SQL thread 执行事务和更新 mysql.slave_replay_log_info 的语句合并为同一个事务,由 MySQL 系统来保障事务的原子性。我们可以通过伪代码来模拟 crash-safe 的原理:crash-safe 情况下 SQL_thread 的工作模式</p>
<pre><code>START TRANSACTION;
Statement 1
...
Statement N
Update replication info
COMMIT</code></pre>
<p>一图胜千言:</p>
<p><img src="/img/bVbtHyf?w=814&h=446" alt="clipboard.png" title="clipboard.png"></p>
<p>绿色的代表实际业务的事务,蓝色的是开启 MySQL 执行的更新slave_replay_log_info 相关位点信息的 sql ,然后将这两个 sql 合并在一个事务中执行,利用 MySQL 事务机制和 InnoDB 表保障<strong>原子性</strong>。不会出现应用 binlog 和更新位点信息两个动作割裂导致不一致的问题。</p>
<h4>3.2 crash 后的恢复动作</h4>
<p>通过设置 <strong>relay_log_recovery = ON</strong>,slave 遇到异常 crash,然后重启的时候,系统会删除现有的 relay log,然后 IO thread 会从 mysql.slave_replay_log_info 记录的位点信息重新拉取主库的 binlog。MySQL 如此设计的出发点是:</p>
<ol>
<li>SQL thread apply binlog 的位点永远小于等于 IO thread 从主库拉取的位点。</li>
<li>SQL thread 记录的位点是已经执行并且提交的事务之后位点信息。</li>
</ol>
<p>一图胜千言:</p>
<p><img src="/img/bVbtHyN?w=1172&h=782" alt="clipboard.png" title="clipboard.png"></p>
<p>蓝色的 update 语句代表已经执行并提交的事务,绿色的 delete 语句表示正在执行的 sql,还未提交。此时 slave_replay_log_info 表记录的 relay log info是**update 语句结束,delete 语句开始之前的位点 <br>(relay_log.01,100)** 。如果遇到系统 crash,slave 实例重启之后,会删除已经有的 relaylog,并且 IO thread 会从(relay_log.01,100)对应的 master binlog 位点重新拉取主库的 binlog,SQL thread 也会从这个位点开始应用 binlog。</p>
<h4>3.3 GTID 模式下的 crash safe</h4>
<p>和基于位点的复制不同,GTID 模式下使用新的复制协议 COM_BINLOG_DUMP_GTID 进行复制。举个🌰</p>
<p>实例 a 的事务集合 set_a, 实例 b 的事务集合 set_b ,设置 b 为 a 的从库的时候,其中的 binlog 协议伪算法如下:</p>
<ol>
<li>实例 b 指向主库实例 a, 基于主备协议建立主从关系</li>
<li>
<p>实例 b 将 GTID 信息发送给实例 a</p>
<blockquote>UNION(@@global.gtid_executed, Retrieved_gtid_set - last_received_GTID)</blockquote>
</li>
<li>实例 a 计算出 set_b 与 set_a 的差集,也就是存在于 set_a 但是不存在与 set_b 的 GTID 集合,判断实例 a 本地的 binlog 是否包含了该差集所需要的所有 binlog 事务。<p>a 如果不包含,表示实例 a 已经把实例 b 需要的 binlog 删除了,直接返回报错。</p>
<p>b 如果确认全部包含 实例 a 从本地 binlog 文件里面,找到第一个不在 set_b 的事务,发送给实例 b。</p>
</li>
<li>从这个事务开始,往后读文件,按顺序取 binlog 发送给实例 b。</li>
</ol>
<p>GTID 模式下,slave crash-safe 运行机制</p>
<p><img src="/img/bVbtHy5?w=1450&h=824" alt="clipboard.png" title="clipboard.png"></p>
<p>蓝色 ABC:3 表示已经执行并提交的事务,绿色 ABC:4表示正在执行的事务,此时 slave crash,实例记录的 gtid_executed=ABC:1-3,系统重启 relay_log 被删除。slave 将 <strong>UNION(@@global.gtid_executed, null)</strong> 发送到主库,主库会将 ABC:3 以后的 binlog 传送给 slave 继续执行。</p>
<p><strong>注意</strong></p>
<p>从新的复制协议中slave重启时是基于binlog中的GTID信息进行复制的,并不依赖于mysql.slave_replay_log_info。为了保障binlog及时落盘slave要设置 双1模式 **sync_binlog = 1<br>innodb_flush_log_at_trx_commit = 1**</p>
<h4>3.4 如何开启 crash-safe 特性</h4>
<p>通过配置两个如下两个参数开启该特性。</p>
<pre><code>relay_log_info_repository = TABLE
relay_log_recovery = ON</code></pre>
<p>看到这里是不是有疑问为什么没有 master.info 相关的参数配置?</p>
<blockquote>其实开启 slave 的 crash-safe 之后,slave 重启的时候会自动清空之前的 relay-log,IO thread 从 mysql.slave_relay_log_info 表中记录的位点开始拉取数据,而不是依赖 slave_master_info 表相关数据。</blockquote>
<p>注意:<br>如果是 MySQL 5.6.5 或者更早期。slave_master_info 和 slave_relay_log_info 表默认使用 MyISAM 引擎。所以还得修改成 innodb,如下:</p>
<blockquote>
<p>ALTER TABLE mysql.slave_master_info ENGINE=InnoDB; </p>
<p>ALTER TABLE mysql.slave_relay_log_info ENGINE=InnoDB;</p>
</blockquote>
<h4>3.5 相关参数</h4>
<ol>
<li>
<p>开启 crash-safe 之后,slave 重启之后,不再依赖 master info 相关的参数,所以这两个参数不做过多讨论。不过为了和 relay log info 存储一致,<strong>推荐存储 maste-info 到表里,sync_master_info 保持默认,设置为比较低的值,在写压力比较大的情况下,会有 IO 损耗。</strong></p>
<pre><code>master_info_repository =TABLE
sync_master_info=0</code></pre>
</li>
<li>
<p>开启 crash-safe 必要参数</p>
<pre><code> relay_log_info_repository = TABLE
relay_log_recovery = 1</code></pre>
<p>这 2 个不多做介绍了,前面已经将的非常透彻。</p>
</li>
<li>relay log 相关<p><strong>当 relay_log_info_repository=file 时</strong>,<br> 更新位点信息的频率依赖于sync_relay_log_info = N (N>=0):</p>
<p>a 当 sync_relay_log_info=0 时,MySQL 依赖 OS 系统定期更新。</p>
<p>b 当 sync_relay_log_info=N时(N>0),<br> MySQL server 会在每执行 N 个事务之后调用 fdatasync() 刷 relay-log.info 文件。</p>
<p><strong>当 relay_log_info_repository=table</strong></p>
<p>如果 mysql.slave_relay_log_info 是 innodb 存储引擎,则每次事务更新,系统会自动忽略 sync_relay_log_info 的设置。<br><img alt="" title="" src=""></p>
<p>如果 mysql.slave_relay_log_info 是非事务存储引擎,则</p>
<p>a 当 sync_relay_log_info=0 时,不更新。</p>
<p>b 当 sync_relay_log_info=N 时(N>0),<br> MySQL server 会在每执行 N 个事务之后调用 fdatasync() 刷 relay-log.info 文件。</p>
<p><strong>sync_relay_log</strong> 控制着 relay-log 的刷新策略,类似 sync_binlog。不过这个参数在开启 crash-safe 特性之后没有什么实质的意义。建议<strong>保持该参数为默认值即可</strong>。</p>
</li>
</ol>
<h3>四 其他问题</h3>
<p>每个硬币都有它的两面性。开启 crash-safe 会带来哪些潜在的问题?</p>
<p>1 重启 slave,重新拉取 relay-log,一主多从的集群会给主库带来 IO 和带宽压力。</p>
<p>2 主库不可用,或者 binlog 被删除了,slave 找不到所需要的 binlog。</p>
<h3>参考文章</h3>
<p>[1] <a href="https://link.segmentfault.com/?enc=tTN2WJW5EHg1Dtuw3E%2B4Jw%3D%3D.X%2BlGV%2BXjAVZ1QEFRlwlpxTTdBVaDZU%2FpZ5ym%2FfR%2BTEYugvQt9gwVHGgDQ2cDpzR6LmRNAE2fQmOMC7tUv%2B6cMl5hPgMBnjPERXIkDVFyu7o%3D" rel="nofollow">https://hackmongo.com/post/cr...</a></p>
<p>[2] <a href="https://link.segmentfault.com/?enc=9rm4IFwI%2FDmEkDLq1hPzSw%3D%3D.SwEX9rcu%2BUdIO0s5afVEA2fVv0rc74R%2B%2B9kr7HGl4psX3NNSd9fnPojFS1%2FVpy%2BbiIjpNt2GBP%2FEmlUL3WE4cv6Dcj4Eslqhi3MI3z0wX6J%2F0KsNNjVT2xECevqNmAFP" rel="nofollow">http://dev.mysql.com/doc/refm...</a></p>
<p>[3] <a href="https://link.segmentfault.com/?enc=I2HRni0woyTd8SMAQAyOeQ%3D%3D.4tk0NiHDv0MZ172nVpOCl1K7v%2FVBqjmgFvSibz0g5zX%2BFMZckGBWawRv4KEbixxtTc5DDY9kbwpEXmty2npCSO5ZYpAc3sjfxvoZolWUq5nxl0sC5LlFiMaJLjqnRlPi" rel="nofollow">http://dev.mysql.com/doc/refm...</a></p>
<p><img src="/img/bVbsSPc?w=640&h=400" alt="clipboard.png" title="clipboard.png"></p>
DataX在有赞大数据平台的实践
https://segmentfault.com/a/1190000019237959
2019-05-20T17:01:22+08:00
2019-05-20T17:01:22+08:00
有赞技术
https://segmentfault.com/u/youzantech
4
<h2>一、需求</h2>
<p>有赞大数据技术应用的早期,我们使用 Sqoop 作为数据同步工具,满足了 MySQL 与 Hive 之间数据同步的日常开发需求。</p>
<p>随着公司业务发展,数据同步的场景越来越多,主要是 MySQL、Hive 与文本文件之间的数据同步,Sqoop 已经不能完全满足我们的需求。在2017年初,我们已经无法忍受 Sqoop 给我们带来的折磨,准备改造我们的数据同步工具。当时有这么些很最痛的需求:</p>
<ul>
<li>多次因 MySQL 变更引起的数据同步异常。MySQL 需要支持读写分离与分表分库模式,而且要兼容可能的数据库迁移、节点宕机以及主从切换</li>
<li>有不少异常是由表结构变更导致。MySQL 或 Hive 的表结构都可能发生变更,需要兼容多数的表结构不一致情况</li>
<li>MySQL 读写操作不要影响线上业务,不要触发 MySQL 的运维告警,不想天天被 DBA 喷</li>
<li>希望支持更多的数据源,如 HBase、ES、文本文件</li>
</ul>
<p>作为数据平台管理员,还希望收集到更多运行细节,方便日常维护:</p>
<ul>
<li>统计信息采集,例如运行时间、数据量、消耗资源</li>
<li>脏数据校验和上报</li>
<li>希望运行日志能接入公司的日志平台,方便监控</li>
</ul>
<h2>二、选型</h2>
<p>基于上述的数据同步需求,我们计划基于开源做改造,考察的对象主要是 DataX 和 Sqoop,它们之间的功能对比如下</p>
<table>
<thead><tr>
<th>功能</th>
<th>DataX</th>
<th>Sqoop</th>
</tr></thead>
<tbody>
<tr>
<td>运行模式</td>
<td>单进程多线程</td>
<td>MapReduce</td>
</tr>
<tr>
<td>MySQL读写</td>
<td>单机压力大;读写粒度容易控制</td>
<td>MapReduce 模式重,写出错处理麻烦</td>
</tr>
<tr>
<td>Hive读写</td>
<td>单机压力大</td>
<td>扩展性好</td>
</tr>
<tr>
<td>文件格式</td>
<td>orc支持</td>
<td>orc不支持,可添加</td>
</tr>
<tr>
<td>分布式</td>
<td>不支持,可以通过调度系统规避</td>
<td>支持</td>
</tr>
<tr>
<td>流控</td>
<td>有流控功能</td>
<td>需要定制</td>
</tr>
<tr>
<td>统计信息</td>
<td>已有一些统计,上报需定制</td>
<td>没有,分布式的数据收集不方便</td>
</tr>
<tr>
<td>数据校验</td>
<td>在core部分有校验功能</td>
<td>没有,分布式的数据收集不方便</td>
</tr>
<tr>
<td>监控</td>
<td>需要定制</td>
<td>需要定制</td>
</tr>
<tr>
<td>社区</td>
<td>开源不久,社区不活跃</td>
<td>一直活跃,核心部分变动很少</td>
</tr>
</tbody>
</table>
<p>DataX 主要的缺点在于单机运行,而这个可以通过调度系统规避,其他方面的功能均优于 Sqoop,最终我们选择了基于 DataX 开发。</p>
<h2>三、前期设计</h2>
<h3>3.1 运行形态</h3>
<p>使用 DataX 最重要的是解决分布式部署和运行问题,DataX 本身是单进程的客户端运行模式,需要考虑如何触发运行 DataX。</p>
<p>我们决定复用已有的离线任务调度系统,任务触发由调度系统负责,DataX 只负责数据同步。这样就复用了系统能力,避免重复开发。关于调度系统,可参考文章<a href="https://link.segmentfault.com/?enc=uCdqihJVPO%2Bl0m9r91e2JA%3D%3D.tyeN1MqwfPBf%2FjaJiMdXSctbK%2BqfdgjhIKtw5SZidnwznxBA%2FHkrIYs79apDv9H%2F" rel="nofollow">《大数据开发平台(Data Platform)在有赞的最佳践》</a></p>
<p><img src="/img/bVbsSO5?w=502&h=465" alt="clipboard.png" title="clipboard.png"></p>
<p>在每个数据平台的 worker 服务器,都会部署一个 DataX 客户端,运行时可同时启动多个进程,这些都由调度系统控制。</p>
<h3>3.2 执行器设计</h3>
<p>为了与已有的数据平台交互,需要做一些定制修改:</p>
<ul>
<li>符合平台规则的状态上报,如启动/运行中/结束,运行时需上报进度,结束需上报成功失败</li>
<li>符合平台规则的运行日志实时上报,用于展示</li>
<li>统计、校验、流控等子模块的参数可从平台传入,并需要对结果做持久化</li>
<li>需要对异常输入做好兼容,例如 MySQL 主从切换、表结构变更</li>
</ul>
<h3>3.3 开发策略</h3>
<p>大致的运行流程是:<code>前置配置文件转换、表结构校验 -> (输入 -> DataX 核心+业务无关的校验 -> 输出) -> 后置统计/持久化</code></p>
<p>尽量保证 DataX 专注于数据同步,尽量不隐含业务逻辑,把有赞特有的业务逻辑放到 DataX 之外,数据同步过程无法满足的需求,才去修改源码。</p>
<p>表结构、表命名规则、地址转换这些运行时前置校验逻辑,以及运行结果的持久化,放在元数据系统(参考<a href="https://link.segmentfault.com/?enc=tv7%2FCv%2FoYY%2F2RCYjJNAEdg%3D%3D.13oy0DNloyBVMi%2FFNhZHsSjWnyRtBl2HzmREPVRCvLpLq6DzG0PScfhJo0X00ExQ" rel="nofollow">《有赞数据仓库元数据系统实践》</a>),而运行状态的监控放在调度系统。</p>
<h2>四、源码改造之路</h2>
<h3>4.1 支持 Hive 读写</h3>
<p>DataX 并没有自带 Hive 的 reader 和 writer,而只有 HDFS 的 reader 和writer。我们选择在 DataX 之外封装,把 Hive 读写操作的配置文件,转换为 HDFS 读写的配置文件,另外辅助上 Hive DDL 操作。具体的,我们做了如下改造:</p>
<h4>4.1.1 Hive 读操作</h4>
<ul>
<li>根据表名,拼接出 HDFS 路径。有赞的数据仓库规范里有一条,禁止使用外部表,这使得 HDFS 路径拼接变得容易。若是外部表,就需要从元数据系统获取相应的路径</li>
<li>Hive 的表结构获取,需要依赖元数据系统。还需对 Hive 表结构做校验,后面会详细说明</li>
</ul>
<h4>4.1.2 Hive 写操作</h4>
<ul>
<li>写 Hive 的配置里不会指定 Hive 的文件格式、分隔符,需要读取元数据,获取这些信息填入 HDFS 的写配置文件</li>
<li>支持新建不存在的 Hive 表或分区,能构建出符合数据仓库规范的建表语句</li>
</ul>
<h3>4.2 MySQL -> Hive兼容性</h3>
<p>按 DataX 的设计理念,reader 和 writer 相互不用关心,但实际使用经常需要关联考虑才能避免运行出错。MySQL 加减字段,或者字段类型变更,都会导致 MySQL 和 Hive 的表结构不一致,需要避免这种不一致的运行出错。</p>
<h4>4.2.1 MySQL -> Hive 非分区表</h4>
<p>非分区表都是全量导入,以 mysqlreader 配置为准。如果 MySQL 配置字段与 Hive 实际结构不一致,则把 Hive 表 drop 掉后重建。表重建可能带来下游 Hive SQL 出错的风险,这个靠 SQL 的定时检查规避。</p>
<p>Hive 表重建时,需要做 MySQL 字段转换为 Hive 类型,比如 MySQL 的 varchar 转为 Hive 的 string。这里有坑,Hive 没有无符号类型,注意 MySQL 的 int unsigned 的取值范围,需要向上转型,转为 Hive 的 bigint;同理,MySQL 的 bigint unsigned 也需要向上转型,我们根据实际业务情况大胆转为 bigint。而 Hive 的 string 是万能类型,如果不知道怎么转,用 string 是比较保险的。</p>
<h4>4.2.2 MySQL -> Hive 分区表</h4>
<p>Hive 分区表不能随意变更表结构,变更可能会导致旧分区数据读取异常。所以写Hive 分区表时,以 Hive 表结构为准,表结构不一致则直接报错。我们采取了如下的策略</p>
<table>
<thead><tr>
<th>MySQL字段</th>
<th>Hive实际字段</th>
<th>处理方法</th>
</tr></thead>
<tbody>
<tr>
<td>a,b</td>
<td>a,b</td>
<td>正常</td>
</tr>
<tr>
<td>a,b,c</td>
<td>a,b</td>
<td>忽略MySQL的多余字段,以Hive为准</td>
</tr>
<tr>
<td>b,a</td>
<td>a,b</td>
<td>顺序不对,调整</td>
</tr>
<tr>
<td>a</td>
<td>a,b</td>
<td>MySQL少一个,报错</td>
</tr>
<tr>
<td>a,c</td>
<td>a,b</td>
<td>不匹配, 报错</td>
</tr>
<tr>
<td>未指定字段</td>
<td>a,b</td>
<td>以Hive为准</td>
</tr>
</tbody>
</table>
<p>这么做偏保守,对于无害的Hive分区表变更,其实可以大胆去做,比如int类型改bigint、orc表加字段。</p>
<h3>4.3 适配 MySQL 集群</h3>
<p>有赞并没有独立运行的 MySQL 实例,都是由 RDS 中间件管理着 MySQL 集群,有读写分离和分表分库两种模式。读写 MySQL 有两种选择,通过 RDS 中间件读写,以及直接读写 MySQL 实例。</p>
<table>
<thead><tr>
<th>方案</th>
<th>优先</th>
<th>缺点</th>
</tr></thead>
<tbody>
<tr>
<td>连实例</td>
<td>性能好;不影响线上业务</td>
<td>当备库维护或切换地址时,需要修改配置;开发者不知道备库地址</td>
</tr>
<tr>
<td>连 RDS</td>
<td>与普通应用一致;屏蔽了后端维护</td>
<td>对 RDS 造成额外压力,有影响线上业务的风险;需要完全符合公司 SQL 规范</td>
</tr>
</tbody>
</table>
<p>对于写 MySQL,写入的数据量一般不大,DataX 选择连 RDS,这样就不用额外考虑主从复制。<br>对于读 MySQL,考虑到有大量的全表同步任务,特别是凌晨离线任务高峰流量特别大,避免大流量对 RDS 中间件的冲击,DataX 选择直连到 MySQL 实例去读取数据。为了规避 MySQL 维护带来的地址变更风险,我们又做了几件事情:</p>
<ul>
<li>元数据维护了标准的 RDS 中间件地址</li>
<li>主库、从库、RDS 中间件三者地址可以关联和任意转换</li>
<li>每次 DataX 任务启动时,获取最新的主库和从库地址</li>
<li>定期的 MySQL 连通性校验</li>
<li>与 DBA 建立协作关系,变更提前通知</li>
</ul>
<p>读取 MySQL 时,对于读写分离,每次获取其中一个从库地址并连接;对于分表分库,我们有1024分片,就要转换出1024个从库地址,拼接出 DataX 的配置文件。</p>
<h3>4.4 MySQL 运维规范的兼容</h3>
<h4>4.4.1 避免慢 SQL</h4>
<p>前提是有赞的 MySQL 建表规范,规定了建表必须有整型自增id主键。另一条运维规范,SQL 运行超过2s会被强行 kill 掉。</p>
<p>以读取 MySQL 全表为例,我们把一条全表去取的 SQL,拆分为很多条小 SQL,而每条小 SQL 只走主键 id 的聚簇索引,代码如下<br><code>select ... from table_name where id>? by id asc limit ?</code></p>
<h4>4.4.2 避免过快读写影响其他业务</h4>
<p>执行完一条 SQL 后会强制 sleep 一下,让系统不能太忙。无论是 insert 的 batchSize,还是 select 每次分页大小,我们都是动态生成的,根据上一条运行的时间,运行太快就多 sleep,运行太慢就少 sleep,同时调整下一个批次的数量。</p>
<p>这里还有改进的空间,可以根据系统级的监控指标动态调整速率,比如磁盘使用率、CPU 使用率、binlog 延迟等。实际运行中,删数据很容易引起 binlog 延迟,仅从 delete 语句运行时间无法判断是否删的太快,具体原因尚未去深究。</p>
<h3>4.5 更多的插件</h3>
<p>除了最常用的 MySQL、Hive,以及逻辑比较简单的文本,我们还对 HBase 的读写根据业务情况做了简单改造。<br>我们还全新开发了 eswriter,以及有赞 kvds 的 kvwriter,这些都是由相关存储的开发者负责开发和维护插件。</p>
<h3>4.6 与大数据体系交互</h3>
<h4>4.6.1 上报运行统计数据</h4>
<p>DataX 自带了运行结果的统计数据,我们希望把这些统计数据上报到元数据系统,作为 ETL 的过程元数据存储下来。</p>
<p>基于我们的开发策略,不要把有赞元数据系统的 api 嵌入 DataX 源码,而是在 DataX 之外获取 stdout,截取出打印的统计信息再上报。</p>
<h4>4.6.2 与数据平台的交互</h4>
<p>数据平台提供了 DataX 任务的编辑页面,保存后会留下 DataX 运行配置文件以及调度周期在平台上。调度系统会根据调度周期和配置文件,定时启动 DataX 任务,每个 DataX 任务以独立进程的方式运行,进程退出后任务结束。运行中,会把 DataX 的日志实时传输并展示到页面上。</p>
<h3>4.7 考虑更多异常</h3>
<p>DataX 代码中多数场景暴力的使用<code>catch Exception</code>,缺乏对各异常场景的兼容或重试,一个大任务执行过程中出现网络、IO等异常容易引起任务失败。最常见的异常就是 SQLException,需要对异常做分类处理,比如 SQL 异常考虑重试,批量处理异常改走单条依次处理,网络异常考虑数据库连接重建。<br>HDFS 对异常容忍度较高,DataX 较少捕获异常。</p>
<h3>4.8 测试场景改造</h3>
<h4>4.8.1 持续集成</h4>
<p>为了发现低级问题,例如表迁移了但任务还在、普通表改成了分区表,我们每天晚上20点以后,会把当天运行的所有重要 DataX 任务“重放”一遍。</p>
<p>这不是原样重放,而是在配置文件里加入了一个测试的标识,DataX 启动后,reader 部分只会读取一行数据,而 writer 会把目标地址指向一个测试的空间。这个测试能保证 DataX基本功能没问题,以及整个运行环境没有问题。</p>
<h4>4.8.2 全链路压测场景</h4>
<p>有赞全链路压测系统通过 Hive 来生成数据,通过 DataX 把生成好的数据导入影子库。影子库是一种建在生产 MySQL 里的 database,对普通应用不可见,加上 SQL 的特殊 hint 才可以访问。</p>
<p>生产环境的全链路压测是个高危操作,一旦配置文件有误可能会破坏真实的生产数据。DataX 的 MySQL 读写参数里,加上了全链路压测的标记时,只能读写特定的 MySQL 和 Hive 库,并配置数据平台做好醒目的提醒。</p>
<h2>五、线上运行情况</h2>
<p>DataX 是在2017年二季度正式上线,到2017年Q3完成了上述的大部分特性开发,后续仅做了少量修补。到2019年Q1,已经稳定运行了超过20个月时间,目前每天运行超过6000个 DataX 任务,传输了超过100亿行数据,是数据平台里比较稳定的一个组件。</p>
<p>期间出现过一些小问题,有一个印象深刻。原生的 hdfsreader 读取超大 orc 文件有 bug,orc 的读 api 会把大文件分片成多份,默认大于256MB会分片,而 datax 仅读取了第一个分片,修改为读取所有分片解决问题。因为256MB足够大,这个问题很少出现很隐蔽。除此之外没有发现大的 bug,平时遇到的问题,多数是运行环境或用户理解的问题,或是可以克服的小问题。</p>
<h2>六、下一步计划</h2>
<p>对于 DataX 其实并没有再多的开发计划。在需求列表里积累了十几条改进需求,而这些需求即便1年不去改进,也不会影响线上运行,诸如脏数据可读性改进、支持 HDFS HA。这些不重要不紧急的需求,暂时不会再投入去做。</p>
<p>DataX 主要解决批量同步问题,无法满足多数增量同步和实时同步的需求。对于增量同步我们也有了成熟方案,会有另一篇文章介绍我们自研的增量同步产品。</p>
<p><img src="/img/bVbsSPc?w=640&h=400" alt="clipboard.png" title="clipboard.png"></p>
实时计算在有赞的实践 - 效率提升之路
https://segmentfault.com/a/1190000019186688
2019-05-15T12:13:19+08:00
2019-05-15T12:13:19+08:00
有赞技术
https://segmentfault.com/u/youzantech
6
<h2>1. 概述</h2>
<p>有赞是一个商家服务公司,提供全行业全场景的电商解决方案。在有赞,大量的业务场景依赖对实时数据的处理,作为一类基础技术组件,服务着有赞内部几十个业务产品,几百个实时计算任务,其中包括交易数据大屏,商品实时统计分析,日志平台,调用链,风控等多个业务场景,本文将介绍有赞实时计算当前的发展历程和当前的实时计算技术架构。</p>
<h2>2. 实时计算在有赞发展</h2>
<p>从技术栈的角度,我们的选择和大多数互联网公司一致,从早期的Storm,到JStorm, Spark Streaming 和最近兴起的Flink。从发展阶段来说,主要经历了两个阶段,起步阶段和平台化阶段;下面将按照下图中的时间线,介绍实时计算在有赞的发展历程。</p>
<p><img src="/img/remote/1460000019186691" alt="" title=""></p>
<h3>2.1 起步阶段</h3>
<p>这里的的起步阶段的基本特征是,缺少整体的实时计算规划,缺乏平台化任务管理,监控,报警工具,用户提交任务直接通过登录 AG 服务器使用命令行命令提交任务到线上集群,很难满足用户对可用性的要求。 但是,在起步阶段里积累了内部大量的实时计算场景。</p>
<h4>2.1.1 Storm 登场</h4>
<p>2014年初,第一个 Storm 应用在有赞内部开始使用,最初的场景是把实时事件的统计从业务逻辑中解耦出来,Storm 应用通过监听 MySQL 的 binlog 更新事件做实时计算,然后将结果更新到 MySQL 或者 Redis 缓存上,供在线系统使用。类似的场景得到了业务开发的认可,逐渐开始支撑起大量的业务场景,详见2017年整理的一篇<a href="https://link.segmentfault.com/?enc=uqgRhMPxm0%2B3RzsaXHijBA%3D%3D.21SLIQt%2BgWdDuRpIclPvWQcYy7eBMoIaSUYPjbwMv9s10WbsZWCQig1%2BV2hvryyt" rel="nofollow">博客文章-《基于 Storm 的实时应用实践》</a>。 </p>
<p>早期,用户通过登录一组线上环境的AG服务器,通过Storm的客户端向Storm集群做提交任务等操作, 这样在2年多的时间里,Storm 组件积累了近百个实时应用。 Storm也同样暴露出很多问题,主要体现在系统吞吐上,对吞吐量巨大,但是对延迟不敏感的场景,显得力不从心。</p>
<h4>2.1.2 引入Spark Streaming</h4>
<p>2016 年末,随着 Spark 技术栈的日益成熟,又因为 Storm 引擎本身在吞吐/性能上跟 Spark Streaming 技术栈相比有明显劣势,所以从那时候开始,部分业务团队开始尝试新的流式计算引擎。 因为有赞离线计算有大量 Spark 任务的使用经验,Spark Streaming 很自然的成为了第一选择,随着前期业务日志系统和埋点日志系统的实时应用的接入,大量业务方也开始逐渐接入。 同 Storm 一样,业务方完成实时计算应任务开发后,通过一组 AG 服务器,使用 Spark 客户端,向大数据 Yarn 集群提交任务。 </p>
<p>初步阶段持续的时间比较长,差不多在2017年年末,有赞实时计算的部署情况如下图所示:</p>
<p><img src="/img/remote/1460000019186692" alt="" title=""></p>
<h4>2.1.3 小结</h4>
<p>这种架构在业务量少的情况下问题不大,但是随着应用方任务数目的增加,暴露出一些运维上的问题,主要在以下几个方面:</p>
<ol>
<li>缺少业务管理机制。大数据团队平台组,作为集群管理者,很难了解当前集群上运行着的实时任务的业务归属关系,也就导致在集群出现可用性问题或者集群要做变更升级时,无法高效通知业务方做处理,沟通成本很高</li>
<li>Storm和Spark Streaming的监控报警,是各自实现的,处于工具化的阶段,很多业务方,为了可用性,会定制自己的监控报警工具,导致很多重复造轮,影响开发效率</li>
<li>计算资源没有隔离。资源管理粗糙,没有做离线系统和实时系统的隔离;早期离线任务和 Spark Streaming 任务运行在同一组 Yarn 资源上,凌晨离线任务高峰时,虽然 Yarn 层有做 CapacityScheduler 的 Queue 隔离,但是 HDFS 层公用物理机,难免网卡和磁盘 IO 层面会相互影响,导致凌晨时间段实时任务会有大量延迟</li>
<li>缺少灵活的资源调度。用户通过 AG 服务器启动实时任务,任务所使用的集群资源,也在启动脚本中指定。这种方式在系统可用性上存在很大弊端,当实时计算所在的 Yarn 资源池出现故障时,很难做实时任务的集群间切换</li>
</ol>
<p>总的来说就是缺少一个统一的实时计算平台,来管理实时计算的方方面面。</p>
<h3>2.2 平台化阶段</h3>
<h4>2.2.1 构建实时计算平台</h4>
<p>接上一节,面对上面提到的这四个问题,对实时计算平台的初步需求如下:</p>
<ol>
<li>业务管理功能。主要是记录实时应用的相关信息,并且和业务的接口人做好关联</li>
<li>提供任务级别的监控,任务故障自动拉起,用户自定义基于延迟/吞吐等指标的报警,流量趋势大盘等功能</li>
<li>做好集群规划,为实时应用构建独立的计算Yarn集群,避免离线任务和实时任务互相影响</li>
<li>提供任务零花的切换计算集群,保证在集群故障时可以方便迁移任务到其他集群暂避</li>
</ol>
<p>所以在18年初,我们立项开始做实时平台第一期,作为尝试起初我们仅仅完成对 Spark Streaming 实时计算任务的支持, 并在较短时间内完成了所有 Spark Streaming 任务的迁移。 试运行2个月后,明显感觉到对业务的掌控力变强。随后便开始了对 Storm 任务的支持,并迁移了所有的 Storm 实时计算任务. AG 服务器全部下线,业务方再也不需要登录服务器做任务提交。 </p>
<p>2018 年中,有赞线上运行着 Storm,Spark Streaming 两种计算引擎的实时任务,可以满足大部分业务需求,但是,两种引擎本身也各自存在着问题。 Storm本身存在着吞吐能力的限制。和 Spark Streaming 对比,选择似乎更难一些。我们主要从以下几个角度考虑:</p>
<ol>
<li>延迟, Flink 胜出,Spark Streaming 本质上还是以为微批次计算框架,处理延迟一般跟 Batch Interval一致,一般在秒级别,在有赞的重吞吐场景下,一般 batch 的大小在 15 秒左右</li>
<li>吞吐, 经过实际测试,相同条件下,Flink 的吞吐会略低于 Spark Streaming,但是相差无几</li>
<li>对状态的存储支持, Flink在这方面完胜,对于数据量较大的状态数据,Flink 可以选择直接存储计算节点本地内存或是 RocksDB,充分利用物理资源</li>
<li>对 SQL 的支持,对当时两种框架的最新稳定版本的 SQL 功能做了调研,结果发现在对 SQL 的支持度上,Flink也具有较大优势,主要体现在支持更多的语法</li>
<li>API灵活性, Flink 的实时计算 API会更加友好</li>
</ol>
<p>出于以上几点原因,有赞开始在实时平台中增加了对 Flink 引擎的支持,选择 Flink 的更具体的原因可以参考我们另一篇<a href="https://link.segmentfault.com/?enc=JVN%2FK4Hv8hhDfnPDP0PtJA%3D%3D.kTgBorK8H2j3cIVDCz7OZD%2FVTnCcRQCLvaP8o5mGaft2W8nfjmzy%2BPvHzS2hAe0e" rel="nofollow">博客文章-《Flink 在有赞实时计算的实践》</a></p>
<p>在完成 Flink 引擎的集成后,有赞实时计算的部署情况如下图所示:<br><img src="/img/remote/1460000019186693" alt="" title=""></p>
<h4>2.2.2 新的挑战</h4>
<p>以上完成之后,基本上就可以提供稳定/可靠的实时计算服务;随之,业务方开发效率的问题开始显得突出。用户一般的接入流程包含以下几个步骤:</p>
<ol>
<li>熟悉具体实时计算框架的SDK使用,第一次需要半天左右</li>
<li>申请实时任务上下游资源,如消息队列,Redis/MySQL/HBase 等在线资源,一般几个小时</li>
<li>实时任务开发,测试,视复杂程度,一般在1~3天左右</li>
<li>对于复杂的实时开发任务,实时任务代码质量很难保证,平台组很难为每个业务方做代码 review, 所以经常会有使用不当的应用在测试环境小流量测试正常后,发布到线上,引起各种各样的问题</li>
</ol>
<p>整个算下来,整个流程至少需要2~3天,实时应用接入效率逐渐成了眼前最棘手的问题。 对于这个问题。在做了很多调研工作后,最终确定了两个实时计算的方向:</p>
<ol>
<li>实时任务 SQL 化</li>
<li>对于通用的实时数据分析场景,引入其他技术栈, 覆盖简单场景</li>
</ol>
<h5>2.2.2.1 实时任务SQL化</h5>
<p>实时任务 SQL 化可以大大简化业务的开发成本,缩短实时任务的上线周期。 在有赞,实时任务 SQL化 基于 Flink 引擎,目前正在构建中,我们目前的规划是首先完成对以下功能的支持:</p>
<ol>
<li>基于 Kafka 流的流到流的实时任务开发</li>
<li>基于 HBaseSink 的流到存储的SQL任务开发</li>
<li>对 UDF 的支持</li>
</ol>
<p>目前SQL化实时任务的支持工作正在进行中。</p>
<h5>2.2.2.2 引入实时OLAP引擎</h5>
<p>通过对业务的观察,我们发现在业务的实时应用中,有大量的需求是统计在不同维度下的 uv,pv 类统计,模式相对固定,对于此类需求,我们把目光放在了支持数据实时更新,并且支持实时的Olap类查询上的存储引擎上。 </p>
<p>我们主要调研了 Kudu,Druid 两个技术栈,前者是 C++ 实现,分布式列式存储引擎,可以高效的做 Olap 类查询,支持明细数据查询;后者是 Java 实现的事件类数据的预聚合 Olap 类查询引擎~ </p>
<p>综合考虑了运维成本,与当前技术栈的融合,查询性能,支持场景后,最终选择了 Druid,关于 Druid 在有赞的实践,可以参考我们另一篇<a href="https://link.segmentfault.com/?enc=wE3Jpr1s82iGc2GAi9sMHw%3D%3D.KopvZ%2BAP6lizJ6oCP9lLAMZ%2FUAip48qPGb5vLQD510f%2BbN%2BPBKmVk9l1pt9J8AUn" rel="nofollow">博客文章-《Druid在有赞的实践》</a>。</p>
<p>目前实时计算在有赞的整体技术架构如下图:<br><img src="/img/remote/1460000019186694" alt="" title=""></p>
<h2>3. 未来规划</h2>
<p>首先要落地并的是实时任务SQL化,提高SQL化任务可以覆盖的业务场景(目标是70%),从而通过提高业务开发效率的角度赋能业务。</p>
<p>在SQL化实时任务初步完成后,流数据的复用变成了提高效率上 ROI 最高的措施,初步计划会着手开始实时数仓的建设,对于实时数仓的初步设计如下图:</p>
<p><img src="/img/remote/1460000019186695" alt="" title=""></p>
<p>当然,完整的实时数仓绝没有这么简单,不只是实时计算相关的基础设施要达到一定的平台化水平,还依赖实时元数据管理,实时数据质量管理等配套的组件建设,路漫漫其修远~</p>
<h2>4. 总结</h2>
<p>有赞实时计算在业务方的需求下推动前进,在不同的阶段下,技术方向始终朝着当前投入产出比最高的方向在不断调整。本文并没有深入技术细节,而是循着时间线描述了实时计算在有赞的发展历程,有些地方因为作者认知有限,难免纰漏,欢迎各位同行指出。</p>
<p>最后打个小广告,有赞大数据团队基础设施团队,主要负责有赞的数据平台(DP), 实时计算(Storm, Spark Streaming, Flink),离线计算(HDFS,YARN,HIVE, SPARK SQL),在线存储(HBase),实时 OLAP(Druid) 等数个技术产品,欢迎感兴趣的小伙伴联系 hefei@youzan.com</p>
<h2>相关链接</h2>
<ol>
<li><a href="https://link.segmentfault.com/?enc=jSg1slnHHA8TxuOdd1skOQ%3D%3D.zGQ88xQcjerZVtw%2F9wN2caS5%2FFcCCioOGNuOJQ3bxcWDa7H3yqRGxI2CM%2FHvGk2l" rel="nofollow">Druid在有赞的实践</a></li>
<li><a href="https://link.segmentfault.com/?enc=%2FXR2kg%2F0Va3ErT3VPe4Y5A%3D%3D.ugfXhqNcmZ9v3Q99s8Lx%2B3%2FUFYM8wrbUY2DVLTakK%2F3tjwPBz0IB%2BPhTKlVTAK%2Bp" rel="nofollow">Flink 在有赞实时计算的实践</a></li>
<li><a href="https://link.segmentfault.com/?enc=eqtD1H0IHM4HBMaal7DSFA%3D%3D.rgxn%2B50XzDkIvE6Jr%2BfcedsJP3Rst9DdS9Kdt5p7VZL8HPBZlTN34406KbNb7nBy" rel="nofollow">基于Storm的实时计算应用实践</a></li>
</ol>
<p><img src="/img/bVbsDnm?w=640&h=400" alt="clipboard.png" title="clipboard.png"></p>
How we redesign the NSQ-NSQ重塑之客户端
https://segmentfault.com/a/1190000019178537
2019-05-14T16:57:03+08:00
2019-05-14T16:57:03+08:00
有赞技术
https://segmentfault.com/u/youzantech
4
<h2>overview</h2>
<p>有赞的自研版 NSQ 在高可用性以及负载均衡方面进行了改造,自研版的 nsqd 中引入了数据分区以及副本,副本保存在不同的 nsqd 上,达到容灾目的。此外,自研版 NSQ 在原有 Protocol Spec 基础上进行了拓展,支持基于分区的消息生产、消费,以及基于消息分区的有序消费,以及消息追踪功能。</p>
<p>为了充分支持自研版 NSQ 新功能,在要构建 NSQ client 时,需要在兼容原版 NSQ 的基础上,实现额外的设计。本文作为<a href="https://link.segmentfault.com/?enc=twghMLor1I2T%2B0%2B81BZtIQ%3D%3D.EmFhDv1Y1J5TmLDSYFiIUFTKTKJagJh7xW71SxdG6N9hjwlY54xs2QmyzMOItCERnkpy4nc6X2bdJYSnUzUdYg%3D%3D" rel="nofollow">《Building Client Libraries》</a>的拓展,为构建有赞自研版 NSQ client 提供指引。</p>
<p>参考 NSQ 官方的构造 client 指南的结构,接下来的文章分为如下部分:</p>
<p>1 workflow 及配置<br>2 nsqd 发现服务<br>3 nsqd 建连<br>4 发送/接收消息<br>5 顺序消费</p>
<blockquote>本文根据有赞自研版 nsq 的新特性,对 nsq 文档<sup id="fnref-1"><a href="#fn-1" class="footnote-ref">1</a></sup>中构建 nsq client 的专题进行补充。在阅读<a href="https://link.segmentfault.com/?enc=pnDnfp0g9TG4iAeTXr9oJA%3D%3D.WC3BfTpBvLPc7ePOOn5M7vkowG4QXn2LTsS9e1lhbtth%2F%2BdIfVA5GJqJwCCFAFvK2v63IWsfwY%2BNw%2FrRvg2dfQ%3D%3D" rel="nofollow">《Building Client Libraries》</a>的基础上阅读本文,更有助于理解。</blockquote>
<h2>workflow 及配置</h2>
<p>通过一张图,浏览一下 nsq client 的工作流程。</p>
<p><img src="/img/remote/1460000019178540" alt="i" title="i"></p>
<p>client 作为消息 producer 或者 consumer 启动后,负责 lookup 的功能通过 nsqlookupd 进行 nsqd 服务发现。对于服务发现返回的 nsqd 节点,client 进行建连操作以及异常处理。nsq 连接建立后,producer 进行消息发送,consumer 则监听端口接收消息。同时,client 负责响应来自 nsqd 的心跳,以保持连接不被断开。在和 nsqd 消息通信过程中,client 通过 lookup 发现,持续更新 nsq 集群中 topic 以及节点最新信息,并对连接做相应更新操作。当消息发送/消费结束时,client 负责关闭相应 nsqd 连接。文章在接下来讨论这一流程中的关键步骤,对相应功能的实现做更详细的说明。</p>
<h3>配置 client</h3>
<p>自研版 NSQ 改造自开源版 NSQ,继承了开源版 NSQ 中的配置。<sup id="fnref-1"><a href="#fn-1" class="footnote-ref">1</a></sup>中 Configuration 段落的内容适用于有赞自研版。唯一需要指出的地方是,开源版 nsq 将使用 nsqlookupd 作为 nsqd 服务发现的一个可选项,基于配置灵活性的考量,开源版 NSQ 允许 client 通过 nsqd 的地址直接建立连接,自研版 NSQ 由于支持动态负载,nsqd 之间的主从关系在集群中发生切换的时候,需要依赖自研版的 nsqlookupd 将变更信息反馈给 nsq client。基于此,使用 nsqlookupd 进行服务发现,在自研版 NSQ 中是一个“标配”。我们也将在下一节中对服务发现过程做详细的说明。</p>
<h2>nsqd 发现服务</h2>
<p>开源版中,提供 nsqd 发现服务作为 nsqlookupd 的重要功能,用于向消息的 consumer/producer 提供可消费/生产的 nsqd 节点。上文提到,区别于开源版本,自研版的 nsqlookupd 将作为服务发现的唯一入口。nsq client 负责调用 nsqlookupd 的 lookup 服务,并通过 poll,定时更新消息在 nsq 集群上的读写分布信息。根据 lookup 返回的结果,nsq client 对 nsqd 进行建连。</p>
<p>在自研版中访问 lookup 服务的方式和开源版一样简单直接,向 nsqlookupd 的 http 端口 GET:/lookup?topic={topic_name} 即可完成。不同之处在于,自研版本 NSQ 的 lookup 服务中支持两种新的查询参数:</p>
<blockquote>GET:/lookup?topic={topic_name}&access={r or w}&metainto=true</blockquote>
<p>其中:access 用于区分 nsq client 的生产/消费请求。代表 producer 的 lookup 查询中的参数为 access=w,consumer 的 lookup 查询为 access=r。metainfo 参数用于提示 nsqlookup 是否返回查询 topic 的分区元数据,包括查询 topic 的分区总数,以及副本个数。顺序消费时,prodcuer 通过返回的分区元数据来判断 lookup 响应中返回的分区数是否完整,顺序消费和生产的详细部分我们将在发送消息章节中讨论。client 在访问 lookup 服务时,根据 prodcuer&consumer 角色的差别可以使用两类查询参数</p>
<ul>
<li>Producer GET:/lookup?topic=test&access=w&metainfo=true</li>
<li>Consumer GET:/lookup?topic=test&access=r</li>
</ul>
<p>在设计上,由于 metainfo 提供的信息是为 producer 在顺序消费场景下的生产,为了减少 nsqlookupd 服务压力,代表 consumer 的 lookup 查询无需携带 metainfo 参数。自研版 lookup 的响应和开原版本兼容:</p>
<pre><code>{
"status_code":200,
"status_txt":"OK",
"data":{
"channels":[
"BaseConsumer"
],
"meta":{
"partition_num":1,
"replica":1
},
"partitions":{
"0":{
"id":"XX.XX.XX.XX:33122",
"remote_address":"X.X.X.X:33122",
"hostname":"host-name",
"broadcast_address":"XX.XX.XX.XX",
"tcp_port":4150,
"http_port":4151,
"version":"0.3.7-HA.1.5.4.1",
"distributed_id":"XX.XX.XX.XX:4250:4150:338437"
}
},
"producers":[
{
"id":"XX.XX.XX.XX:33122",
"remote_address":"XX.XX.XX.XX:33122",
"hostname":"host-name",
"broadcast_address":"XX.XX.XX.XX",
"tcp_port":4150,
"http_port":4151,
"version":"0.3.7-HA.1.5.4.1",
"distributed_id":"XX.XX.XX.XX:4250:4150:338437"
}
]
}
}</code></pre>
<h3>lookup 流程</h3>
<p>client 的 lookup 流程如下:</p>
<p><img src="/img/remote/1460000019178541" alt="" title=""></p>
<p>自研版 nsqlookupd 添加了 listlookup 服务,用于发现接入集群中的 nsqlookupd。nsq client 通过访问一个已配置的 nsqlookupd 地址,发现所有 nsqlookupd。获得 nsqlookupd 后,nsq client 遍历得到的 nsqlookupd 地址,进行 lookup 节点发现。nsq client 合并遍历获得的节点信息并返回。nsq client 在访问 listlookup 以及 lookup 服务失败的场景下(如,访问超时),nsq client 可以尝试重试。lookup 超过最大重试次数后依然失败的情况,nsq client 可以降低访问该 nsqlookupd 的优先级。client 定期查询 lookup,保证 client 更新连接到有效的 nsqd。</p>
<h2>nsqd 建连</h2>
<p>自研版 nsqd 在建连时遵照<sup id="fnref-1"><a href="#fn-1" class="footnote-ref">1</a></sup>中描述的建连步骤,通过 lookup 返回结果中 partitions 字段中的{broadcast_address}:{tcp_port}建立 TCP 连接。自研版中,一个独立的 TCP 连接对应一个 topic 的一个分区。consumer 在建连的时候需要建立与分区数量对应的 TCP 连接,以接收到所有的分区中的消息。client 的基本建连过程依然遵守<sup id="fnref-1"><a href="#fn-1" class="footnote-ref">1</a></sup>中的 4 步:</p>
<ul>
<li>client 发送 magic 标志</li>
<li>client 发送 an IDENTIFY command 并处理返回结果</li>
<li>client 发送 SUB command (指定目标的 topic 以及分区), 并处理返回结果</li>
<li>client 发送 RDY 命令</li>
</ul>
<p>client 通过自研版 NSQ 中拓展的SUB命令,连接到指定 topic 的指定分区。</p>
<pre><code>SUB &lt;topic&gt; &lt;channel_name&gt; &lt;topic_partition&gt; \n
topic_name -- 消费的 topic 名称
topic_partition -- topic 的合法分区名
channel_name -- channel</code></pre>
<p>Response:</p>
<pre><code>OK</code></pre>
<p>Error response:</p>
<pre><code>E_INVALID
E_BAD_TOPIC
E_BAD_CHANNEL
E_SUB_ORDER_IS_MUST 当前 topic 只支持顺序消费</code></pre>
<p>client 在建连过程中,向 lookup 返回的每一个 nsqd partition 发送该命令。SUB 命令的出错响应中,自研版本 NSQ 中加入了最后一个错误代码,当 client SUB 一个配置为顺序消费的 topic 时,client 会收到该错误。相应的携带分区号的 PUB 命令格式为:</p>
<pre><code>PUB &lt;topic_name&gt; &lt;topic_partition&gt;\n
[ 4-byte size in bytes ][ N-byte binary data ]
topic -- 合法 topic 名称
partitionId -- 合法分区名</code></pre>
<p>Response:</p>
<pre><code>OK</code></pre>
<p>Error response:</p>
<pre><code>E_INVALID
E_BAD_TOPIC
E_BAD_MESSAGE
E_PUB_FAILED
E_FAILED_ON_NOT_LEADER 当前尝试写入的 nsqd 节点在副本中不是 leader
E_FAILED_ON_NOT_WRITABLE 当前的 nqd 节点禁止写入消息
E_TOPIC_NOT_EXIST 向当前连接中写入不存在的 topic 消息</code></pre>
<p>自研版本 NSQ 中加入了最后三个错误代码,分别用于提示当前尝试写入的 nsqd 节点在副本中不是 leader,以及当前的 nqd 节点禁止写入。client 在接收到错误的时候,应该直接关闭 TCP 连接,等待 lookup 定时查询更新 nsqd 节点信息,或者立刻发起 lookup 查询。如果没有传入 partition id, 服务端会选择默认的 partition. 客户端可以选择 topic 的 partition 发送算法,可以根据负载情况选择一个 partition 发送,也可以固定的 Key 发送到固定的 partition。client 在消费时,可以指定只消费一个 partition 还是消费所有 partition。每个 partition 会建立独立的 socket 连接接收消息。client 需要处理多个 partition 的 channel 消费问题。</p>
<h2>发送/接收消息</h2>
<p>主要讨论生产者和消费者对消息的处理</p>
<h3>生产者发送消息</h3>
<p>client的消息生产流程如下:</p>
<p><img src="/img/remote/1460000019178542" alt="" title=""></p>
<p>建连过程中,对于消息生产者,client 在接收到对于 IDENTITY 的响应之后,使用 PUB 命令向连接发送消息。client 在 PUB 命令后需要解析收到的响应,出现 Error response 的情况下,client 应当关闭当前连接,并向 lookup 服务重新获取 nsqd 节点信息。client 可以将 nsqd 连接通过池化,在生产时进行复用,连接池中指定 topic 的连接为空时,client 将初始化该连接,因失败而关闭的连接将不返回连接池。</p>
<h3>消费者接收消息</h3>
<p>client 的消费流程如下:</p>
<p><img src="/img/remote/1460000019178543" alt="" title=""></p>
<p>client 在和 nsqd 建立连接后,使用异步方式消费消息。nsqd 定时向连接中发送心跳相应,用于检查 client 的活性。client 在收到心跳帧后,向 nsqd 回应 NOP。当 nsqd 连续两次发送心跳未能收到回应后,nsqd 连接将在服务端关闭。参考<sup id="fnref-1"><a href="#fn-1" class="footnote-ref">1</a></sup>中消息处理章节的相关内容,client 在消费消息的时候有如下情景:</p>
<ol>
<li>消息被顺利消费的情况下,FIN 通过 nsqd 连接发送;</li>
<li>消息的消费失败的情况下,client 需要通知 nsqd 该条消息消费失败。client 通过 REQ 命令携带延时参数,通知 nsqd 将消息重发。如果消费者没有指定延时参数,client 可以根据消息的 attempt 参数,计算延时参数。client 可以允许消费者指定当某条消息的消费次数超过指定的次数,client 是否可以将该条消息丢弃,或者重新发送至 nsqd 消息队列尾部去后,再 FIN;</li>
<li>消息的消费需要更多的时间,client 发送 TOUCH 命令,重置 nsqd 的超时时间。TOUCH 命令可以重复发送,直到消息超时,或者 client 发送 FIN,REQ 命令。是否发送 TOUCH 命令,决定权应当由消费者决定,client 本身不应当使用 TOUCH;</li>
<li>下发至 client 的消息超时,此时 nsqd 将重发该消息。此种情况下,client 可能重复消费到消息。消费者在保证消息消费的幂等性的同时,对于重发消息 client 应当能够正常消费;</li>
</ol>
<h3>消息 ACK</h3>
<p>自研版本 NSQ 中,对原有的消息 ID 进行了改造,自研版本中的消息 ID 长度依然为 16 字节:</p>
<p>高位开始的 16 字节,是自研版 NSQ 的内部 ID,后 16 字节是该条消息的 TraceID,用于 client 实现消息追踪功能。</p>
<h2>顺序消费</h2>
<p>基于 topic 分区的消息顺序生产消费,是自研版 NSQ 中的新功能。自研版 NSQ 允许生产者通过 shardingId 映射将消息发送到固定 topic 分区。建立连接时,消费者在发送 IDENTIFY 后,通过新的 SubOrder 命令连接到顺序消费 topic。顺序消费时,nsqd 的分区在接收到来自 client 的 FIN 确认之前,将不会推送下一条消息。</p>
<p>在 nsqd 配置为顺序消费的 topic 需要 nsq client 通过 SubOrder 进行消费。向顺序消费 topic 发送 Sub 命令将会收到错误信息,同时连接将被关闭。<code>E_SUB_ORDER_IS_MUST</code><br>client 在启动消费者前,可以通过配置指导 client 在 SUB 以及 SUB_ORDER 命令之间切换,或者基于 topic 进行切换。SUB_ORDER 在 TCP 协议的格式如下:</p>
<pre><code>
topic_name -- 进行顺序消息的 topic 名称
channel_name -- channel 名称
topic_partition -- topic 分区名称</code></pre>
<p>NSQ 新集群中,消息的顺序生产/消费基于 topic 分区。消息生产者通过指定 shardingID,向目标 partition 发送消息;消息消费者通过指定分区 ID,从指定分区接收消息。Client 进行顺序消费时时,TCP 连接的 RDY 值相当于将被 NSQ 服务端指定为 1,在当前消息 Finish 之前不会推送下一条。NSQ 服务器端 topic 进行强制消费配置,当消费场景中日志出现</p>
<p>错误消息时,说明该 topic 必须进行顺序消费。</p>
<p>顺序消费的场景由消息生产这个以及消息消费者两方的操作完成:</p>
<ul>
<li>消息生产者通过 SUB_ORDER 命令,连接到 Topic 所在的所有 NSQd 分区;</li>
<li>消息消费者通过设置 shardingID 映射,将消息发送到指定 NSQd 的指定 partition,进行生产;</li>
</ul>
<h3>顺序消费场景下的消息生产</h3>
<p>client 在进行消息生产时,将携带有相同 shardingID 的消息投递到同一分区中,分区的消息则通过 lookup 服务发现。作为生产者,client 在 lookup 请求中包含 metainfo 参数,用于获得 topic 的分区总数。client 负责将 shardingID 映射到 topic 分区,同时保证映射的一致性:具有相同的 shardindID 的消息始终被投递到固定的分区连接中。当 shardingID 映射到的 topic 分区对于 client 不可达时,client 结束发送,告知生产者返回错误信息,并立即更新 lookup。</p>
<h3>顺序消费场景下的消息消费</h3>
<p>client 在进行消息消费时,通过 SUB_ORDER 命令连接到 topic 所有分区上。顺序消费的场景中,当某条消费的消息超时或 REQUEUE 后,nsq 将会立即将该条消息下发。消息超时或者超过指定重试次数后的策略由消费者指定,client 可以对于重复消费的消息打印日志或者告警。</p>
<h2>消息追踪功能的实现</h2>
<p>新版 NSQ 通过在消息 ID 中增加 TraceID,对消息在 NSQ 中的生命周期进行追踪,client 通过 PUBTRACE 新命令将需要追踪的消息发送到 NSQ,PUB_TRACE 命令将包含 traceID 和消息字节码,格式如下:</p>
<pre><code>[ 4-byte size in bytes ][8-byte size trace id][ N-byte binary data ]</code></pre>
<p>相较于 PUB 命令,PUB_TRACE 在消息体中增加了 traceID 字段,client 在实现时,传递 64 位整数。NSQ 对 PUB_TRACE 的响应格式如下:</p>
<p>client 可通过配置或者动态开关,开启或关闭消息追踪功能,让生产者在 PUB 和 PUB_TRACE 命令之间进行切换。为了得到完整的 Trace 信息,建议 client 在生产者端打印 PUB_TRACE 响应返回的信息,在消费者端打印收到消息的 TraceID 和时间。</p>
<h2>总结</h2>
<p>本文结合自研版 NSQ 新特性,讨论了构建支持服务发现、顺序消费、消息追踪等新特性的 client 过程中的一些实践。</p>
<p><img src="/img/bVbsDnm?w=640&h=400" alt="clipboard.png" title="clipboard.png"></p>
<hr>
<ol><li id="fn-1">1 <a href="#fnref-1" class="footnote-backref">↩</a>
</li></ol>
浅析 Spark Shuffle 内存使用
https://segmentfault.com/a/1190000019157848
2019-05-13T10:10:26+08:00
2019-05-13T10:10:26+08:00
有赞技术
https://segmentfault.com/u/youzantech
8
<p>在使用 Spark 进行计算时,我们经常会碰到作业 (Job) Out Of Memory(OOM) 的情况,而且很大一部分情况是发生在 Shuffle 阶段。那么在 Spark Shuffle 中具体是哪些地方会使用比较多的内存而有可能导致 OOM 呢? 为此,本文将围绕以上问题梳理 Spark 内存管理和 Shuffle 过程中与内存使用相关的知识;然后,简要分析下在 Spark Shuffle 中有可能导致 OOM 的原因。</p>
<h2>一、Spark 内存管理和消费模型</h2>
<p>在分析 Spark Shuffle 内存使用之前。我们首先了解下以下问题:当一个 Spark 子任务 (Task) 被分配到 Executor 上运行时,Spark 管理内存以及消费内存的大体模型是什么样呢?(注:由于 OOM 主要发生在 Executor 端,所以接下来的讨论主要针对 Executor 端的内存管理和使用)。<br></p>
<p>1,在 Spark 中,使用抽象类 MemoryConsumer 来表示需要使用内存的消费者。在这个类中定义了分配,释放以及 Spill 内存数据到磁盘的一些方法或者接口。具体的消费者可以继承 MemoryConsumer 从而实现具体的行为。 因此,在 Spark Task 执行过程中,会有各种类型不同,数量不一的具体消费者。如在 Spark Shuffle 中使用的 ExternalAppendOnlyMap, ExternalSorter 等等(具体后面会分析)。<br><br>2,MemoryConsumer 会将申请,释放相关内存的工作交由 TaskMemoryManager 来执行。当一个 Spark Task 被分配到 Executor 上运行时,会创建一个 TaskMemoryManager。在 TaskMemoryManager 执行分配内存之前,需要首先向 MemoryManager 进行申请,然后由 TaskMemoryManager 借助 MemoryAllocator 执行实际的内存分配。 <br><br>3,Executor 中的 MemoryManager 会统一管理内存的使用。由于每个 TaskMemoryManager 在执行实际的内存分配之前,会首先向 MemoryManager 提出申请。因此 MemoryManager 会对当前进程使用内存的情况有着全局的了解。<br><br>MemoryManager,TaskMemoryManager 和 MemoryConsumer 之前的对应关系,如下图。总体上,一个 MemoryManager 对应着至少一个 TaskMemoryManager (具体由 executor-core 参数指定),而一个 TaskMemoryManager 对应着多个 MemoryConsumer (具体由任务而定)。</p>
<p><img src="/img/bVbsxYs?w=2122&h=782" alt="clipboard.png" title="clipboard.png"></p>
<p>了解了以上内存消费的整体过程以后,有两个问题需要注意下:<br><br>1,当有多个 Task 同时在 Executor 上执行时, 将会有多个 TaskMemoryManager 共享 MemoryManager 管理的内存。那么 MemoryManager 是怎么分配的呢?答案是每个任务可以分配到的内存范围是 [1 / (2 * n), 1 / n],其中 n 是正在运行的 Task 个数。因此,多个并发运行的 Task 会使得每个 Task 可以获得的内存变小。<br><br>2,前面提到,在 MemoryConsumer 中有 Spill 方法,当 MemoryConsumer 申请不到足够的内存时,可以 Spill 当前内存到磁盘,从而避免无节制的使用内存。但是,对于堆内内存的申请和释放实际是由 JVM 来管理的。因此,在统计堆内内存具体使用量时,考虑性能等各方面原因,Spark 目前采用的是抽样统计的方式来计算 MemoryConsumer 已经使用的内存,从而造成堆内内存的实际使用量不是特别准确。从而有可能因为不能及时 Spill 而导致 OOM。</p>
<h2>二、Spark Shuffle 过程</h2>
<p>整体上 Spark Shuffle 具体过程如下图,主要分为两个阶段:Shuffle Write 和 Shuffle Read。<br><br><br>Write 阶段大体经历排序(最低要求是需要按照分区进行排序),可能的聚合 (combine) 和归并(有多个文件 spill 磁盘的情况 ),最终每个写 Task 会产生数据和索引两个文件。其中,数据文件会按照分区进行存储,即相同分区的数据在文件中是连续的,而索引文件记录了每个分区在文件中的起始和结束位置。<br><br>而对于 Shuffle Read, 首先可能需要通过网络从各个 Write 任务节点获取给定分区的数据,即数据文件中某一段连续的区域,然后经过排序,归并等过程,最终形成计算结果。</p>
<p><img src="/img/bVbsxYZ?w=2118&h=972" alt="clipboard.png" title="clipboard.png"></p>
<p>对于 Shuffle Write,Spark 当前有三种实现,具体分别为 BypassMergeSortShuffleWriter, UnsafeShuffleWriter 和 SortShuffleWriter (具体使用哪一个实现有一个判断条件,此处不表)。而 Shuffle Read 只有一种实现。</p>
<h3>2.1 Shuffle Write 阶段分析</h3>
<h4>2.1.1 BypassMergeSortShuffleWriter 分析</h4>
<p>对于 BypassMergeSortShuffleWriter 的实现,大体实现过程是首先为每个分区创建一个临时分区文件,数据写入对应的分区文件,最终所有的分区文件合并成一个数据文件,并且产生一个索引文件。由于这个过程不做排序,combine(如果需要 combine 不会使用这个实现)等操作,因此对于 BypassMergeSortShuffleWriter,总体来说是不怎么耗费内存的。<br></p>
<h4>2.1.2 SortShuffleWriter 分析</h4>
<p>SortShuffleWriter 是最一般的实现,也是日常使用最频繁的。SortShuffleWriter 主要委托 ExternalSorter 做数据插入,排序,归并 (Merge),聚合 (Combine) 以及最终写数据和索引文件的工作。ExternalSorter 实现了之前提到的 MemoryConsumer 接口。下面分析一下各个过程使用内存的情况:<br><br>1,对于数据写入,根据是否需要做 Combine,数据会被插入到 PartitionedAppendOnlyMap 这个 Map 或者 PartitionedPairBuffer 这个数组中。每隔一段时间,当向 MemoryManager 申请不到足够的内存时,或者数据量超过 spark.shuffle.spill.numElementsForceSpillThreshold 这个阈值时 (默认是 Long 的最大值,不起作用),就会进行 Spill 内存数据到文件。假设可以源源不断的申请到内存,那么 Write 阶段的所有数据将一直保存在内存中,由此可见,PartitionedAppendOnlyMap 或者 PartitionedPairBuffer 是比较吃内存的。 <br><br>2,无论是 PartitionedAppendOnlyMap 还是 PartitionedPairBuffer, 使用的排序算法是 TimSort。在使用该算法是正常情况下使用的临时额外空间是很小,但是最坏情况下是 n / 2,其中 n 表示待排序的数组长度(具体见 TimSort 实现)。<br><br>3,当插入数据因为申请不到足够的内存将会 Spill 数据到磁盘,在将最终排序结果写入到数据文件之前,需要将内存中的 PartitionedAppendOnlyMap 或者 PartitionedPairBuffer 和已经 spill 到磁盘的 SpillFiles 进行合并。Merge 的大体过程如下图。</p>
<p><img src="/img/bVbsxZc?w=1376&h=784" alt="clipboard.png" title="clipboard.png"></p>
<p>从上图可见,大体差不多就是归并排序的过程,由此可见这个过程是没有太多额外的内存消耗。归并过程中的聚合计算大体也是差不多的过程,唯一需要注意的是键值碰撞的情况,即当前输入的各个有序队列的键值的哈希值相同,但是实际的键值不等的情况。这种情况下,需要额外的空间保存所有键值不同,但哈希值相同值的中间结果。但是总体上来说,发生这种情况的概率并不是特别大。 <br><br><br>4,写数据文件的过程涉及到不同数据流之间的转化,而在流的写入过程中,一般都有缓存,主要由参数 spark.shuffle.file.buffer 和 spark.shuffle.spill.batchSize 控制,总体上这部分开销也不大。<br><br>以上分析了 SortShuffleWriter write 阶段的主要过程,从中可以看出主要的内存消耗在写入 PartitionedAppendOnlyMap 或者 PartitionedPairBuffer 这个阶段。</p>
<h4>2.1.3 UnsafeShuffleWriter</h4>
<p>UnsafeShuffleWriter 是对 SortShuffleWriter 的优化,大体上也和 SortShuffleWriter 差不多,在此不再赘述。从内存使用角度看,主要差异在以下两点:<br><br>一方面,在 SortShuffleWriter 的 PartitionedAppendOnlyMap 或者 PartitionedPairBuffer 中,存储的是键值或者值的具体类型,也就是 Java 对象,是反序列化过后的数据。而在 UnsafeShuffleWriter 的 ShuffleExternalSorter 中数据是序列化以后存储到实际的 Page 中,而且在写入数据过程中会额外写入长度信息。总体而言,序列化以后数据大小是远远小于序列化之前的数据。 <br><br><br>另一方面,UnsafeShuffleWriter 中需要额外的存储记录(LongArray),它保存着分区信息和实际指向序列化后数据的指针(经过编码的Page num 以及 Offset)。相对于 SortShuffleWriter, UnsafeShuffleWriter 中这部分存储的开销是额外的。<br></p>
<h3>2.2 Shuffle Read 阶段分析</h3>
<p>Spark Shuffle Read 主要经历从获取数据,序列化流,添加指标统计,可能的聚合 (Aggregation) 计算以及排序等过程。大体流程如下图。</p>
<p><img src="/img/bVbsxZu?w=1194&h=842" alt="clipboard.png" title="clipboard.png"></p>
<p>以上计算主要都是迭代进行。在以上步骤中,比较复杂的操作是从远程获取数据,聚合和排序操作。接下来,依次分析这三个步骤内存的使用情况。<br><br><br>1,数据获取分为远程获取和本地获取。本地获取将直接从本地的 BlockManager 取数据, 而对于远程数据,需要走网络。在远程获取过程中,有相关参数可以控制从远程并发获取数据的大小,正在获取数据的请求数,以及单次数据块请求是否放到内存等参数。具体参数包括 spark.reducer.maxSizeInFlight (默认 48M),spark.reducer.maxReqsInFlight, spark.reducer.maxBlocksInFlightPerAddress 和 spark.maxRemoteBlockSizeFetchToMem。考虑到数据倾斜的场景,如果 Map 阶段有一个 Block 数据特别的大,默认情况由于 spark.maxRemoteBlockSizeFetchToMem 没有做限制,所以在这个阶段需要将需要获取的整个 Block 数据放到 Reduce 端的内存中,这个时候是非常的耗内存的。可以设置 spark.maxRemoteBlockSizeFetchToMem 值,如果超过该阈值,可以落盘,避免这种情况的 OOM。 另外,在获取到数据以后,默认情况下会对获取的数据进行校验(参数 spark.shuffle.detectCorrupt 控制),这个过程也增加了一定的内存消耗。<br><br>2,对于需要聚合和排序的情况,这个过程是借助 ExternalAppendOnlyMap 来实现的。整个插入,Spill 以及 Merge 的过程和 Write 阶段差不多。总体上,这块也是比较消耗内存的,但是因为有 Spill 操作,当内存不足时,可以将内存数据刷到磁盘,从而释放内存空间。<br></p>
<h2>三、Spark Shuffle OOM 可能性分析</h2>
<p>围绕内存使用,前面比较详细的分析了 Spark 内存管理以及在 Shuffle 过程可能使用较多内存的地方。接下来总结的要点如下:<br><br><br>1,首先需要注意 Executor 端的任务并发度,多个同时运行的 Task 会共享 Executor 端的内存,使得单个 Task 可使用的内存减少。<br><br>2,无论是在 Map 还是在 Reduce 端,插入数据到内存,排序,归并都是比较都是比较占用内存的。因为有 Spill,理论上不会因为数据倾斜造成 OOM。 但是,由于对堆内对象的分配和释放是由 JVM 管理的,而 Spark 是通过采样获取已经使用的内存情况,有可能因为采样不准确而不能及时 Spill,导致OOM。<br><br>3,在 Reduce 获取数据时,由于数据倾斜,有可能造成单个 Block 的数据非常的大,默认情况下是需要有足够的内存来保存单个 Block 的数据。因此,此时极有可能因为数据倾斜造成 OOM。 可以设置 spark.maxRemoteBlockSizeFetchToMem 参数,设置这个参数以后,超过一定的阈值,会自动将数据 Spill 到磁盘,此时便可以避免因为数据倾斜造成 OOM 的情况。在我们的生产环境中也验证了这点,在设置这个参数到合理的阈值后,生产环境任务 OOM 的情况大大减少了。<br><br>4,在 Reduce 获取数据后,默认情况会对数据流进行解压校验(参数 spark.shuffle.detectCorrupt)。正如在代码注释中提到,由于这部分没有 Spill 到磁盘操作,也有很大的可性能会导致 OOM。在我们的生产环境中也有碰到因为检验导致 OOM 的情况。</p>
<h2>四、小结</h2>
<p>本文主要围绕内存使用这个点,对 Spark shuffle 的过程做了一个比较详细的梳理,并且分析了可能造成 OOM 的一些情况以及我们在生产环境碰到的一些问题。本文主要基于作者对 Spark 源码的理解以及实际生产过程中遇到 OOM 案例总结而成,限于经验等各方面原因,难免有所疏漏或者有失偏颇。如有问题,欢迎联系一起讨论。</p>
<p><img src="/img/bVbq1xK?w=640&h=400" alt="clipboard.png" title="clipboard.png"></p>
有赞百亿级日志系统架构设计
https://segmentfault.com/a/1190000018867825
2019-04-15T10:06:56+08:00
2019-04-15T10:06:56+08:00
有赞技术
https://segmentfault.com/u/youzantech
17
<h2>一、概述</h2>
<p>日志是记录系统中各种问题信息的关键,也是一种常见的海量数据。日志平台为集团所有业务系统提供日志采集、消费、分析、存储、索引和查询的一站式日志服务。主要为了解决日志分散不方便查看、日志搜索操作复杂且效率低、业务异常无法及时发现等等问题。</p>
<p>随着有赞业务的发展与增长,每天都会产生百亿级别的日志量(据统计,平均每秒产生 50 万条日志,峰值每秒可达 80 万条)。日志平台也随着业务的不断发展经历了多次改变和升级。本文跟大家分享有赞在当前日志系统的建设、演进以及优化的经历,这里先抛砖引玉,欢迎大家一起交流讨论。</p>
<h2>二、原有日志系统</h2>
<p>有赞从 16 年就开始构建适用于业务系统的统一日志平台,负责收集所有系统日志和业务日志,转化为流式数据,通过 flume 或者 logstash 上传到日志中心(kafka 集群),然后共 Track、Storm、Spark 及其它系统实时分析处理日志,并将日志持久化存储到 HDFS 供离线数据分析处理,或写入 ElasticSearch 提供数据查询。整体架构如下图 所示。</p>
<p><img src="/img/remote/1460000018867828" alt="图2-1 原有日志系统架构" title="图2-1 原有日志系统架构"></p>
<p>随着接入的应用的越来越多,接入的日志量越来越大,逐渐出现一些问题和新的需求,主要在以下几个方面:</p>
<p>1.业务日志没有统一的规范,业务日志格式各式各样,新应用接入无疑大大的增加了日志的分析、检索成本。<br>2.多种数据日志数据采集方式,运维成本较高<br>3.存储方面,</p>
<p>-采用了 Es 默认的管理策略,所有的 index 对应 3*2个shard(3 个 primary,3 个 replica),有部分 index 数量较大,对应单个 shard 对应的数据量就会很大,导致有 hot node,出现很多 bulk request rejected,同时磁盘 IO 集中在少数机器上。</p>
<p>-对于 bulk request rejected 的日志没有处理,导致业务日志丢失</p>
<p>-日志默认保留 7 天,对于 ssd 作为存储介质,随着业务增长,存储成本过于高昂</p>
<p>-另外 Elasticsearch 集群也没有做物理隔离,Es 集群 oom 的情况下,使得集群内全部索引都无法正常工作,不能为核心业务运行保驾护航</p>
<p>4.日志平台收集了大量用户日志信息,当时无法直接的看到某个时间段,哪些错误信息较多,增加定位问题的难度。</p>
<h2>三、现有系统演进</h2>
<p>日志从产生到检索,主要经历以下几个阶段:采集->传输->缓冲->处理->存储->检索,详细架构如下图所示:</p>
<p><img src="/img/remote/1460000018867829" alt="" title=""></p>
<h2>3.1日志接入</h2>
<p>日志接入目前分为两种方式,SDK 接入和调用 Http Web 服务接入</p>
<p>-SDK 接入:日志系统提供了不同语言的 SDK,SDK 会自动将日志的内容按照统一的协议格式封装成最终的消息体,并最后最终通过 TCP 的方式发送到日志转发层(rsyslog-hub)</p>
<p>-Http Web 服务接入:有些无法使用 SDk 接入日志的业务,可以通过 Http 请求直接发送到日志系统部署的 Web 服务,统一由 web protal 转发到日志缓冲层的 kafka 集群</p>
<h3>3.2日志采集</h3>
<p><img src="/img/remote/1460000018867830" alt="" title=""><br>现在有 rsyslog-hub 和 web portal 做为日志传输系统,rsyslog 是一个快速处理收集系统日志的程序,提供了高性能、安全功能和模块化设计。之前系统演进过程中使用过直接在宿主机上部署 flume 的方式,由于 flume 本身是 java 开发的,会比较占用机器资源而统一升级为使用 rsyslog 服务。为了防止本地部署与 kafka 客户端连接数过多,本机上的 rsyslog 接收到数据后,不做过多的处理就直接将数据转发到 rsyslog-hub 集群,通过 LVS 做负载均衡,后端的 rsyslog-hub 会通过解析日志的内容,提取出需要发往后端的 kafka topic。</p>
<h3>3.3日志缓冲</h3>
<p>Kafka 是一个高性能、高可用、易扩展的分布式日志系统,可以将整个数据处理流程解耦,将 kafka 集群作为日志平台的缓冲层,可以为后面的分布式日志消费服务提供异步解耦、削峰填谷的能力,也同时具备了海量数据堆积、高吞吐读写的特性。</p>
<h3>3.4日志切分</h3>
<p>日志分析是重中之重,为了能够更加快速、简单、精确地处理数据。日志平台使用 spark streaming 流计算框架消费写入 kafka 的业务日志,Yarn 作为计算资源分配管理的容器,会跟不同业务的日志量级,分配不同的资源处理不同日志模型。</p>
<p>整个 spark 任务正式运行起来后,单个批次的任务会将拉取的到所有的日志分别异步的写入到 ES 集群。业务接入之前可以在管理台对不同的日志模型设置任意的过滤匹配的告警规则,spark 任务每个 excutor 会在本地内存里保存一份这样的规则,在规则设定的时间内,计数达到告警规则所配置的阈值后,通过指定的渠道给指定用户发送告警,以便及时发现问题。当流量突然增加,es 会有 bulk request rejected 的日志会重新写入 kakfa,等待补偿。</p>
<h3>3.5日志存储</h3>
<p>-原先所有的日志都会写到 SSD 盘的 ES 集群,logIndex 直接对应 ES 里面的索引结构,随着业务增长,为了解决 Es 磁盘使用率单机最高达到 70%~80% 的问题,现有系统采用 Hbase 存储原始日志数据和 ElasticSearch 索引内容相结合的方式,完成存储和索引。</p>
<p>-Index 按天的维度创建,提前创建index会根据历史数据量,决定创建明日 index 对应的 shard 数量,也防止集中创建导致数据无法写入。现在日志系统只存近 7 天的业务日志,如果配置更久的保存时间的,会存到归档日志中。</p>
<p>-对于存储来说,Hbase、Es 都是分布式系统,可以做到线性扩展。</p>
<h2>四、多租户</h2>
<p>随着日志系统不断发展,全网日志的 QPS 越来越大,并且部分用户对日志的实时性、准确性、分词、查询等需求越来越多样。为了满足这部分用户的需求,日志系统支持多租户的的功能,根据用户的需求,分配到不同的租户中,以避免相互影响。 </p>
<p><img src="/img/remote/1460000018867831" alt="" title=""></p>
<p>针对单个租户的架构如下:</p>
<p><img src="/img/remote/1460000018867832" alt="" title=""></p>
<p>-SDK:可以根据需求定制,或者采用天网的 TrackAppender 或 SkynetClient</p>
<p>-Kafka 集群:可以共用,也可以使用指定 Kafka 集群</p>
<p>-Spark 集群:目前的 Spark 集群是在 yarn 集群上,资源是隔离的,一般情况下不需要特地做隔离</p>
<p>-存储:包含 ES 和 Hbase,可以根据需要共用或单独部署 ES 和 Hbase</p>
<h2>五、现有问题和未来规划</h2>
<p>目前,有赞日志系统作为集成在天网里的功能模块,提供简单易用的搜索方式,包括时间范围查询、字段过滤、NOT/AND/OR<br>、模糊匹配等方式,并能对查询字段高亮显示,定位日志上下文,基本能满足大部分现有日志检索的场景,但是日志系统还存在很多不足的地方,主要有:</p>
<p>1.缺乏部分链路监控:日志从产生到可以检索,经过多级模块,现在采集,日志缓冲层还未串联,无法对丢失情况进行精准监控,并及时推送告警。</p>
<p>2.现在一个日志模型对应一个 kafka topic,topic 默认分配三个 partition,由于日志模型写入日志量上存在差异,导致有的 topic 负载很高,有的 topic 造成一定的资源浪费,且不便于资源动态伸缩。topic 数量过多,导致 partition 数量过多,对 kafka 也造成了一定资源浪费,也会增加延迟和 Broker 宕机恢复时间。</p>
<p>3.目前 Elasticsearch 中文分词我们采用 ik_max_word,分词目标是中文,会将文本做最细粒度的拆分,但是日志大部分都是英文,分词效果并不是很好。</p>
<p>上述的不足之处也是我们以后努力改进的地方,除此之外,对于日志更深层次的价值挖掘也是我们探索的方向,从而为业务的正常运行保驾护航。</p>
<p><strong>4月27日(周六)下午13:30,有赞技术中间件团队联合Elastic中文社区,围绕Elastic的开源产品及周边技术,在杭州举办一场线下技术交流活动。</strong></p>
<p><strong>本次活动免费开放,限额200名。</strong></p>
<p><strong>扫描下图二维码,回复“报名”即可参加</strong></p>
<p><img src="/img/remote/1460000018867833" alt="" title=""></p>
<p><strong>欢迎参加,我们一起聊聊~</strong></p>
有赞订单搜索AKF架构演进之路
https://segmentfault.com/a/1190000018829230
2019-04-11T09:50:26+08:00
2019-04-11T09:50:26+08:00
有赞技术
https://segmentfault.com/u/youzantech
9
<h2>一、前情提要</h2>
<p>时节如流,两年前的今天写了有赞订单管理的三生三世与十面埋伏,转眼两年过去了,这套架构发展的如何,遇到了什么新的挑战和收获,今天主要来一起整理回顾下有赞订单搜索AKF架构演进之路。</p>
<h3>1.1 分久必合</h3>
<p>之前将散落在 DB 多个分片中的数据在 ES 做了一次聚合,带来了巨大的好处,同步任务少,维护成本低。尤其是订单迁移这一块,之前由于是分片设计,所以当订单触发迁移时候,需要将数据插入新分片,确认无误后还需要删除老分片数据,流程繁琐易错,统一收拢后对于 ES 来说,各个端订单迁移,都只是一次更新操作,无比简单。补充介绍下订单迁移:</p>
<ul>
<li>买家订单迁移 针对新用户转变为关注用户,从系统 mock 的 buyerId 到真正分配的 buyerId 订单的迁移。</li>
<li>卖家订单迁移 针对店铺模型升级,比如从微商城到零售连锁,原门店独立需要迁移订单。</li>
</ul>
<h2>二、新的挑战</h2>
<p>然而随着业务的不断发展,聚合后的索引也开始暴露各种问题。</p>
<ul>
<li>数据量增长比预期要快很多,亿级别的索引,慢查也开始出现,像一个庞然大物蠢蠢欲动。</li>
<li>为了满足商家的一些个性搜索需求,很多搜索需求都属于极少数会查询到的,但是都会被加到同一个主索引中,使得主索引字段不断增多。</li>
</ul>
<h2>三、应对</h2>
<h3>3.1 合久必分</h3>
<p>为了解决以上挑战,踏上了可扩展性架构拆分之路。简单介绍下有赞订单搜索的几个维度:</p>
<ul>
<li>B 端商家单店搜索(商家管理单店订单)</li>
<li>B 端商家总店跨分店搜索(连锁总店管理分店订单)</li>
<li>C 端买家跨店铺搜索(买家管理跨店所有订单)</li>
</ul>
<p>由于既要 ToB 又要 ToC ,而 B 端零售连锁商家的引入,增加了不少复杂度,因为有总店 MU 来管理多个 BU 单元,需要跨多个店铺查询。无论怎么分片,单一维度都必然存在跨分片搜索的场景。计划优先按数据冷热分离来拆分,而如何区分和定义这个冷热数据?最近一天,一月,一段时间的搜索,都比较范,缺乏数据支撑。</p>
<blockquote>念念不忘,必有回响。</blockquote>
<h4>3.1.1 热状态索引</h4>
<p>于是观察了下我们的监控,发现了奇妙的规律。所有搜索场景中,常见的按支付方式,物流类型,商品名称,订单类型等搜索占比很少,而按订单状态搜索占比最多,约 53% ,也就是一半多的搜索流量全部来自于订单状态检索。</p>
<p><img src="/img/bVbrauB?w=1356&h=581" alt="clipboard.png" title="clipboard.png"></p>
<p>而细化了下这 53% 的订单状态搜索中,其中 3% 左右搜索终态订单(已完成,已关闭),其中 50% 所有流量全部都是搜热状态订单(待付款,待发货,待成团,待接单,已发货),-_- 忽略比较乱的枚举,历史多个版本统计合一。</p>
<p><img src="/img/bVbrauG?w=649&h=539" alt="clipboard.png" title="clipboard.png"></p>
<p>不禁让人兴奋,为什么?因为无论订单量如何激增,处于热状态的订单数不会持续暴增,因为所有订单都会陆续流转到终态,比如超时 30 分钟未付款,订单从待支付变成已关闭状态,比如订单发货 7 天后,从已发货状态变成已完成。统计了下,热状态订单总量在千万级别,且一直比较平稳的进行流转。</p>
<p><img src="/img/bVbrauN?w=1382&h=382" alt="clipboard.png" title="clipboard.png"></p>
<p>也就是说我们用这个千万级小索引,就承接了整个订单搜索一半左右的流量。无论是统计,总店查询,各种跨分片维度查询,都可以支持。因为它是一个热状态订单数据全集,包含所有分片场景,无比兴奋。目前该索引已在线上平稳运行近一年。</p>
<h4>3.1.2 时间分片索引</h4>
<p>那么对于那些终态订单,数据量随着订单状态流转会变得越来越大,如何扩展,时间分片是个不错的选择,有赞订单搜索早期最早做的切分就是按下单时间分片,之前业务数据量小,每半年一个,到后来发展改成了每 3 个月一个,到现在即使每一个月一个索引都显得有些庞大。具体还是要结合搜索场景,理论上终态订单检索的量比较小,也可以换个思维从产品层面有个引导,比如默认只展示最近半年订单,也是一种思路。</p>
<h3>3.2 扩展依据</h3>
<h4>3.2.1 AKF 扩展立方体</h4>
<p>在《架构即未来》与《架构真经》中都反复提到这个立方体,结合我们的实际情况,确实受益匪浅,给了我们指引的方法论。</p>
<p>X 轴 : 关注水平的数据和服务克隆,比如主备集群,数据完全一样复制。克隆多个系统(加机器)负载均衡分配请求。</p>
<ul>
<li>优点:成本最低,实施简单</li>
<li>缺点:当个产品过大时,服务响应变慢</li>
<li>场景:发展初期,业务复杂度低,需要增加系统容量</li>
</ul>
<p>Y 轴 : 关注应用中职责的划分,比如数据业务维度拆分。比如交易库,商品库,会员库拆分。</p>
<ul>
<li>优点:故障隔离,提高响应时间,更聚焦</li>
<li>缺点:成本相对较高</li>
<li>场景:业务复杂,数据量大,代码耦合度高,团队规模大</li>
</ul>
<p>Z 轴 : 关注服务和数据的优先级划分,数据用户维度拆分。比如常见的按用户维度买卖家切分数据分片。</p>
<ul>
<li>优点:降低故障风险,影响范围可控,可以带来更大的扩展性</li>
<li>缺点:成本最高</li>
<li>场景:用户指数级快速增长</li>
</ul>
<p><img src="/img/bVbrauT?w=1029&h=805" alt="clipboard.png" title="clipboard.png"></p>
<p>上面介绍的热状态订单拆分其实就是朝 Y 轴方向扩展,当然 AKF 可扩展立方体的精髓就在于不要一直只在一个轴方向上扩展,要根据不同的业务场景,数据规模,做到有针对性的扩展,理论上 XYZ 轴可以做到某种程度的无限扩展。目前有赞订单搜索的总体索引架构如下,涵盖 3 个轴方向。</p>
<h3>3.3 现状</h3>
<p><img src="/img/bVbravf?w=1001&h=390" alt="clipboard.png" title="clipboard.png"></p>
<h2>四、收获</h2>
<p>上面简单介绍了下有赞订单搜索 AKF 扩展之路,下面再简单聊下过程中的几个意外收获,受益良多,可以给类似业务同学一个可以尝试的参考。</p>
<h3>4.1 可扩展性索引字段设计</h3>
<p>之前迁移到 ES 里就是看中 ES 的多索引检索能力,然而多变的产品需求通过不断加字段的模式,也会使索引变得越来越大,不好维护,有没有一种可扩展性的方式,来以不变或者以小变应对需求的万变呢。答案是肯定的,list< String > 字段设计,比如目前开放了搜索扩展点给有赞云,商家可以自定义的建立自己的检索字段,K 和 V 都有商家自己把控,如何做到代码可配置化,业务代码无感知呢,按照我们的约定需要检索的字段进入 list< k_v > 格式,即可做到。关于细节订单管理系列博文之可配置化订单搜索博文中会详细进一步介绍。</p>
<h3>4.2 轻量级统计</h3>
<p>统计一直是各大公司比较重要的一块,有赞也是,几乎有订单的地方都会看到各种订单数统计,早期统计场景比较简单,比如统计待发货,已发货,退款订单等都可以通过一个 sql 或者一个脚本任务就可以统计出来,但是随着有赞业务发展的越来越快,比如统计一个加入担保交易+已经完成7天内+发生退款的订单数,普通的统计模式通过更改统计 sql ,再刷个离线数据也是能做到的,但是周期往往较长,而且不够灵活,一旦有部分统计失败报错的,排查问题很困难,只能再全量重新统计。而这里我们采用了另一种视角,用搜索来做统计,依赖于ES搜索默认返回的 total 作为统计值,可以无缝利用现有数据做任意维度任意组合的任意统计,随时提需求,即用即拿,非常轻量。关于细节也会在订单管理系列博文之配置化订单统计博文中会做详细进一步介绍。</p>
<h2>五、展望</h2>
<p>回望有赞订单管理 4 年的心路历程,收获良多,配置化订单搜索,配置化订单统计,配置化订单同步系列博文也会陆续发出(配置化订单导出博文已发),目前已从订单管理顺利毕业,后续主要负责有赞搜索中台业务线,诚邀有成长型思维,大数据思维和业务敏感度的同学加入,共建有赞搜索中台大业,简历直邮 wangye@youzan.com</p>
Druid Segment Balance 及其代价计算函数分析
https://segmentfault.com/a/1190000018802357
2019-04-09T11:00:55+08:00
2019-04-09T11:00:55+08:00
有赞技术
https://segmentfault.com/u/youzantech
0
<h2>一. 引言</h2>
<p>Druid 的查询需要有实时和历史部分的 Segment,历史部分的 Segment 由 Historical 节点加载,所以加载的效率直接影响了查询的 RT(不考虑缓存)。查询通常需要指定一个时间范围[StartTime, EndTime],该时间范围的内所有 Segment 需要由 Historical 加载,最差的情况是所有 Segment 不幸都储存在一个节点上,加载无疑会很慢;最好的情况是 Segment 均匀分布在所有的节点上,并行加载提高效率。所以 Segment 在 Historical 集群中分布就变得极为重要,Druid 通过 Coordinator 的 Balance 策略协调 Segment 在集群中的分布。</p>
<p>本文将分析 Druid 的 Balance 策略、源码及其代价计算函数,本文使用 Druid 的版本是 0.12.0。</p>
<h2>二. Balance方法解析</h2>
<h3>2.1 Balance 相关的配置</h3>
<p>Druid 目前有三种 Balance 算法: cachingCost, diskNormalized, Cost, 其中 cachingCost 是基于缓存的,diskNormalized 则是基于磁盘的 Balance 策略,本文不对前两种展开篇幅分析, Druid Coordinator 中开启 cost balance 的配置如下:</p>
<pre><code>druid.coordinator.startDelay=PT30S
druid.coordinator.period=PT30S 调度的时间
druid.coordinator.balancer.strategy=cost 默认
动态配置:
maxSegmentsToMove = 5 ##每次Balance最多移动多少个Segment</code></pre>
<h3>2.2 Cost 算法概述</h3>
<p>Cost 是 Druid 在 0.9.1 开始引入的,在 0.9.1 之前使用的 Balance 算法会存在 Segment 不能快速均衡,分布不均匀的情况,Cost 算法的核心思想是:当在做均衡的时候,随机选择一个 Segment(假设 Segment A ), 依次计算Segment A 和 Historical 节点上的所有 Segment 的 Cost,选取 Cost 值最小的节点,然后到该节点上重新加载 Segment</p>
<p><img src="/img/bVbq3vg?w=1080&h=434" alt="clipboard.png" title="clipboard.png"></p>
<h3>2.3 源码和流程图分析</h3>
<p>以下会省略一些不必要的代码 </p>
<p>DruidCoordinatorBalancer 类</p>
<pre><code>@Override
public DruidCoordinatorRuntimeParams run(DruidCoordinatorRuntimeParams params)
{
final CoordinatorStats stats = new CoordinatorStats();
// 不同tier层的分开Balance
params.getDruidCluster().getHistoricals().forEach((String tier, NavigableSet<ServerHolder> servers) -> {
balanceTier(params, tier, servers, stats);
});
return params.buildFromExisting().withCoordinatorStats(stats).build();
}</code></pre>
<p>DruidCoordinatorBalancer 类的 balanceTier 方法,主要是均衡入口函数</p>
<pre><code>private void balanceTier(DruidCoordinatorRuntimeParams params, String tier, SortedSet<ServerHolder> servers,CoordinatorStats stats){
final BalancerStrategy strategy = params.getBalancerStrategy();
final int maxSegmentsToMove = params.getCoordinatorDynamicConfig().getMaxSegmentsToMove();
currentlyMovingSegments.computeIfAbsent(tier, t -> new ConcurrentHashMap<>());
final List<ServerHolder> serverHolderList = Lists.newArrayList(servers);
//集群中只有一个 Historical 节点时不进行Balance
if (serverHolderList.size() <= 1) {
log.info("[%s]: One or fewer servers found. Cannot balance.", tier);
return;
}
int numSegments = 0;
for (ServerHolder server : serverHolderList) {
numSegments += server.getServer().getSegments().size();
}
if (numSegments == 0) {
log.info("No segments found. Cannot balance.");
return;
}
long unmoved = 0L;
for (int iter = 0; iter < maxSegmentsToMove; iter++) {
//通过随机算法选择一个候选Segment,该Segment会参与后面的Cost计算
final BalancerSegmentHolder segmentToMove = strategy.pickSegmentToMove(serverHolderList);
if (segmentToMove != null && params.getAvailableSegments().contains(segmentToMove.getSegment())) {
//找Cost最小的节点,Cost计算入口
final ServerHolder holder = strategy.findNewSegmentHomeBalancer(segmentToMove.getSegment(), serverHolderList);
//找到候选节点,发起一次Move Segment的任务
if (holder != null) {
moveSegment(segmentToMove, holder.getServer(), params);
} else {
++unmoved;
}
}
}
......
}</code></pre>
<p>Reservoir 随机算法,随机选择一个 Segment 进行 Balance。Segment 被选中的概率:</p>
<pre><code>public class ReservoirSegmentSampler
{
public BalancerSegmentHolder getRandomBalancerSegmentHolder(final List<ServerHolder> serverHolders)
{
final Random rand = new Random();
ServerHolder fromServerHolder = null;
DataSegment proposalSegment = null;
int numSoFar = 0;
//遍历所有List上的Historical节点
for (ServerHolder server : serverHolders) {
//遍历一个Historical节点上所有的Segment
for (DataSegment segment : server.getServer().getSegments().values()) {
int randNum = rand.nextInt(numSoFar + 1);
// w.p. 1 / (numSoFar+1), swap out the server and segment
// 随机选出一个Segment,后面的会覆盖前面选中的,以最后一个被选中为止。
if (randNum == numSoFar) {
fromServerHolder = server;
proposalSegment = segment;
}
numSoFar++;
}
}
if (fromServerHolder != null) {
return new BalancerSegmentHolder(fromServerHolder.getServer(), proposalSegment);
} else {
return null;
}
}
}</code></pre>
<p>继续调用到CostBalancerStrategy类的findNewSegmentHomeBalancer方法,其实就是找最合适的Historical节点</p>
<pre><code>@Override
public ServerHolder findNewSegmentHomeBalancer(DataSegment proposalSegment, List<ServerHolder> serverHolders){
return chooseBestServer(proposalSegment, serverHolders, true).rhs;
}
protected Pair<Double, ServerHolder> chooseBestServer(
final DataSegment proposalSegment,
final Iterable<ServerHolder> serverHolders,
final boolean includeCurrentServer
){
Pair<Double, ServerHolder> bestServer = Pair.of(Double.POSITIVE_INFINITY, null);
List<ListenableFuture<Pair<Double, ServerHolder>>> futures = Lists.newArrayList();
for (final ServerHolder server : serverHolders) {
futures.add(
exec.submit(
new Callable<Pair<Double, ServerHolder>>()
{
@Override
public Pair<Double, ServerHolder> call() throws Exception
{
//计算Cost:候选Segment和Historical节点上所有Segment的cost和
return Pair.of(computeCost(proposalSegment, server, includeCurrentServer), server);
}
}
)
);
}
final ListenableFuture<List<Pair<Double, ServerHolder>>> resultsFuture = Futures.allAsList(futures);
final List<Pair<Double, ServerHolder>> bestServers = new ArrayList<>();
bestServers.add(bestServer);
try {
for (Pair<Double, ServerHolder> server : resultsFuture.get()) {
if (server.lhs <= bestServers.get(0).lhs) {
if (server.lhs < bestServers.get(0).lhs) {
bestServers.clear();
}
bestServers.add(server);
}
}
//Cost最小的如果有多个,随机选择一个
bestServer = bestServers.get(ThreadLocalRandom.current().nextInt(bestServers.size()));
}
catch (Exception e) {
log.makeAlert(e, "Cost Balancer Multithread strategy wasn't able to complete cost computation.").emit();
}
return bestServer;
}</code></pre>
<pre><code>protected double computeCost(final DataSegment proposalSegment, final ServerHolder server,final boolean includeCurrentServer){
final long proposalSegmentSize = proposalSegment.getSize();
// (optional) Don't include server if it is already serving segment
if (!includeCurrentServer && server.isServingSegment(proposalSegment)) {
return Double.POSITIVE_INFINITY;
}
// Don't calculate cost if the server doesn't have enough space or is loading the segment
if (proposalSegmentSize > server.getAvailableSize() || server.isLoadingSegment(proposalSegment)) {
return Double.POSITIVE_INFINITY;
}
// 初始cost为0
double cost = 0d;
//计算Cost:候选Segment和Historical节点上所有Segment的totalCost
cost += computeJointSegmentsCost(
proposalSegment,
Iterables.filter(
server.getServer().getSegments().values(),
Predicates.not(Predicates.equalTo(proposalSegment))
)
);
// 需要加上和即将被加载的Segment之间的cost
cost += computeJointSegmentsCost(proposalSegment, server.getPeon().getSegmentsToLoad());
// 需要减掉和即将被加载的 Segment 之间的 cost
cost -= computeJointSegmentsCost (proposalSegment, server.getPeon().getSegmentsMarkedToDrop());
return cost;
}</code></pre>
<p>开始计算:</p>
<pre><code>static double computeJointSegmentsCost(final DataSegment segment, final Iterable<DataSegment> segmentSet){
double totalCost = 0;
// 此处需要注意,当新增的Historical节点第一次上线的时候,segmentSet应该是空,所以totalCost=0最小
// 新增节点总会很快的被均衡
for (DataSegment s : segmentSet) {
totalCost += computeJointSegmentsCost(segment, s);
}
return totalCost;
}</code></pre>
<p>进行一些处理:1)Segment的Interval毫秒转换成hour; 2)先计算了带lambda的x1, y0, y1的值</p>
<pre><code>public static double computeJointSegmentsCost(final DataSegment segmentA, final DataSegment segmentB){
final Interval intervalA = segmentA.getInterval();
final Interval intervalB = segmentB.getInterval();
final double t0 = intervalA.getStartMillis();
final double t1 = (intervalA.getEndMillis() - t0) / MILLIS_FACTOR; //x1
final double start = (intervalB.getStartMillis() - t0) / MILLIS_FACTOR; //y0
final double end = (intervalB.getEndMillis() - t0) / MILLIS_FACTOR; //y1
// constant cost-multiplier for segments of the same datsource
final double multiplier = segmentA.getDataSource().equals(segmentB.getDataSource()) ? 2.0 : 1.0;
return INV_LAMBDA_SQUARE * intervalCost(t1, start, end) * multiplier;
}</code></pre>
<p>真正计算 cost 函数的值</p>
<pre><code>public static double intervalCost(double x1, double y0, double y1){
if (x1 == 0 || y1 == y0) {
return 0;
}
// 保证Segment A开始时间小于B的开始时间
if (y0 < 0) {
// swap X and Y
double tmp = x1;
x1 = y1 - y0;
y1 = tmp - y0;
y0 = -y0;
}
if (y0 < x1) {
// Segment A和B 时间有重叠的情况,这个分支暂时不分析
.......
} else {
// 此处就是计算A和B两个Segment之间的cost,代价计算函数:See https://github.com/druid-io/druid/pull/2972
final double exy0 = FastMath.exp(x1 - y0);
final double exy1 = FastMath.exp(x1 - y1);
final double ey0 = FastMath.exp(0f - y0);
final double ey1 = FastMath.exp(0f - y1);
return (ey1 - ey0) - (exy1 - exy0);
}
}</code></pre>
<h3>2.4 代价计算函数分析</h3>
<p>现在我们有 2 个 Segment, A 和 B,需要计算他们之间的代价,假设 A 的 start 和 end 时间都是小于 B 的。</p>
<p><img src="/img/bVbq3vA?w=1080&h=303" alt="clipboard.png" title="clipboard.png"></p>
<h4>2.4.1 Cost函数介绍</h4>
<p>Cost 函数的提出请参考 Druid <a href="https://link.segmentfault.com/?enc=cDc4oZb%2B3HF%2BybrgLIN8cQ%3D%3D.cuuYYQ3GXRykzae58u0LqRdYFJNBLPu%2BSTdTaImnOMO%2Bli0Y5MBiT7LqJelQ4dzz" rel="nofollow">PR2972</a>:</p>
<p>$$Cost(X, Y)=\\int\_{x\_0}^{x\_1}\\int\_{y\_0}^{y\_1} {e^{\\lambda|x-y|}}\\,{\\rm d}x{\\rm d}y$$</p>
<p>其中 \( \lambda = frac{log_2e}{24.0} \) 是Cost函数的半衰期</p>
<p>为了弄清楚这个 Cost 函数以及影响 Cost 值的因素?我们先使用一些常用的参数配置: <br>假设1:Segment A 的Interval是1小时,即 \( A_{end}-A_{start}=1*Hour \), 得到: </p>
<p>$$x\_1 = \\frac{(A\_{end}-A\_{start})\*log\_e2}{24*Hour} = \\frac{log\_e2}{24}$$</p>
<p>假设2:Segment B 的 Interval 也是 1 小时, 得到: </p>
<p>$$y\_1 = y\_0 + x\_1$$ </p>
<p>假设3:Segment B 和 A start 时间相差了 t 个小时,得到: </p>
<p>$$y\_0 = \\frac{t\*Hour\*log\_e2}{24*Hour} = \\frac{t}{24}\*log\_e2$$</p>
<p>在实际的代码中,\( \lambda \)的计算已经放到了\( {x_0}{x_1}{y_0}{y_1} \)中</p>
<h4>2.4.2 计算 Cost 函数</h4>
<p>$$Cost(A, B)=(e^{x\_1-y\_0}-e^{x\_1-y\_1})-(e^{-y\_0}-e^{-y\_1})$$ </p>
<p>根据假设 2,得到: <br>$$Cost(A, B)=(e^{x\_1 - y\_0} - e^{-y\_0}) - (e^{-y\_0} - e^{-x\_1 - y\_0})=e^{x\_1 - y\_0} + e^{-x\_1 - y\_0} - 2e^{-y\_0}$$</p>
<p>继续简化,得到: <br>$$Cost(A, B)=(e^{x\_1} + e^{-x\_1} - 2 )e^{-y\_0}$$</p>
<p>根据假设 1,得到: <br>$$Cost(A, B)=(2^{\\frac{1}{24}} + 2^{-\\frac{1}{24}} - 2)e^{-y\_0}$$</p>
<p>根据假设 3,得到: <br>$$Cost(A, B)=(2^{\\frac{1}{24}} + 2^{-\\frac{1}{24}} - 2)*e^{\\frac{-t}{24} * log\_e2}$$</p>
<p>继续简化,得到: <br>$$ Cost(A, B)={(2^{\frac{1}{24}} + 2^{-\frac{1}{24}} - 2)*2^{\frac{-t}{24}}}$$</p>
<h4>2.4.5 小结</h4>
<p>根据上诉 cost 函数化简的结果,当 Segment A 和 B 的 Interval 都是 1 小时的情况下:Segment A 和 B 时间相距越大 Cost 越小,它们就越可能共存在同一个 Historical 节点。这也和本文开始时候提出的时间相邻的 Segment 存储在不同的节点上让查询更快相呼应。</p>
<h2>三. 总结</h2>
<p>Druid 的 balance 机制,主要解决 segments 数据在 history 节点的分布问题,这里的优化主要针对于查询做优化,一般情况下,用户的某一次查询针对的是一个时间范围内的多个 Segment 数据, cost 算法的核心思想是,尽可能打散 Segment 数据分布,这样在一次查询设计多个连续时间 Segment 数据的时候能够利用多台 history server 的并行处理能力,分散系统开销,缩短查询 RT. </p>
<p>最后打个小广告,有赞大数据团队基础设施团队,主要负责有赞的数据平台(DP), 实时计算(Storm, Spark Streaming, Flink),离线计算(HDFS,YARN,HIVE, SPARK SQL),在线存储(HBase),实时 OLAP(Druid) 等数个技术产品,欢迎感兴趣的小伙伴联系.</p>
有赞美业店铺装修前端解决方案
https://segmentfault.com/a/1190000018794780
2019-04-08T17:09:45+08:00
2019-04-08T17:09:45+08:00
有赞技术
https://segmentfault.com/u/youzantech
15
<h3>一、背景介绍</h3>
<p>做过电商项目的同学都知道,店铺装修是电商系统必备的一个功能,在某些场景下,可能是广告页制作、活动页制作、微页面制作,但基本功能都是类似的。所谓店铺装修,就是用户可以在 PC 端进行移动页面的制作,只需要通过简单的拖拽就可以实现页面的编辑,属于用户高度自定义的功能。最终编辑的结果,可以在 H5、小程序进行展示推广。</p>
<p><a href="https://link.segmentfault.com/?enc=O8daBOBqOLhU5EnWcsn8SA%3D%3D.isnBpivrJeGUBT42qHm7yY1qs7V6CaEDj%2Bsj0zW4%2B78BMiNSJ86xO9Wwn64J0JuS" rel="nofollow">有赞美业</a>是一套美业行业的 SaaS 系统,为美业行业提供信息化和互联网化解决方案。有赞美业本身提供了店铺装修的功能,方便用户自定义网店展示内容,下面是有赞美业店铺装修功能的截图:</p>
<p><img src="/img/remote/1460000018794783?w=1053&h=1562" alt="有赞美业店铺装修" title="有赞美业店铺装修"></p>
<p>上面的图片是 PC 端的界面,下面两张图分别是 H5 和小程序的最终展示效果。可以简单地看到,PC 端主要做页面的编辑和预览功能,包括了丰富的业务组件和详细的自定义选项;H5 和小程序则承载了最终的展示功能。</p>
<p>再看看有赞美业当前的技术基本面:目前我们的 PC 端是基于 React 的技术栈,H5 端是基于 Vue 的技术栈,小程序是微信原生开发模式。</p>
<p>在这个基础上,如果要做技术设计,我们可以从以下几个角度考虑:</p>
<ul>
<li>三端的视图层都是数据驱动类型,如何管理各端的数据流程?</li>
<li>三个端三种不同技术栈,业务中却存在相同的内容,是否存在代码复用的可能?</li>
<li>PC 最终生成的数据,需要与 H5、小程序共享,三端共用一套数据,应该通过什么形式来做三端数据的规范管理?</li>
<li>在扩展性上,怎么低成本地支持后续更多组件的业务加入?</li>
</ul>
<h3>二、方案设计</h3>
<p>所以我们针对有赞美业的技术基本面,设计了一个方案来解决以上几个问题。</p>
<p>首先摆出一张架构图:</p>
<p><img src="/img/remote/1460000018794784?w=562&h=715" alt="店铺装修架构图" title="店铺装修架构图"></p>
<h3>2.1 数据驱动</h3>
<p>首先关注 CustomPage 组件,这是整个店铺装修的总控制台,内部维护三个主要组件 PageLeft、 PageView 和 PageRight,分别对应上面提到的 PC 端3个模块。</p>
<p>为了使数据共享,CustomPage 通过 React context 维护了一个”作用域“,提供了内部三个组件共享的“数据源”。 PageLeft 、 PageRight 分别是左侧组件和右侧编辑组件,共享 <code>context.page</code> 数据,数据变更则通过 <code>context.pageChange</code> 传递。整个过程大致用代码表示如下:</p>
<pre><code>// CustomerPage
class CustomerPage extends React.Component {
static childContextTypes = {
page: PropTypes.object.isRequired,
pageChange: PropTypes.func.isRequired,
activeIndex: PropTypes.number.isRequired,
};
getChildContext() {
const { pageInfo, pageLayout } = this.state;
return {
page: { pageInfo, pageLayout },
pageChange: this.pageChange || (() => void 0),
activeIndex: pageLayout.findIndex(block => block.active),
};
}
render() {
return (
<div>
<PageLeft />
<PageView />
<PageRight />
</div>
);
}
}
// PageLeft
class PageLeft extends Component {
static contextTypes = {
page: PropTypes.object.isRequired,
pageChange: PropTypes.func.isRequired,
activeIndex: PropTypes.number.isRequired,
};
render() {...}
}
// PageRight
class PageRight extends Component {
static contextTypes = {
page: PropTypes.object.isRequired,
pageChange: PropTypes.func.isRequired,
activeIndex: PropTypes.number.isRequired,
};
render() {...}
}</code></pre>
<p>至于 H5 端,可以利用 Vue 的动态组件完成业务组件的动态化,这种异步组件的方式提供了极大的灵活性,非常适合店铺装修的场景。</p>
<pre><code><div v-for="item in components">
<component :is="item.component" :options="convertOptions(item.options)" :isEdit="true">
</component>
</div></code></pre>
<p>小程序因为没有动态组件的概念,所以只能通过 <code>if else</code> 的面条代码来实现这个功能。更深入的考虑复用的话,目前社区有开源的工具实现 Vue 和小程序之间的转换,可能可以帮助我们做的更多,但这里就不展开讨论了。</p>
<p>PC 编辑生成数据,最终会与 H5、小程序共享,所以协商好数据格式和字段含义很重要。为了解决这个问题,我们抽取了一个npm包,专门管理3端数据统一的问题。这个包描述了每个组件的字段格式和含义,各端在实现中,只需要根据字段描述进行对应的样式开发就可以了,这样也就解决了我们说的扩展性的问题。后续如果需要增加新的业务组件,只需要协商好并升级新的npm包,就能做到3端的数据统一。</p>
<pre><code>/**
* 显示位置
*/
export const position = {
LEFT: 0,
CENTER: 1,
RIGHT: 2,
};
export const positionMap = [{
value: position.LEFT,
name: '居左',
}, {
value: position.CENTER,
name: '居中',
}, {
value: position.RIGHT,
name: '居右',
}];</code></pre>
<h3>2.2 跨端复用</h3>
<p>PageView 是预览组件,是这个设计的核心。按照最直接的思路,我们可能会用 React 把所有业务组件都实现一遍,然后把数据排列展示的逻辑实现一遍;再在 H5 和小程序把所有组件实现一遍,数据排列展示的逻辑也实现一遍。但是考虑到代码复用性,我们是不是可以做一些“偷懒”?</p>
<p>如果不考虑小程序的话,我们知道 PC 和 H5 都是基于 dom 的样式实现,逻辑也都是 js 代码,两端都实现一遍的话肯定做了很多重复的工作。所以为了达到样式和逻辑复用的能力,我们想了一个方法,就是通过 iframe 嵌套 H5 的页面,通过 postmessage 来做数据交互,这样就实现了用 H5 来充当预览组件,那么 PC 和 H5 的代码就只有一套了。按照这个实现思路,PageView 组件可以实现成下面这样:</p>
<pre><code>class PageView extends Component {
render() {
const { page = {} } = this.props;
const { pageInfo = {}, pageLayout = [] } = page;
const { loading } = this.state;
return (
<div className={style}>
<iframe
title={pageInfo.title}
src={this.previewUrl}
frameBorder="0"
allowFullScreen="true"
width="100%"
height={601}
ref={(elem) => { this.iframeElem = elem; }}
/>
</div>);
}
}</code></pre>
<p>PageView 代码很简单,就是内嵌 iframe,其余的工作都交给 H5。H5 将拿到的数据,按照规范转换成对应的组件数组展示:</p>
<pre><code><template>
<div>
<component
v-for="(item, index) in components"
:is="item.component"
:options="item.options"
:isEdit="false">
</component>
</div>
</template>
<script>
computed: {
components() {
return mapToComponents(this.list);
},
},
</script></code></pre>
<p>因为有了 iframe ,还需要利用 postmessage 进行跨源通信,为了方便使用,我们做了一层封装(代码参考自有赞餐饮):</p>
<pre><code>export default class Messager {
constructor(win, targetOrigin) {
this.win = win;
this.targetOrigin = targetOrigin;
this.actions = {};
window.addEventListener('message', this.handleMessageListener, false);
}
handleMessageListener = (event) => {
// 我们能相信信息的发送者吗? (也许这个发送者和我们最初打开的不是同一个页面).
if (event.origin !== this.targetOrigin) {
console.warn(`${event.origin}不对应源${this.targetOrigin}`);
return;
}
if (!event.data || !event.data.type) {
return;
}
const { type } = event.data;
if (!this.actions[type]) {
console.warn(`${type}: missing listener`);
return;
}
this.actions[type](event.data.value);
};
on = (type, cb) => {
this.actions[type] = cb;
return this;
};
emit = (type, value) => {
this.win.postMessage({
type, value,
}, this.targetOrigin);
return this;
};
destroy() {
window.removeEventListener('message', this.handleMessageListener);
}
}</code></pre>
<p>在此基础上,业务方就只需要关注消息的处理,例如 H5 组件接收来自 PC 的数据更新可以这样用:</p>
<pre><code>this.messager = new Messager(window.parent, `${window.location.protocol}//mei.youzan.com`);
this.messager.on('pageChangeFromReact', (data) => {
...
});</code></pre>
<p>这样通过两端协商的事件,各自进行业务逻辑处理就可以了。</p>
<p>这里有个细节需要处理,因为预览视图高度会动态变化,PC 需要控制外部视图高度,所以也需要有动态获取预览视图高度的机制。</p>
<pre><code>// vue script
updated() {
this.$nextTick(() => {
const list = document.querySelectorAll('.preview .drag-box');
let total = 0;
list.forEach((item) => {
total += item.clientHeight;
});
this.messager.emit('vueStyleChange', { height: total });
}
}
// react script
this.messsager.on('vueStyleChange', (value) => {
const { height } = value;
height && (this.iframeElem.style.height = `${height}px`);
});</code></pre>
<h4>2.3 拖拽实现</h4>
<p>拖拽功能是通过 HTML5 drag & drop api 实现的,在这次需求中,主要是为了实现拖动过程中组件能够动态排序的效果。这里有几个关键点,实现起来可能会花费一些功夫:</p>
<ul>
<li>向上向下拖动过程中视图自动滚动</li>
<li>拖拽结果同步数据变更</li>
<li>适当的动画效果</li>
</ul>
<p>目前社区有很多成熟的拖拽相关的库,我们选用了vuedraggable。原因也很简单,一方面是避免重复造轮子,另一方面就是它很好的解决了我们上面提到的几个问题。</p>
<p>vuedraggable 封装的很好,使用起来就很简单了,把我们前面提到的动态组件再封装一层 draggable 组件:</p>
<pre><code><draggable
v-model="list"
:options="sortOptions"
@start="onDragStart"
@end="onDragEnd"
class="preview"
:class="{dragging: dragging}">
<div>
<component
v-for="(item, index) in components"
:is="item.component"
:options="item.options"
:isEdit="false">
</component>
</div>
</draggable>
const sortOptions = {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
};
// vue script
computed: {
list: {
get() {
return get(this.designData, 'pageLayout') || [];
},
set(value) {
this.designData.pageLayout = value;
this.notifyReact();
},
},
components() {
return mapToComponents(this.list);
},
},</code></pre>
<h3>三、总结</h3>
<p>到这里,所有设计都完成了。总结一下就是:PC 端组件间主要通过 React context 来做数据的共享;H5 和 小程序则是通过数据映射对应的组件数组来实现展示;核心要点则是通过 iframe 来达到样式逻辑的复用;另外可以通过第三方npm包来做数据规范的统一。</p>
<p>当然除了基本架构以外,还会有很多技术细节需要处理,比如需要保证预览组件不可点击等,这些则需要在实际开发中具体处理。</p>
<p><img src="/img/bVbq1xK?w=640&h=400" alt="clipboard.png" title="clipboard.png"></p>
HBase 读流程解析与优化的最佳实践
https://segmentfault.com/a/1190000018640891
2019-03-25T10:26:50+08:00
2019-03-25T10:26:50+08:00
有赞技术
https://segmentfault.com/u/youzantech
10
<h2>一、前言</h2>
<p>本文首先对 HBase 做简单的介绍,包括其整体架构、依赖组件、核心服务类的相关解析。再重点介绍 HBase 读取数据的流程分析,并根据此流程介绍如何在客户端以及服务端优化性能,同时结合有赞线上 HBase 集群的实际应用情况,将理论和实践结合,希望能给读者带来启发。如文章有纰漏请在下面留言,我们共同探讨共同学习。</p>
<h2>二、 HBase 简介</h2>
<p>HBase 是一个分布式,可扩展,面向列的适合存储海量数据的数据库,其最主要的功能是解决海量数据下的实时随机读写的问题。 通常 HBase 依赖 <strong>HDFS</strong> 做为底层分布式文件系统,本文以此做前提并展开,详细介绍 HBase 的架构,读路径以及优化实践。</p>
<h3>2.1 HBase 关键进程</h3>
<p>HBase是一个 Master/Slave 架构的分布式数据库,内部主要有 Master, RegionServer 两个核心服务,依赖 HDFS 做底层存储,依赖 zookeeper 做一致性等协调工作。</p>
<ul>
<li>
<strong>Master</strong> 是一个轻量级进程,负责所有 DDL 操作,负载均衡, region 信息管理,并在宕机恢复中起主导作用。</li>
<li>
<strong>RegionServer</strong> 管理 HRegion,与客户端点对点通信,负责实时数据的读写,。</li>
<li>
<strong>zookeeper</strong> 做 HMaster 选举,关键信息如 meta-region 地址,replication 进度,Regionserver 地址与端口等存储。</li>
</ul>
<h3>2.2 HBase 架构</h3>
<p>首先给出架构图如下</p>
<p><img src="/img/bVbqniq?w=603&h=389" alt="clipboard.png" title="clipboard.png"></p>
<p>架构浅析: HBase 数据存储基于 LSM 架构,数据先顺序写入 HLog,默认情况下 RegionServer 只有一个 Hlog 实例,之后再写入 <strong>HRegion</strong> 的 <strong>MemStore</strong> 之中。<strong>HRegion</strong> 是一张 HBase 表的一块数据连续的区域,数据按照 rowkey 字典序排列,RegionServer 管理这些 HRegion 。当MemStore达到阈值时触发flush操作,刷写为一个 <strong>HFile</strong> 文件,众多 HFile 文件会周期性进行 major, minor compaction 合并成大文件。所有 HFile 与日志文件都存储在HDFS之上。 <br>至此,我们对 HBase 的关键组件和它的角色以及架构有了一个大体的认识,下面重点介绍下 HBase 的读路径。</p>
<h2>三、读路径解析</h2>
<p>客户端读取数据有两种方式, <strong>Get</strong> 与 <strong>Scan</strong>。 Get 是一种随机点查的方式,根据 rowkey 返回一行数据,也可以在构造 Get 对象的时候传入一个 rowkey 列表,这样一次 RPC 请求可以返回多条数据。Get 对象可以设置列与 filter,只获取特定 rowkey 下的指定列的数据、Scan 是范围查询,通过指定 Scan 对象的 startRow 与 endRow 来确定一次扫描的数据范围,获取该区间的所有数据。 <br>一次由客户端发起的完成的读流程,可以分为两个阶段。第一个阶段是客户端如何将请求发送到正确的 RegionServer 上,第二阶段是 RegionServer 如何处理读取请求。</p>
<h3>3.1 客户端如何发送请求到指定的 RegionServer</h3>
<p>HRegion 是管理一张表一块连续数据区间的组件,而表是由多个 HRegion 组成,同时这些 HRegion 会在 RegionServer 上提供读写服务。所以客户端发送请求到指定的 RegionServer 上就需要知道 HRegion 的元信息,这些元信息保存在 hbase:meta 这张系统表之内,这张表也在某一个 RegionServer 上提供服务,而这个信息至关重要,是所有客户端定位 HRegion 的基础所在,所以这个映射信息是存储在 zookeeper 上面。 <br>客户端获取 HRegion 元信息流程图如下 </p>
<p><img src="/img/bVbqniI?w=1046&h=546" alt="clipboard.png" title="clipboard.png"></p>
<p>我们以单条 rowkey 的 Get 请求为例,当用户初始化到 zookeeper 的连接之后,并发送一个 Get 请求时,需要先定位这条 rowkey 的 HRegion 地址。如果该地址不在缓存之中,就需要请求 zookeeper (箭头1),询问 meta 表的地址。在获取到 meta 表地址之后去读取 meta 表的数据来根据 rowkey 定位到该 rowkey 属于的 HRegion 信息和 RegionServer 的地址(箭头2),缓存该地址并发 Get 请求点对点发送到对应的 RegionServer(箭头3),至此,客户端定位发送请求的流程走通。</p>
<h3>3.2 RegionServer 处理读请求</h3>
<p>首先在 RegionServer 端,将 Get 请求当做特殊的一次 Scan 请求处理,其 startRow 和 StopRow 是一样的,所以介绍 Scan 请求的处理就可以明白 Get 请求的处理流程了。</p>
<h4>3.2.1 数据组织</h4>
<p>让我们回顾一下 HBase 数据的组织架构,首先 Table 横向切割为多个 HRegion ,按照一个列族的情况,每一个 HRegion 之中包含一个 MemStore 和多个 HFile 文件, HFile 文件设计比较复杂,这里不详细展开,用户需要知道给定一个 rowkey 可以根据索引结合二分查找可以迅速定位到对应的数据块即可。结合这些背景信息,我们可以把一个Read请求的处理转化下面的问题:如何从一个 MemStore,多个 HFile <br>中获取到用户需要的正确的数据(默认情况下是最新版本,非删除,没有过期的数据。同时用户可能会设定 filter ,指定返回条数等过滤条件) <br>在 RegionServer 内部,会把读取可能涉及到的所有组件都初始化为对应的 scanner 对象,针对 Region 的读取,封装为一个 RegionScanner 对象,而一个列族对应一个 Store,对应封装为 StoreScanner,在 Store 内部,MemStore 则封装为 MemStoreScanner,每一个 HFile 都会封装为 StoreFileScanner 。最后数据的查询就会落在对 MemStoreScanner 和 StoreFileScanner 上的查询之上。 <br>这些 scanner 首先根据 scan 的 TimeRange 和 Rowkey Range 会过滤掉一些,剩下的 scanner 在 RegionServer 内部组成一个最小堆 KeyValueHeap,该数据结构核心一个 PriorityQueue 优先级队列,队列里按照 Scanner 指向的 KeyValue 排序。</p>
<pre><code>// 用来组织所有的Scanner
protected PriorityQueue<KeyValueScanner> heap = null;
// PriorityQueue当前排在最前面的Scanner
protected KeyValueScanner current = null;
</code></pre>
<h4>3.2.2 数据过滤</h4>
<p>我们知道数据在内存以及 HDFS 文件中存储着,为了读取这些数据,RegionServer 构造了若干 Scanner 并组成了一个最小堆,那么如何遍历这个堆去过滤数据返回用户想要的值呢。<br>我们假设 HRegion 有4个 Hfile,1个 MemStore,那么最小堆内有4个 scanner 对象,我们以 scannerA-D 来代替这些 scanner 对象,同时假设我们需要查询的 rowkey 为 rowA。每一个 scanner 内部有一个 current 指针,指向的是当前需要遍历的 KeyValue,所以这时堆顶部的 scanner 对象的 current 指针指向的就是 rowA(rowA:cf:colA)这条数据。通过触发 next() 调用,移动 current 指针,来遍历所有 scanner 中的数据。scanner 组织逻辑视图如下图所示。</p>
<p><img src="/img/bVbqnla?w=1080&h=331" alt="clipboard.png" title="clipboard.png"></p>
<p>第一次 next 请求,将会返回 ScannerA中的rowA:cf:colA,而后 ScannerA 的指针移动到下一个 KeyValue rowA:cf:colB,堆中的 Scanners 排序不变; <br>第二次 next 请求,返回 ScannerA 中的 rowA:cf:colB,ScannerA 的 current 指针移动到下一个 KeyValue rowB:cf:ColA,因为堆按照 KeyValue 排序可知 rowB 小于 rowA, 所以堆内部,scanner 顺序发生改变,改变之后如下图所示 </p>
<p><img src="/img/bVbqnll?w=1080&h=305" alt="clipboard.png" title="clipboard.png"></p>
<p>scanner 内部数据完全检索之后会 close 掉,或者 rowA 所有数据检索完毕,则查询下一条。默认情况下返回的数据需要经过 ScanQueryMatcher 过滤返回的数据需要满足下面的条件</p>
<ul>
<li>keyValue类型为put</li>
<li>列是Scanner指定的列</li>
<li>满足filter过滤条件</li>
<li>最新的版本</li>
<li>未删除的数据</li>
</ul>
<p>如果 scan 的参数更加复杂,条件也会发生变化,比如指定 scan 返回 Raw 数据的时候,打了删除标记的数据也要被返回,这部分就不再详细展开,至此读流程基本解析完成,当然本文介绍的还是很粗略,有兴趣的同学可以自己研究这一部分源码。</p>
<h2>四、读优化</h2>
<p>在介绍读流程之后,我们再结合有赞业务上的实践来介绍如何优化读请求,既然谈到优化,就要先知道哪些点可会影响读请求的性能,我们依旧从客户端和服务端两个方面来深入了解优化的方法。</p>
<h3>4.1客户端层面</h3>
<p>HBase 读数据共有两种方式,Get 与 Scan。 <br>在通用层面,在客户端与服务端建连需要与 zookeeper 通信,再通过 meta 表定位到 region 信息,所以在初次读取 HBase 的时候 rt 都会比较高,避免这个情况就需要客户端针对表来做预热,简单的<strong>预热</strong>可以通过获取 table 所有的 region 信息,再对每一个 region 发送一个 Scan 或者 Get 请求,这样就会缓存 region 的地址; <br> rowkey 是否存在读写<strong>热点</strong>,若出现热点则失去分布式系统带来的优势,所有请求都只落到一个或几个 HRegion 上,那么请求效率一定不会高;<br>读写占比是如何的。如果<strong>写重读轻</strong>,浏览服务端 RegionServer 日志发现很多 <strong>MVCC STUCK</strong> 这样的字样,那么会因为 MVCC 机制因为写 Sync 到 WAL 不及时而阻塞读,这部分机制比较复杂,考虑之后分享给大家,这里不详细展开。 </p>
<p><strong>Get 请求优化</strong></p>
<ul>
<li>将 Ge t请求批量化,减少 rpc 次数,但如果一批次的 Get 数量过大,如果遇到磁盘毛刺或者 Split 毛刺,则 Get 会全部失败(不会返回部分成功的结果),抛出异常。</li>
<li>指定列族,标识符。这样可以服务端过滤掉很多无用的 scanner,减少 IO 次数,提高效率,该方法同样适用于 Scan。</li>
</ul>
<p><strong>Scan 请求优化</strong></p>
<ul>
<li>设定合理的 startRow 与 stopRow 。如果 scan 请求不设置这两个值,而只设置 filter,则会做全表扫描。</li>
<li>设置合理的 caching 数目, scan.setCaching(100)。 因为 Scan 潜在会扫描大量数据,因此客户端发起一次 Scan 请求,实际并不会一次就将所有数据加载到本地,而是分成多次 RPC 请求进行加载。默认值是100。用户如果确实需要扫描海量数据,同时不做逻辑分页处理,那么可以将缓存值设置到1000,减少 rpc 次数,提升处理效率。如果用户需要快速,迭代地获取数据,那么将 caching 设置为50或者100就合理。</li>
</ul>
<h3>4.2 服务端优化</h3>
<p>相对于客户端,服务端优化可做的比较多,首先我们列出有哪些点会影响服务端处理读请求。</p>
<ul>
<li>gc 毛刺</li>
<li>磁盘毛刺</li>
<li>HFile 文件数目</li>
<li>缓存配置</li>
<li>本地化率</li>
<li>Hedged Read 模式是否开启</li>
<li>短路读是否开启</li>
<li>是否做高可用</li>
</ul>
<p><strong>gc 毛刺</strong>没有很好的办法避免,通常 HBase 的一次 Young gc 时间在 20~30ms 之内。磁盘毛刺发生是无法避免的,通常 SATA 盘读 IOPS 在 150 左右,SSD 盘随机读在 30000 以上,所以存储介质使用 SSD 可以提升吞吐,变向降低了毛刺的影响。HFile 文件数目因为 flush 机制而增加,因 <strong>Compaction</strong> 机制减少,如果 HFile 数目过多,那么一次查询可能经过更多 IO ,读延迟就会更大。这部分调优主要是优化 Compaction 相关配置,包括触发阈值,Compaction 文件大小阈值,一次参与的文件数量等等,这里不再详细展开。<strong>读缓存</strong>可以设置为为 CombinedBlockCache,调整读缓存与 MemStore 占比对读请求优化同样十分重要,这里我们配置 hfile.block.cache.size 为 0.4,这部分内容又会比较艰深复杂,同样不再展开。下面结合业务需求讲下我们做的<strong>优化实践</strong>。 <br>我们的在线集群搭建伊始,接入了比较重要的粉丝业务,该业务对RT要求极高,为了满足业务需求我们做了如下措施。</p>
<h4>4.2.1 异构存储</h4>
<p>HBase 资源隔离+异构存储。SATA 磁盘的随机 iops 能力,单次访问的 RT,读写吞吐上都远远不如 SSD,那么对RT极其敏感业务来说,SATA盘并不能胜任,所以我们需要HBase有支持SSD存储介质的能力。 <br>为了 HBase 可以支持异构存储,首先在 HDFS 层面就需要做响应的支持,在 HDFS 2.6.x 以及之后的版本,提供了对SSD上存储文件的能力,换句话说在一个 HDFS 集群上可以有SSD和SATA磁盘并存,对应到 HDFS 存储格式为 [ssd] 与 [disk]。然而 HBase 1.2.6 上并不能对表的列族和 RegionServer 的 WAL 上设置其存储格式为 [ssd], 该功能在社区 HBase 2.0 版本之后才开放出来,所以我们从社区 backport 了对应的 patch ,打到了我们有赞自己的 HBase 版本之上。支持 [ssd] 的 社区issue 如下: <a href="https://link.segmentfault.com/?enc=bL0dt7R%2FyjiBEn5Q%2F8%2B5Jw%3D%3D.ebxD6I9ogCMT5oDzThxXlBG%2FL01lMjBE0A6nv1KnEARv4vzTmb4kjTC7O1LSRf%2BtAo8fpkm6eB5uqpgRfl0BcsAD%2F3uvPOrQA7%2Fw4GJvxgThDy02NWXPTc5KUYZ96Tu9" rel="nofollow">https://issues.apache.org/jir...</a> 。 <br>添加SSD磁盘之后,HDFS集群存储架构示意图如图所示 <br><img src="/img/bVbqnrA?w=1080&h=926" alt="clipboard.png" title="clipboard.png"></p>
<p><strong>理想的混合机型集群</strong>异构部署,对于 HBase 层面来看,文件存储可选三种策略:HOT, ONE_SSD, ALL_SSD,其中 ONE_SSD 存储策略既可以把三个副本中的两个存储到便宜的SATA磁盘介质之上来减少 SSD 磁盘存储成本的开销,同时在数据读取访问本地 SSD 磁盘上的数据可以获得理想的 RT ,是一个十分理想的存储策略。HOT 存储策略与不引入异构存储时的存储情况没有区别,而 ALL_SSD 将所有副本都存储到 SSD 磁盘上。 在有赞我们目前没有这样的理想混合机型,只有纯 SATA 与 纯 SSD 两种大数据机型,这样的机型对应的架构与之前会有所区别,存储架构示意图如图所示。</p>
<p><img src="/img/bVbqnrM?w=1080&h=521" alt="clipboard.png" title="clipboard.png"></p>
<p>基于这样的场景,我们做了如下规划:1.将SSD机器规划成独立的组,分组的 RegionServer 配置 hbase.wal.storage.policy=ONE_SSD, 保证 wal 本身的本地化率;2. 将SSD分组内的表配置成 ONE_SSD 或者 ALL_SSD;3. 非SSD分组内的表存储策略使用默认的 HOT<br>具体的配置策略如下:在 hdfs-site.xml 中修改</p>
<pre><code> <property>
<name>dfs.datanode.data.dir</name>
<value>[SSD]file:/path/to/dfs/dn1</value>
</property></code></pre>
<p>在 SSD 机型 的 RegionServer 中的 hbase-site.xml 中修改</p>
<pre><code> <property>
<name>hbase.wal.storage.policy</name>
<value>ONE_SSD</value>
</property>
</code></pre>
<p>其中ONE_SSD 也可以替代为 ALL_SSD。 SATA 机型的 RegionServer 则不需要修改或者改为 HOT 。</p>
<h4>4.2.2 HDFS短路读</h4>
<p>该特性由 HDFS-2246 引入。我们集群的 RegionServer 与 DataNode 混布,这样的好处是数据有本地化率的保证,数据第一个副本会优先写本地的 Datanode。在不开启短路读的时候,即使读取本地的 DataNode 节点上的数据,也需要发送RPC请求,经过层层处理最后返回数据,而短路读的实现原理是客户端向 DataNode 请求数据时,DataNode 会打开文件和校验和文件,将两个文件的描述符直接传递给客户端,而不是将路径传递给客户端。客户端收到两个文件的描述符之后,直接打开文件读取数据,该特性是通过 UNIX Domain Socket进程间通信方式实现,流程图如图所示。</p>
<p><img src="/img/bVbqntH?w=1080&h=461" alt="clipboard.png" title="clipboard.png"></p>
<p>该特性内部实现比较复杂,设计到共享内存段通过 slot 放置副本的状态与计数,这里不再详细展开。 </p>
<p>开启短路读需要修改 hdfs-site.xml 文件</p>
<pre><code> <property>
<name>dfs.client.read.shortcircuit</name>
<value>true</value>
</property>
<property>
<name>dfs.domain.socket.path</name>
value>/var/run/hadoop/dn.socket</value>
</property>
</code></pre>
<h4>4.2.3 HDFS Hedged read</h4>
<p>当我们通过短路读读取本地数据因为磁盘抖动或其他原因读取数据一段时间内没有返回,去向其他 DataNode 发送相同的数据请求,先返回的数据为准,后到的数据抛弃,这也可以减少磁盘毛刺带来的影响。默认该功能关闭,在HBase中使用此功能需要修改 hbase-site.xml</p>
<pre><code>
<property>
<name>dfs.client.hedged.read.threadpool.size</name>
<value>50</value>
</property>
<property>
<name>dfs.client.hedged.read.threshold.millis</name>
<value>100</value>
</property></code></pre>
<p>线程池大小可以与读handler的数目相同,而超时阈值不适宜调整的太小,否则会对集群和客户端都增加压力。同时可以通过 Hadoop 监控查看 <em>hedgedReadOps</em> 与 <em>hedgedReadOps</em> 两个指标项,查看启用 Hedged read 的效果,前者表示发生了 Hedged read 的次数,后者表示 Hedged read 比原生读要快的次数。</p>
<h4>4.2.4 高可用读</h4>
<p>HBase是一个CP系统,同一个region同一时刻只有一个regionserver提供读写服务,这保证了数据的一致性,即不存在多副本同步的问题。但是如果一台regionserver发声宕机的时候,系统需要一定的故障恢复时间deltaT, 这个deltaT时间内,region是不提供服务的。这个deltaT时间主要由宕机恢复中需要回放的log的数目决定。集群复制原理图如下图所示。</p>
<p><img src="/img/bVbqnuj?w=1080&h=825" alt="clipboard.png" title="clipboard.png"></p>
<p>HBase提供了HBase Replication机制,用来实现集群间单方向的异步数据复制我们线上部署了双集群,备集群 SSD 分组和主集群 SSD 分组有相同的配置。当主集群因为磁盘,网络,或者其他业务突发流量影响导致某些 RegionServer 甚至集群不可用的时候,就需要提供备集群继续提供服务,备集群的数据可能会因为 HBase Replication 机制的延迟,相比主集群的数据是滞后的,按照我们集群目前的规模统计,平均延迟在 100ms 以内。所以为了达到高可用,粉丝业务可以接受复制延迟,放弃了强一致性,选择了最终一致性和高可用性,在第一版采用的方案如下</p>
<p><img src="/img/bVbqnuo?w=1080&h=618" alt="clipboard.png" title="clipboard.png"></p>
<p>粉丝业务方不想感知到后端服务的状态,也就是说在客户端层面,他们只希望一个 Put 或者 Get 请求正常送达且返回预期的数据即可,那么就需要高可用客户端封装一层降级,熔断处理的逻辑,这里我们采用 Hystrix 做为底层熔断处理引擎,在引擎之上封装了 HBase 的基本 API,用户只需要配置主备机房的 ZK 地址即可,所有的降级熔断逻辑最终封装到 ha-hbase-client 中,原理类似图9,这里不再赘述。</p>
<h4>4.2.5 预热失败问题修复</h4>
<p>应用冷启动预热不生效问题。该问题产生的背景在于应用初始化之后第一次访问 HBase 读取数据时候需要做寻址,具体流程见图2,这个过程涉及多次 RPC 请求,所以耗时较长。在缓存下所有的 Region 地址之后,客户端与 RegionServer 就会做点对点通信,这样 RT 就有所保证。所以我们会在应用启动的时候做一次预热操作,而预热操作我们通常做法是调用方法 <em>getAllRegionLocations</em> 。在1.2.6版本<em>getAllRegionLocations</em> 存在 bug(后来经过笔者调研,1.3.x,以及2.x版本也都有类似问题),该方案预期返回所有的 Region locations 并且缓存这些 Region 地址,但实际上,该方法只会缓存 table 的第一个 Region, 笔者发现此问题之后反馈给社区,并提交了 patch 修复了此问题,issue连接:<a href="https://link.segmentfault.com/?enc=24ZBSWMfVbksZvFMZ9hqhA%3D%3D.wO%2BCoggkN0F2t%2FfFaOQ1Lpn8Js2YS6lwPvPa3vg8wL24%2BBmfYltF2ZuPZgX8uFSMYTP9s5%2BIE8FdvK%2F8VWcp3w%3D%3D" rel="nofollow">https://issues.apache.org/jir...</a> 。这样通过调用修复 bug 之后的 <em>getAllRegionLocations</em> 方法,即可在应用启动之后做好预热,在应用第一次读写HBase时便不会产生 RT 毛刺。 <br>粉丝业务主备超时时间都设置为 300ms。经过这些优化,其批量 Get 请求 99.99% 在 20ms 以内,99.9999% 在 400ms 以内。</p>
<h2>五、总结</h2>
<p>HBase 读路径相比写路径更加复杂,本文只是简单介绍了核心思路。也正是因为这种复杂性,在考虑优化的时候需要深入了解其原理,且目光不能仅仅局限于本身的服务组件,也要考虑其依赖的组件,是否也有可优化的点。最后,本人能力有限,文中观点难免存在纰漏,还望交流指正。</p>
<p>最后打个小广告,有赞大数据团队基础设施团队,主要负责有赞的数据平台(DP), 实时计算(Storm, Spark Streaming, Flink),离线计算(HDFS,YARN,HIVE, SPARK SQL),在线存储(HBase),实时 OLAP(Druid) 等数个技术产品,欢迎感兴趣的小伙伴联系 zhaoyuan@youzan.com</p>
<p>参考 <br><a href="https://link.segmentfault.com/?enc=nrdPxI6d5w1rsC%2BV3uA9bg%3D%3D.9pJymAlezF75yolRtLpvjjXCHkBmuMRLdH69s8RYvqBU0XImvHnNjhWslW71OoE7dqA%2FZb2oBmu7cG%2B0Xn1rKw%3D%3D" rel="nofollow">http://www.nosqlnotes.com/tec...</a> <br><a href="https://link.segmentfault.com/?enc=r1Nwa%2Fc2cBu07rEznyB86A%3D%3D.m3p4m9JxdNik9zffIeH2Cv%2FB5xWTU0sXf4178a44jNc%3D" rel="nofollow">http://hbasefly.com/2016/11/11/</a><br><a href="https://link.segmentfault.com/?enc=vag5gqszI0P5tXguMqT0BQ%3D%3D.xv8m%2BFwUZXh%2Fn%2BL8hxitYRW5jzhXNUa0iZuMQqJmuHt2ZoIdxk8%2BqnzhpmayGC62bheYfBn%2FHpk%2FL27NUjndgro0U%2BSM1IZBdYcFr1E15Z81BlqV1urN876ItfY%2F0IK86B7YiKubnGjrUO7xnlLN7g%3D%3D" rel="nofollow">http://hadoop.apache.org/docs...</a></p>
为什么选择使用 OKR 进行项目过程管理
https://segmentfault.com/a/1190000018278871
2019-02-25T17:11:09+08:00
2019-02-25T17:11:09+08:00
有赞技术
https://segmentfault.com/u/youzantech
6
<p>延续上次讨论的<a href="https://link.segmentfault.com/?enc=HU3P%2BEV9VHdjBc%2Fq2c8XFw%3D%3D.jCRmCgBtgzte3BTQ9yAMMkq%2Fz9HNM0dTO%2FVqFjNn40pMyYoEKEa%2FaN8tcvmVDPH9RrooRT4wVQesNuVK3tKD0F4pkuKnmHkdjyhppxHg0hE%3D" rel="nofollow">透过 OKR 进行项目过程管理</a>的内容,有位朋友给了反馈,但是碍于回覆的字数有限,无法说明的更多,索性将内容弄得多点,变成一篇文章 ???</p>
<p><img src="/img/bVboRjV?w=1296&h=440" alt="图片描述" title="图片描述"></p>
<h2>一、OKR</h2>
<p>OKR 是一个目标管理框架,可以帮助领导者将他们的团队从 A 领导到 B。OKR 的一些好处包含改善焦点、提高透明度以及团队之间更好的一致性。由英特尔的 Andrew Grove 发明,后来由谷歌推广,在硅谷科技公司中广为人知,并被世界各地的许多组织采用。OKR 提供了一个简单的结构和标准来创建业务目标以及组织可以采用的一些规则和最佳实践。</p>
<h4>1.1 OKR 的运作方式</h4>
<p>一个好的 OKR 包含两个元素:一个目标和一或多个关键结果。在有赞,我们引入了第三个要素:关键行动。</p>
<p>目标代表一个目的地,能够告诉我们「我需要去哪里?」的问题。</p>
<ul>
<li>目标设定了明确的方向,并且鼓舞人心</li>
<li>目标不包含数字</li>
</ul>
<p>例如:扩展公司业务</p>
<p>关键结果衡量目标的进展情况,能够告诉我们「我怎么知道自己在哪里?」的问题。</p>
<ul>
<li>关键结果是衡量目标达成与否的指标,例如销售数量、网站流量等。这是我们影响的东西。</li>
<li>关键结果不是我们所要做的事情,例如已完成的项目或已推出的产品</li>
</ul>
<p>例如:每月找到 400 个网站澘在客户</p>
<p>关键行动描述了为了推动关键成果取得进展所做的工作,能够告诉我们「我该怎样做才能到达那里?」的问题</p>
<ul>
<li>关键行动是一会影响关键结果的活动</li>
<li>关键行动不表示完成;完成目标取决于关键成果的进展</li>
</ul>
<p>例如:创建 8 个新的登录页面</p>
<h2>二、SMART</h2>
<p>SMART 目标管理方法是许多组织用于取得成功目标的常见方式,包含了一套创造目标的标准,这要归功于彼德‧德鲁克的目标管理理论(MBO)。SMART 用一个简单的结构,描述如何创建和衡量目标的进展。SMART 列出了目标必须满足的五件事:</p>
<ul>
<li>Specific</li>
<li>Measurable</li>
<li>Achievable</li>
<li>Relevant</li>
<li>Time-bound</li>
</ul>
<h4>Specific</h4>
<p>在 SMART 中,目标必须具体,明确说明需要实现的目标,并对每个人都是能理解的。例如:我们要在北京招入更多商家。<br>这个目标有一个明确的范围 (北京商家) 提供了需要完成的内容的描述 (招入更多),但是,这并没有告诉我们有关于完成这个目标的衡量标准、重要性以及何时需要实现的信息。</p>
<h4>Measurable</h4>
<p>要知道什么时候达到目标,就必须是可衡量的。所以应该包含一个指标,其中达到的指标就表示达成目标。例如:我们要在北京招入 10,000 家商家。</p>
<p>这个目标有了一个衡量标准,一旦在北京招入了 10,000 家商家,就会认为目标已经成功实现。</p>
<h4>Achievable</h4>
<p>考虑可用资源和约束,当可能性在可能范围内时,这时候目标是可实现的,但这并不意味着这个目标很容易。以前面的例子来说,招 10,000 家商家代表一个不现实的目标,销售团队决定对其进行调整。例如:在北京招 750 家商家。</p>
<p>这个目标可能很难,但有可能实现,但是,目前还不清楚为什么实现这一目标很重要。</p>
<h4>Relevant</h4>
<p>当一个目标与其他组织目标有关的结果一致并导致其结果时,该目标是相关的。例如:我们在北京招入 750 家商家,来扩大我们的市场份额。</p>
<p>这个目标现在与公司「扩大我们的市场份额」的更大目标相关联。现在还缺少的是截止日期。</p>
<h4>Time-bound</h4>
<p>有时限的目标有一个开始和结束日期。结束日期很重要,因为这是审核目标的时间,以确定这是否被视为达成。例如:2019 年上半年,我们要在北京招入 750 家商家,以扩大我们的业务。</p>
<h4>2.1 OKRs 和 SMART 有什么相同之处</h4>
<p>OKR 与 SMART 的共同点是两者的目标设定方法都可以追溯到彼得·德鲁克的目标管理理论 (MBO)。两者的结构类似,虽然 OKR 可能看起来比 SMART 目标更简单,但 OKR 的三个字母涵盖了与 SMART 相同的标准。以下给个 OKR 与 SMART 的直接比较表。</p>
<p><img src="/img/bVboRj8?w=2156&h=442" alt="图片描述" title="图片描述"></p>
<h4>2.2 OKRs 和 SMART 有什么区别</h4>
<p>从表面上来看,SMART 与 OKR 很相似,都提供了一个结构、规则来帮助设置范围、时间范围和对齐,但是在 SMART 结束的地方,OKR 接管了,这是 OKR 与 SMART 不同的地方。<br>SMART 经常被人误会的是 M 这个字,容易引起混淆,例如 M 可表示测量 (measurable)、意义 (meaningful)、动机 (motivational)。当使用「意义」或「动机」时,这很容易就改变了 SMART 的原本的结构和重点,不再强调评估进展的重要性,OKR 在这点上强调了,相比之下 OKR 没有解释的余地,目标始终包含目标与关键结果。</p>
<h2>三、OKR 不会孤立地对待目标</h2>
<p>OKR 与 SMART 有所不同,因为 OKR 是在一个框架内创建的,并描述了与组织层次结构及其与组织时间框架的关系。 </p>
<p>最终 OKR 位于 OKR 层级的最顶端,可运行 5 年、10 年甚至更长时间,这个目标结合了公司的愿景 (我们希望在长期未来的哪个方面?)和使命 (我们的目的是什么?)。</p>
<p>公司 OKR 构成 OKR 的第二层级,运行 1 年。代表了公司的战略,转化为整个组织努力的 3 至 5 个目标。公司 OKR 在整个组织的协调工作发挥着重要的作用,为所有员工设定了明确的重点,并为团队 OKR 提供了参考点。</p>
<p>团队 OKR 构成了 OKR 里的第三级,可以按季度运行,代表团队或部门执行的策略,以推动公司 OKR 的进展。</p>
<p>项目 OKR 构成了 OKR 里的第四级,可以按月度运行,代表跨团队协作、个人的行动方针,以推进团队、公司 OKR 的进展。</p>
<p><img src="/img/bVboRkh?w=537&h=306" alt="图片描述" title="图片描述"></p>
<p>与 SMART 相比,OKR 提供了目标在其中工作的额外级别的组织架构和上下文,相比之下,SMART 只是孤立地考虑目标的形成,而 OKR 则整个组织的目标之间的关系是明确的。</p>
<h2>四、小结</h2>
<h4>4.1 OKR vs. KPI</h4>
<p>从范围上来做比较的话,OKR 强调的是上下沟通,也就是目标从上至下分解,KR 可以从下至上提出,然后团队、上下级再充分讨论决定;KPI 虽然也强调上下沟通,但是在目标分解的过程中,更多的是强调执行。</p>
<p>在工作方式上来说的话,OKR 关注的是产出、事情的结果;KPI 则关注结果导向,更多是关注事情做了没有。<br>OKR 是更专注于目标的实现结果及改善,不会将结果与绩效做挂勾。KPI 除了对目标的实现结果进行评估外,同时也会将目标实现的评估结果与绩效挂勾。</p>
<p>从这两点上来看,在一个成功的项目里,我们会关注的是项目进行的方式、过程中有多少问题、这些问题如何被解决。如果用 KPI 来进行项目过程管理,会发现项目仅仅是被完成了,但是不一定有价值。</p>
<p>另一个对于参与项目的成员来说,OKR 让成员们对目标负责,而使用 KPI 会变成为绩效负责。<br>不同的场景选择 OKR 还是 KPI 也是有简单的方式,KPI 适用于一些工作目标、措施都比较明确和成熟的岗位,像是制造业的一线操作岗位,这类岗位通常已经有具体的作业标准和流程,为保证产品质量,必须严格按照标准操作和执行,禁止随意发挥。反之 OKR 适合用在实现目标的方法不是特别清晰且不太成熟的岗位,研发性的岗位就是典型案例,在强调目标的基础上,员工可以自己发挥怎么去实现。</p>
<h4>4.2 OKR & SMART</h4>
<p>SMART 标准易于记忆、使用,非常适合个人目标设定。然而,SMART 只是孤立地描述了一个目标。OKR 提供额外级别的组织环境,并将目标设定转变为公司范围的上下文。 使用 OKR,整个组织可以实现清晰和专注。对于项目过程的管理来说,更能对齐组织目标,并能更好的让项目成员理解为何而战。</p>
效能改进之项目例会导入实践
https://segmentfault.com/a/1190000018277732
2019-02-25T16:20:10+08:00
2019-02-25T16:20:10+08:00
有赞技术
https://segmentfault.com/u/youzantech
4
<p>众所周知,在项目管理的过程中,我们需要非常注重沟通,而每日例会作为沟通管理中的一项最佳实践,非常适配互联网项目<strong>短频快</strong>的特点。成功地在项目中建立例会制度,能带来以下好处:</p>
<p>1)让研发人员相互之间了解各自的任务完成情况,以便于上下游及时衔接,发现进度异常和集成的风险;<br>2)让技术难题和业务疑点及时暴露,并根据问题的普适程度,选择在会上或会后得到支持与解答;<br>3)让产品经理了解研发过程,一方面及时掌握动态,另一方面能对结果和风险抱有更深切的同理心,以便后续能更紧密地合作;<br>4)帮助团队建立秩序感和纪律性,提升组织的成熟度。</p>
<p><img src="/img/bVboQ1z?w=1048&h=388" alt="图片描述" title="图片描述"></p>
<p>作为一名在<strong>弱矩阵型组织</strong>中拥有微权力的 PM ,要在一个尚没有形成惯例、且仅为完成当前项目而临时组建的团队中建立例会制度,笔者建议以如下方式迈出第一步:</p>
<h2>一、事前摸底</h2>
<p>可以先通过访谈对话的方式,收集项目组成员在<strong>如何透明研发进度和风险</strong>方面的认知情况,甚至可以了解一下其所属部门的工作风格,以便采取合适的应对措施。</p>
<p>如果有成员曾经参与过项目每日例会的相关实践,务必抓住这个机会,尽量说服 TA 作为你安插在项目组中的卧底,在必要的时候主动站出来配合你,这样的话,其他人也更容易行动起来。</p>
<p>可能也有成员曾在例会中经历过糟糕的体验,比如:时间冗长、议题蔓延、信息过载、与之无关等,这时,你一方面要庆幸提前发现了暗礁,在自己组织的例会中要尽量避开(当然,如果你有丰富的项目管理经验,阅会无数,那你能举出更多的失败案例,但无须事事防范,否则过犹不及,让自己处处掣肘),另一方面,也要更加谨慎地实施你的想法,避免对方受到二次伤害,进而对 PM 和改进工作丧失信心。</p>
<h2>二、承诺约定</h2>
<p>PM 需要具备一定的主动性,在项目启动之初(比如: kickoff meeting 上)提出例会的建议,可以介绍一下例会对项目及成员所带来的好处,与大家(包括 PO )约定每天在固定时间和地点同步一下各自的状态,挑选大家都合适的时间,并获得每个人的口头同意。</p>
<p>作为交换, PM 须针对大家关切的问题作出一些承诺,比如:确保会议的质量,时间不会超过 15 分钟,会后有文字材料输出用于追溯。</p>
<p>不仅是例会制度,绝大部分的改进工作都会要求参与者走出舒适区,做出一定的让步甚至承受阵痛,故所谓“投之以桃,报之以李”:你愿意配合我的工作,我也不会让你失望。针对之前调研所掌握的信息,给项目团队吃下定心丸。</p>
<h2>三、不见不散</h2>
<p>可以要求让每个人创建一个日程提醒。但无论设几个闹钟,刚开始几次例会,还是会有不少人迟到甚至忘记出席,需要经过反复提醒才会慢慢养成习惯。</p>
<p>守时是职业操守之一,但如果遇到成熟度较低的组织,则需要不断灌输和教育。这时候, PM 一方面需要及时补位,在会前就做好提醒工作,另一方面也可以于会后在项目日志中做好记录,在必要的时候反馈给当事人所属部门的负责人,一起帮助 TA 改进。身处于某些组织文化中的读者,可能会对这种直接了当的反馈方式抱有畏惧心理,但考虑到“如果团队尚未达到理想的成熟度,未来高阶的改进工作将难以开展”这一点,就不应该回避沿途的任何困难。</p>
<p>另外,例会地点尽量固定,以便于大家习惯的养成,否则每次换地方都要再通知,成本很高。</p>
<p><img src="/img/bVboQ2b?w=1120&h=322" alt="图片描述" title="图片描述"></p>
<h2>四、每日三问</h2>
<p>首次例会一般需要花更多的时间,最主要的一点是 PM 需要向大家讲解和示范如何参与例会。当然,在 <em>承诺约定</em> 环节可以做一些铺垫,但光是口头解释缺乏身临其境的场景感,大家未必都能 get 得到。所以,也不必赘述,只要现场主动参与和切身感受一下,包教包会,屡试不爽。</p>
<p>为了争取最佳的投入产出比,我们有必要把例会的环节设置得尽可能的精简,但又不失效果。笔者建议会上所有人站成一圈,每个人(除 PM 和 PO 之外)轮流向团队回答三个问题:1)我昨天做了什么(即:工作进度);2)我今天打算做什么(即:工作计划);3)我遇到了什么困难(即:暴露风险)。</p>
<p>前两个问题,上下游与之依赖的组员会特别关注,比如:前端依赖于后端的接口实现,否则他自己的功能验证工作会迟迟完不成。而第三个问题, PM 和 PO 会比较关注,他们有义务为项目扫除障碍,比如: PM 需要协调项目团队以外的技术顾问来做支持, PO 需要解答产品功能细节的疑惑,不一而足。通过上述三个问题,信息在项目组内部变得完全透明,成员对工作更有目标感, PO 对成功交付更有信心,妈妈再也不用担心我的项目了!</p>
<p>如果组织得当,按每人三句话的速度,一次例会妥妥地被控制在 15 分钟以内。当然,由于例会的时长非常短,为了确保会议的效率和效果,仍然要求 PM 控制好会议的节奏,同时也需要项目组成员全身心投入。</p>
<h2>五、反馈改进</h2>
<p>每人分享完自己的三个问题且没有其他公共议题了的话(非公共议题应放在会后单独进行),项目团队的首次例会就进入尾声了。此时,大家的精神状态已经放松下来了,由于是第一次例会, PM 可以在宣布散会前询问一下大家的与会感受,以便后续做改进。</p>
<p>也许首次例会并不如你预想的那样完美,比如:有的成员不太善于表达、有的成员会忍不住和其他人开小会、有的成员羞于公开上报自己的风险,等等。但至少你已经迈出了第一步,项目组成员的心中已然被你播下一颗种子,后续免不了还要不停地浇水施肥,帮助他们积累<strong>如何高效参与例会</strong>的经验,在现有最小可用例会的基础上持续迭代。</p>
<p><img src="/img/bVboQ2e?w=1398&h=638" alt="图片描述" title="图片描述"></p>
<h2>小结</h2>
<p><strong>导入项目例会</strong>比较适合作为对组织实施效能改进工作早期阶段的一次试探,以投石问路的方式了解组织对轻量级变革的接受情况。如果较难接受,则说明组织的成熟度不高, PM 要做好贴身辅导打持久战的心理准备,反之则能顺利推广并赋能到组织的各个角落。</p>
<p>在有赞建立项目管理体系之初,笔者曾倡议试行项目例会,有幸被广泛接纳,辅之以实物看板,简直如虎添翼。曾经有一段日子,某间狭小的会议室,在一个小时内轮番成为四五个项目的每日例会之场所,与会人员络绎不绝,一派繁忙而有序的景象。</p>
<p>作为 PM ,每次参与项目管理都是一次帮助组织进化的机会,而每个为新项目所临时组建的团队中,都可能出现你曾经合作过的同事的身影。让他们成为你效能改进工作过程中散在各处的星星之火,从此,你将不再是一个人在战斗。</p>
Druid 在有赞的实践
https://segmentfault.com/a/1190000018276006
2019-02-25T14:52:36+08:00
2019-02-25T14:52:36+08:00
有赞技术
https://segmentfault.com/u/youzantech
8
<h2>一、Druid介绍</h2>
<p>Druid 是 MetaMarket 公司研发,专为海量数据集上的做高性能 OLAP (OnLine Analysis Processing)而设计的数据存储和分析系统,目前<a href="https://link.segmentfault.com/?enc=fKJphX3d1UUmoaRyP22JEA%3D%3D.p7HeWZ2%2ByYTXMlPgXNtmNfminNmgQM%2BXOBGD9%2FQzgOE%3D" rel="nofollow">Druid</a> 已经在Apache基金会下孵化。Druid的主要特性:</p>
<ul>
<li>交互式查询( Interactive Query ): Druid 的低延迟数据摄取架构允许事件在它们创建后毫秒内查询,因为 Druid 的查询延时通过只读取和扫描有必要的元素被优化。Druid 是列式存储,查询时读取必要的数据,查询的响应是亚秒级响应。</li>
<li>高可用性( High Available ):Druid 使用 HDFS/S3 作为 Deep Storage,Segment 会在2个 Historical 节点上进行加载;摄取数据时也可以多副本摄取,保证数据可用性和容错性。</li>
<li>可伸缩( Horizontal Scalable ):Druid 部署架构都可以水平扩展,增加大量服务器来加快数据摄取,以及保证亚秒级的查询服务</li>
<li>并行处理( Parallel Processing ): Druid 可以在整个集群中并行处理查询</li>
<li>丰富的查询能力( Rich Query ):Druid支持 Scan、 TopN、 GroupBy、 Approximate 等查询,同时提供了2种查询方式:API 和 SQL</li>
</ul>
<p><strong>Druid常见应用的领域:</strong></p>
<ul>
<li>网页点击流分析</li>
<li>网络流量分析</li>
<li>监控系统、APM</li>
<li>数据运营和营销</li>
<li>BI分析/OLAP</li>
</ul>
<h2>二、为什么我们需要用 Druid</h2>
<p>有赞作为一家 SaaS 公司,有很多的业务的场景和非常大量的实时数据和离线数据。在没有是使用 Druid 之前,一些 OLAP 场景的场景分析,开发的同学都是使用 SparkStreaming 或者 Storm 做的。用这类方案会除了需要写实时任务之外,还需要为了查询精心设计存储。带来问题是:开发的周期长;初期的存储设计很难满足需求的迭代发展;不可扩展。 <br>在使用 Druid 之后,开发人员只需要填写一个数据摄取的配置,指定维度和指标,就可以完成数据的摄入;从上面描述的 Druid 特性中我们知道,Druid 支持 SQL,应用 APP 可以像使用普通 JDBC 一样来查询数据。通过有赞自研OLAP平台的帮助,数据的摄取配置变得更加简单方便,一个实时任务创建仅仅需要10来分钟,大大的提高了开发效率。</p>
<h2>2.1、Druid 在有赞使用场景</h2>
<ul>
<li>系统监控和APM:有赞的监控系统(天网)和大量的APM系统都使用了 Druid 做数据分析</li>
<li>数据产品和BI分析:有赞 SaaS 服务为商家提供了有很多数据产品,例如:商家营销工具,各类 BI 报表</li>
<li>实时OLAP服务:Druid 为风控、数据产品等C端业务提供了实时 OLAP 服务</li>
</ul>
<h2>三、Druid的架构</h2>
<p><img src="/img/bVboQBc?w=1920&h=1080" alt="图片描述" title="图片描述"></p>
<p>Druid 的架构是 Lambda 架构,分成实时层( Overlord、 MiddleManager )和批处理层( Broker 和 Historical )。主要的节点包括(PS: Druid 的所有功能都在同一个软件包中,通过不同的命令启动):</p>
<ul>
<li>Coordinator 节点:负责集群 Segment 的管理和发布,并确保 Segment 在 Historical 集群中的负载均衡</li>
<li>Overlord 节点:Overlord 负责接受任务、协调任务的分配、创建任务锁以及收集、返回任务运行状态给客户端;在Coordinator 节点配置 asOverlord,让 Coordinator 具备 Overlord 功能,这样减少了一个组件的部署和运维</li>
<li>MiddleManager 节点:负责接收 Overlord 分配的索引任务,创建新启动Peon实例来执行索引任务,一个MiddleManager可以运行多个 Peon 实例</li>
<li>Broker 节点:负责从客户端接收查询请求,并将查询请求转发给 Historical 节点和 MiddleManager 节点。Broker 节点需要感知 Segment 信息在集群上的分布</li>
<li>Historical 节点:负责按照规则加载非实时窗口的Segment</li>
<li>Router 节点:可选节点,在 Broker 集群之上的API网关,有了 Router 节点 Broker 不在是单点服务了,提高了并发查询的能力</li>
</ul>
<h2>四、有赞 OLAP 平台的架构和功能解析</h2>
<p><strong>4.1 有赞 OLAP 平台的主要目标:</strong></p>
<ul>
<li>最大程度的降低实时任务开发成本:从开发实时任务需要写实时任务、设计存储,到只需填写配置即可完成实时任务的创建</li>
<li>提供数据补偿服务,保证数据的安全:解决因为实时窗口关闭,迟到数据的丢失问题</li>
<li>提供稳定可靠的监控服务:OLAP 平台为每一个 DataSource 提供了从数据摄入、Segment 落盘,到数据查询的全方位的监控服务</li>
</ul>
<p><strong>4.2 有赞 OLAP 平台架构</strong></p>
<p><img src="/img/bVboQFg?w=1742&h=980" alt="图片描述" title="图片描述"></p>
<p>有赞 OLAP 平台是用来管理 Druid 和周围组件管理系统,OLAP平台主要的功能:</p>
<ul>
<li>Datasource 管理</li>
<li>Tranquility 配置和实例管理:OLAP 平台可以通过配置管理各个机器上 Tranquility 实例,扩容和缩容</li>
<li>数据补偿管理:为了解决数据迟延的问题,OLAP 平台可以手动触发和自动触发补偿任务</li>
<li>Druid SQL查询: 为了帮助开发的同学调试 SQL,OLAP 平台集成了 SQL 查询功能</li>
<li>监控报警</li>
</ul>
<h3>4.2 Tranquility 实例管理</h3>
<p>OLAP 平台采用的数据摄取方式是 <a href="https://link.segmentfault.com/?enc=57L%2BSUjGlWMuw5E6LUsFZQ%3D%3D.%2Bm7gYV4Q6%2FAP8ipN0gdDbXG3WTQ%2FijknerXEGgO8XpTZLnIndKqlK0Z%2FVc3QElgl" rel="nofollow">Tranquility工具</a>,根据流量大小对每个 DataSource 分配不同 Tranquility 实例数量; DataSource 的配置会被推送到 Agent-Master 上,Agent-Master 会收集每台服务器的资源使用情况,选择资源丰富的机器启动 Tranquility 实例,目前只要考虑服务器的内存资源。同时 OLAP 平台还支持 Tranquility 实例的启停,扩容和缩容等功能。</p>
<p><img src="/img/bVboQBx?w=821&h=509" alt="图片描述" title="图片描述"></p>
<h3>4.3 解决数据迟延问题——离线数据补偿功能</h3>
<p>流式数据处理框架都会有时间窗口,迟于窗口期到达的数据会被丢弃。如何保证迟到的数据能被构建到 Segment 中,又避免实时任务窗口长期不能关闭。我们研发了 Druid 数据补偿功能,通过 OLAP 平台配置流式 ETL 将原始的数据存储在 HDFS 上,基于 Flume 的流式 ETL 可以保证按照 Event 的时间,同一小时的数据都在同一个文件路径下。再通过 OLAP 平台手动或者自动触发 Hadoop-Batch 任务,从离线构建 Segment。</p>
<p><img src="/img/bVboQBA?w=726&h=378" alt="图片描述" title="图片描述"></p>
<p>基于 Flume 的 ETL 采用了 HDFS Sink 同步数据,实现了 Timestamp 的 Interceptor,按照 Event 的时间戳字段来创建文件(每小时创建一个文件夹),延迟的数据能正确归档到相应小时的文件中。</p>
<h3>4.4 冷热数据分离</h3>
<p>随着接入的业务增加和长期的运行时间,数据规模也越来越大。Historical 节点加载了大量 Segment 数据,观察发现大部分查询都集中在最近几天,换句话说最近几天的热数据很容易被查询到,因此数据冷热分离对提高查询效率很重要。Druid 提供了Historical 的 Tier 分组机制与数据加载 Rule 机制,通过配置能很好的将数据进行冷热分离。 <br>首先将 Historical 群进行分组,默认的分组是"_default_tier",规划少量的 Historical 节点,使用 SATA 盘;把大量的 Historical 节点规划到 "hot" 分组,使用 SSD 盘。然后为每个 DataSource 配置加载 Rule :</p>
<ul>
<li>rule1: 加载1份最近30天的 Segment 到 "hot" 分组;</li>
<li>rule2: 加载2份最近6个月的 Segment 到 "_default_tier" 分组;</li>
<li>rule3: Drop 掉之前的所有 Segment(注:Drop 只影响 Historical 加载 Segment,Drop 掉的 Segment 在 HDFS 上仍有备份)</li>
</ul>
<pre><code>{"type":"loadByPeriod","tieredReplicants":{"hot":1}, "period":"P30D"}
{"type":"loadByPeriod","tieredReplicants":{"_default_tier":2}, "period":"P6M"}
{"type":"dropForever"}</code></pre>
<p>提高 "hot"分组集群的 druid.server.priority 值(默认是0),热数据的查询都会落到 "hot" 分组。</p>
<p><img src="/img/bVboQBK?w=1060&h=536" alt="图片描述" title="图片描述"></p>
<h3>4.5 监控与报警</h3>
<p>Druid 架构中的各个组件都有很好的容错性,单点故障时集群依然能对外提供服务:Coordinator 和 Overlord 有 HA 保障;Segment 是多副本存储在HDFS/S3上;同时 Historical 加载的 Segment 和 Peon 节点摄取的实时部分数据可以设置多副本提供服务。同时为了能在节点/集群进入不良状态或者达到容量极限时,尽快的发出报警信息。和其他的大数据框架一样,我们也对 Druid 做了详细的监控和报警项,分成了2个级别:</p>
<ul><li><strong>基础监控</strong></li></ul>
<p>包括各个组件的服务监控、集群水位和状态监控、机器信息监控</p>
<ul><li><strong>业务监控</strong></li></ul>
<p>业务监控包括:实时任务创建、数据摄取 TPS、消费迟延、持久化相关、查询 RT/QPS 等的关键指标,有单个 DataSource 和全局的2种不同视图;同时这些监控项都有设置报警项,超过阈值触发报警提醒。业务指标的采集是大部分是通过 Druid 框架自身提供的<a href="https://link.segmentfault.com/?enc=RraGpC9BWY1unxCk99HAew%3D%3D.dScLopdVCurRE5Y5URwChh94b%2Bk%2FOPAfPsxQyKuX8OToQZUZvW8Gb1YVhXePnk7faRl6sfWZxgC5llk5UN9Pfg%3D%3D" rel="nofollow"> Metrics 和 Alerts 信息</a>,然后流入到 Kafka / OpenTSDB 等组件,通过流数据分析获得我们想要的指标。</p>
<h3>4.6 部署架构</h3>
<p><img src="/img/bVboQB2?w=1256&h=918" alt="图片描述" title="图片描述"></p>
<p>Historical 集群的部署和4.4节中描述的数据冷热分离相对应,用 SSD 集群存储最近的N天的热数据(可调节 Load 的天数),用相对廉价的 Sata 机型存储更长时间的历史冷数据,同时充分利用 Sata 的 IO 能力,把 Segment Load到不同磁盘上;在有赞有很多的收费业务,我们在硬件层面做隔离,保证这些业务在查询端有足够的资源;在接入层,使用 Router 做路由,避免了 Broker 单点问题,也能很大的程度集群查询吞吐量;在 MiddleManager 集群,除了部署有 Index 任务(内存型任务)外,我们还混合部署了部分流量高 Tranquility 任务(CPU型任务),提高了 MiddleManager 集群的资源利用率。</p>
<h3>4.7 贡献开源社区</h3>
<p>在有赞业务查询方式一般是 SQL On Broker/Router,我们发现一旦有少量慢查询的情况,客户端会出现查询不响应的情况,而且连接越来越难获取到。登录到Broker 的服务端后发现,可用连接数量急剧减少至被耗尽,同时出现了大量的 TCP Close_Wait。用 jstack 工具排查之后发现有 deadlock 的情况,具体的 Stack 请查看 <a href="https://link.segmentfault.com/?enc=OH3a0s%2Fvds2Kxuk7L%2F58FA%3D%3D.OULIP%2BvAt7KamukPhEXukYjCi%2FfFkfBhZru3Ur63myQlXRUnKYoRYy58qrrWtbwQIfLvL8%2FJIuweP1qsygG%2F4w%3D%3D" rel="nofollow">ISSUE-6867</a>。</p>
<p>经过源码排查之后发现,DruidConnection为每个 Statement 注册了回调。在正常的情况下 Statement 结束之后,执行回调函数从 DruidConnection 的 statements 中 remove 掉自己的状态;如果有慢查询的情况(超过最长连接时间或者来自客户端的Kill),connection 会被强制关闭,同时关闭其下的所有 statements ,2个线程(关闭connection的线程和正在退出 statement 的线程)各自拥有一把锁,等待对方释放锁,就会产生死锁现象,连接就会被马上耗尽。</p>
<pre><code class="java">// statement线程退出时执行的回调函数
final DruidStatement statement = new DruidStatement(
connectionId,
statementId,
ImmutableSortedMap.copyOf(sanitizedContext),
() -> {
// onClose function for the statement
synchronized (statements) {
log.debug("Connection[%s] closed statement[%s].", connectionId, statementId);
statements.remove(statementId);
}
}
);</code></pre>
<pre><code class="java">// 超过最长连接时间的自动kill
return connection.sync(
exec.schedule(
() -> {
log.debug("Connection[%s] timed out.", connectionId);
closeConnection(new ConnectionHandle(connectionId));
},
new Interval(DateTimes.nowUtc(), config.getConnectionIdleTimeout()).toDurationMillis(),
TimeUnit.MILLISECONDS
)
);</code></pre>
<p>在排查清楚问题之后,我们也向社区提了 <a href="https://link.segmentfault.com/?enc=zRFgk3CPKUGzX3ZgA4atXw%3D%3D.4FA2dJzuE23dlVXzQGivzO5%2FLPVv7cy6Pyz%2BkTOHQEeShXROgoW4LLpc7hfTiUE79IX7EfubRtin0uPVfAK22A%3D%3D" rel="nofollow">PR-6868</a> 。目前已经成功合并到 Master 分支中,将会 0.14.0 版本中发布。如果读者们也遇到这个问题,可以直接把该PR cherry-pick 到自己的分支中进行修复。</p>
<h2>五、挑战和未来的展望</h2>
<h3>5.1 数据摄取系统</h3>
<p>目前比较常用的数据摄取方案是:KafkaIndex 和 Tranquility 。我们采用的是 Tranquility 的方案,目前 Tranquility 支持了 Kafka 和 Http 方式摄取数据,摄取方式并不丰富;Tranquility 也是 MetaMarket 公司开源的项目,更新速度比较缓慢,不少功能缺失,最关键的是监控功能缺失,我们不能监控到实例的运行状态,摄取速率、积压、丢失等信息。<br>目前我们对 Tranquility 的实例管理支持启停,扩容缩容等操作,实现的方式和 Druid 的 MiddleManager 管理 Peon 节点是一样的。把 Tranquility 或者自研摄取工具转换成 Yarn 应用或者 Docker 应用,就能把资源调度和实例管理交给更可靠的调度器来做。</p>
<h3>5.2 Druid 的维表 JOIN 查询</h3>
<p>Druid 目前并不没有支持 <a href="https://link.segmentfault.com/?enc=4CBqYfjuccY7R8DNWRqEKQ%3D%3D.4i2iro2wEsbacA28iviliY5i85c39JjP1ucIk%2BH97hLq88lDeOOAeercLNgYsR3A" rel="nofollow">JOIN查询</a>,所有的聚合查询都被限制在单 DataSource 内进行。但是实际的使用场景中,我们经常需要几个 DataSource 做 JOIN 查询才能得到所需的结果。这是我们面临的难题,也是 Druid 开发团队遇到的难题。</p>
<h3>5.3 整点查询RT毛刺问题</h3>
<p>对于 C 端的 OLAP 查询场景,RT 要求比较高。由于 Druid 会在整点创建当前小时的 Index 任务,如果查询正好落到新建的 Index 任务上,查询的毛刺很大,如下图所示: </p>
<p><img src="/img/bVboQCo?w=1871&h=807" alt="图片描述" title="图片描述"></p>
<p>我们已经进行了一些优化和调整,首先调整 warmingPeriod 参数,整点前启动 Druid 的 Index 任务;对于一些 TPS 低,但是 QPS 很高的 DataSource ,调大 SegmentGranularity,大部分 Query 都是查询最近24小时的数据,保证查询的数据都在内存中,减少新建 Index 任务的,查询毛刺有了很大的改善。尽管如此,离我们想要的目标还是一定的差距,接下去我们去优化一下源码。</p>
<h3>5.4 历史数据自动Rull-Up</h3>
<p>现在大部分 DataSource 的 Segment 粒度( SegmentGranularity )都是小时级的,存储在 HDFS 上就是每小时一个Segment。当需要查询时间跨度比较大的时候,会导致Query很慢,占用大量的 Historical 资源,甚至出现 Broker OOM 的情况。如果创建一个 Hadoop-Batch 任务,把一周前(举例)的数据按照天粒度 Rull-Up 并且 重新构建 Index,应该会在压缩存储和提升查询性能方面有很好的效果。关于历史数据 Rull-Up 我们已经处于实践阶段了,之后会专门博文来介绍。</p>
<p>最后打个小广告,有赞大数据团队基础设施团队,主要负责有赞的数据平台 (DP), 实时计算 (Storm, Spark Streaming, Flink),离线计算 (HDFS, YARN, HIVE, SPARK SQL),在线存储(HBase),实时 OLAP (Druid) 等数个技术产品,欢迎感兴趣的小伙伴联系 zhaojiandong@youzan.com</p>
CAP 一致性协议及应用解析
https://segmentfault.com/a/1190000018275818
2019-02-25T14:44:50+08:00
2019-02-25T14:44:50+08:00
有赞技术
https://segmentfault.com/u/youzantech
11
<h2>一、一致性</h2>
<h3>1.1 CAP 理论</h3>
<ol>
<li>C 一致性:分布式环境中,一致性是指多个副本之间,在同一时刻能否有同样的值</li>
<li>A 可用性:系统提供的服务必须一直处于可用的状态。即使集群中一部分节点故障。</li>
<li>P 分区容错性:系统在遇到节点故障,或者网络分区时,任然能对外提供一致性和可用性的服务。以实际效果而言,分区相当于通信的时限要求。系统如果不能在一定实现内达成数据一致性,也就意味着发生了分区的情况。必须就当前操作在 C 和 A 之前作出选择</li>
</ol>
<h3>1.2 CAP不能同时满足的证明</h3>
<p>假设系统中有 5 个节点,n1~n5。n1,n2,n3 在A物理机房。n4,n5 在 B 物理机房。现在发生了网络分区,A 机房和 B 机房网络不通。<br><strong>保证一致性</strong>:此时客户端在 A 机房写入数据,不能同步到B机房。写入失败。此时失去了可用性。<br><strong>保证可用性</strong>:数据在 A 机房的 n1~n3 节点都写入成功后返回成功。数据在 B 机房的 n4~n5 节点也写入数据,返回成功。同一份数据在 A 机房和 B 机房出现了数据不一致的情况。聪明如你,可以想到 zookeeper,当一个节点 down 掉,系统会将其剔出节点,然其它一半以上的节点写入成功即可。是不是 zookeeper 同时满足了 CAP 呢。其实这里有一个误区,系统将其剔出节点。有一个隐含的条件是,系统引入了一个调度者,一个踢出坏节点的调度者。当调度者和 zookeeper 节点出现网络分区,整个系统还是不可用的。</p>
<h3>1.3 常见场景</h3>
<p>CA without P: 在分布式环境中,P 是不可避免的,天灾(某软公司的Azure被雷劈劈中)人祸(某里公司 A 和 B 机房之间的光缆被挖断)都能导致P<br>CP without A:相当于每个写请求都须在Server之前强一致。P (分区)会导致同步时间无限延长。这个是可以保证的。例如数据库的分布式事务,两阶段提交,三阶段提交等<br>AP without C: 当网络分区发生,A 和 B 集群失去联系。为了保证高可用,系统在写入时,系统写入部分节点就会返回成功,这会导致在一定时间之内,客户端从不同的机器上面读取到的是数据是不一样的。例如 redis 主从异步复制架构,当 master down 掉,系统会切换到 slave,由于是异步复制,salve 不是最新的数据,会导致一致性的问题。</p>
<h2>二、一致性协议</h2>
<h3>2.1 两阶段提交协议(2PC)</h3>
<blockquote>二阶段提交( Two-phaseCommit )是指,在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法( Algorithm )。通常,二阶段提交也被称为是一种协议( Protocol )。在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的 ACID 特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。</blockquote>
<h4>2.1.1 两种角色</h4>
<ul>
<li>协调者</li>
<li>参与者</li>
</ul>
<h4>2.1.2处理阶段</h4>
<ul>
<li>询问投票阶段:事务协调者给每个参与者发送 Prepare 消息,参与者受到消息后,,要么在本地写入 redo 和 undo 日志成功后,返回同意的消息,否者一个终止事务的消息。</li>
<li>执行初始化(执行提交):协调者在收到所有参与者的消息后,如果有一个返回终止事务,那么协调者给每个参与者发送回滚的指令。否者发送 commit 消息</li>
</ul>
<h4>2.1.3 异常情况处理</h4>
<ul>
<li>协调者故障:备用协调者接管,并查询参与者执行到什么地址</li>
<li>参与者故障:协调者会等待他重启然后执行</li>
<li>协调者和参与者同时故障:协调者故障,然后参与者也故障。例如:有机器 1,2,3,4。其中 4 是协调者,1,2,3是参与者 4 给1,2 发完提交事务后故障了,正好3这个时候也故障了,注意这是 3 是没有提交事务数据的。在备用协调者启动了,去询问参与者,由于3死掉了,一直不知道它处于什么状态(接受了提交事务,还是反馈了能执行还是不能执行 3 个状态)。面对这种情况,2PC,是不能解决的,要解决需要下文介绍的 3PC。</li>
</ul>
<h4>2.1.4缺点</h4>
<ul>
<li>
<strong>同步阻塞问题</strong>:由于所有参与的节点都是事务阻塞型的,例如<code>update table set status=1 where current_day=20181103</code>,那么参与者<code>table</code>表的<code>current_day=20181103</code>的记录都会被锁住,其他的要修改<code>current_day=20181103</code>行的事务,都会被阻塞</li>
<li>
<strong>单点故障阻塞其他事务</strong>:协调者再执行提交的阶段 down 掉,所有的参与者出于锁定事务资源的状态中。无法完成相关的事务操作。</li>
<li>
<strong>参与者和协调者同时 down 掉</strong>:协调者在发送完 commit 消息后 down 掉,而唯一接受到此消息的参与者也 down 掉了。新协调者接管,也是一个懵逼的状态,不知道此条事务的状态。无论提交或者回滚都是不合适的。<strong>这个是两阶段提交无法改变的</strong>
</li>
</ul>
<h3>2.2 三阶段提交协议(3PC)</h3>
<p>2PC 当时只考虑如果单机故障的情况,是可以勉强应付的。当遇到协调者和参与者同时故障的话,2PC 的理论是不完善的。此时 3PC 登场。<br>3PC 就是对 2PC 漏洞的补充协议。主要改动两点</p>
<ol>
<li>在 2PC 的第一阶段和第二阶段插入一个准备阶段,做到就算参与者和协调者同时故障也不阻塞,并且保证一致性。</li>
<li>在协调者和参与者之间引入超时机制</li>
</ol>
<h4>2.2.1 处理的三个阶段</h4>
<ul>
<li>事务询问阶段( can commit 阶段):协调者向参与者发送 commit 请求,然后等待参与者反应。这个和 2PC 阶段不同的是,此时参与者没有锁定资源,没有写 redo,undo,执行回滚日志。<strong>回滚代价低</strong>
</li>
<li>事务准备阶段 (pre commit):如果参与者都返回ok,那么就发送Prepare消息,参与者本地执行redo和undo日志。否者就向参与者提交终止(abort)事务的请求。如果再发送Prepare消息的时候,等待超时,也会向参与者提交终止事务的请求。</li>
<li>执行事务阶段(do commit):如果所有发送Prepare都返回成功,那么此时变为执行事务阶段,向参与者发送commit事务的消息。否者回滚事务。在此阶段参与者如果在一定时间内没有收到docommit消息,触发超时机制,会自己提交事务。此番处理的逻辑是,能够进入此阶段,说明在事务询问阶段所有节点都是好的。即使在提交的时候部分失败,有理由相信,此时大部分节点都是好的。是可以提交的</li>
</ul>
<h4>2.2.2 缺点</h4>
<ul>
<li>不能解决网络分区的导致的数据不一致的问题:例如 1~5 五个参与者节点,1,2,3 个节点在A机房,4,5 节点在 B 机房。在<code>pre commit</code>阶段,1~5 个节点都收到 Prepare 消息,但是节点1执行失败。协调者向1~5个节点发送回滚事务的消息。但是此时A,B机房的网络分区。1~3 号节点会回滚。但是 4~5 节点由于没收到回滚事务的消息,而提交了事务。待网络分区恢复后,会出现数据不一致的情况。</li>
<li>不能解决 fail-recover 的问题:</li>
</ul>
<p>由于 3PC 有超时机制的存在,2PC 中未解决的问题,参与者和协调者同时 down 掉,也就解决了。一旦参与者在超时时间内没有收到协调者的消息,就会自己提交。这样也能避免参与者一直占用共享资源。但是其在网络分区的情况下,不能保证数据的一致性</p>
<h3>2.3 Paxos协议</h3>
<p>像 2PC 和 3PC 都需要引入一个协调者的角色,当协调者 down 掉之后,整个事务都无法提交,参与者的资源都出于锁定的状态,对于系统的影响是灾难性的,而且出现网络分区的情况,很有可能会出现数据不一致的情况。有没有不需要协调者角色,每个参与者来协调事务呢,在网络分区的情况下,又能最大程度保证一致性的解决方案呢。此时 Paxos 出现了。</p>
<p>Paxos 算法是 Lamport 于 1990 年提出的一种基于消息传递的一致性算法。由于算法难以理解起初并没有引起人们的重视,Lamport在八年后重新发表,即便如此Paxos算法还是没有得到重视。2006 年 Google 的三篇论文石破天惊,其中的 chubby 锁服务使用Paxos 作为 chubbycell 中的一致性,后来才得到关注。</p>
<h4>2.3.1 解决了什么问题</h4>
<ul><li>Paxos 协议是一个解决分布式系统中,多个节点之间就某个值(提案)达成一致(决议)的通信协议。它能够处理在少数节点离线的情况下,剩余的多数节点仍然能够达成一致。<strong>即每个节点,既是参与者,也是决策者</strong>
</li></ul>
<h4>2.3.2 两种角色(两者可以是同一台机器)</h4>
<ul>
<li>Proposer:提议提案的服务器</li>
<li>Acceptor:批准提案的服务器</li>
</ul>
<p>由于 Paxos 和下文提到的 zookeeper 使用的 ZAB 协议过于相似,详细讲解参照下文,<code>Zookeeper原理</code>部分</p>
<h3>2.4 Raft协议</h3>
<p>Paxos 是论证了一致性协议的可行性,但是论证的过程据说晦涩难懂,缺少必要的实现细节,而且工程实现难度比较高广为人知实现只有 zk 的实现 zab 协议。然后斯坦福大学RamCloud项目中提出了易实现,易理解的分布式一致性复制协议 Raft。Java,C++,Go 等都有其对应的实现</p>
<h4>2.4.1 基本名词</h4>
<ul>
<li>
<p>节点状态</p>
<ul>
<li>Leader(主节点):接受 client 更新请求,写入本地后,然后同步到其他副本中</li>
<li>Follower(从节点):从 Leader 中接受更新请求,然后写入本地日志文件。对客户端提供读请求</li>
<li>Candidate(候选节点):如果 follower 在一段时间内未收到 leader 心跳。则判断 leader 可能故障,发起选主提议。节点状态从 Follower 变为 Candidate 状态,直到选主结束</li>
</ul>
</li>
<li>termId:任期号,时间被划分成一个个任期,每次选举后都会产生一个新的 termId,一个任期内只有一个 leader。termId 相当于 paxos 的 proposalId。</li>
<li>RequestVote:请求投票,candidate 在选举过程中发起,收到 quorum (多数派)响应后,成为 leader。</li>
<li>AppendEntries:附加日志,leader 发送日志和心跳的机制</li>
<li>election timeout:选举超时,如果 follower 在一段时间内没有收到任何消息(追加日志或者心跳),就是选举超时。</li>
</ul>
<h4>2.4.2 特性</h4>
<ul>
<li>Leader 不会修改自身日志,只会做追加操作,日志只能由Leader转向Follower。例如即将要down掉的Leader节点已经提交日志1,未提交日志 2,3。down 掉之后,节点 2 启动最新日志只有 1,然后提交了日志 4。好巧不巧节点 1 又启动了。此时节点 2 的编号 4 日志会追加到节点 1 的编号 1 日志的后面。节点 1 编号 2,3 的日志会丢掉。</li>
<li>不依赖各个节点物理时序保证一致性,通过逻辑递增的 term-id 和 log-id 保证。</li>
</ul>
<h4>2.4.3 选主契机</h4>
<ol>
<li>在超时时间内没有收到 Leader 的心跳</li>
<li>启动时</li>
</ol>
<h4>2.4.4 选主过程</h4>
<p><img src="/img/remote/1460000018275821" alt="raft-1" title="raft-1"></p>
<p><img src="/img/remote/1460000018275822" alt="raft-2" title="raft-2"></p>
<p>如图<code>raft-2</code>所示,Raft将时间分为多个 term(任期),term 以连续的整数来标识,每个 term 表示一个选举的开始。例如Follower 节点 1。在 term1 和 term2 连接处的时间,联系不到Leader,将currentTerm编号加1,变成2,进入了到term2任期,在term2的蓝色部分选举完成,绿色部分正常工作。当然一个任期不一定能选出Leader,那么会将currentTerm继续加1,然后继续进行选举,例如图中的t3。选举的原则是,每一轮选举每个选民一张选票,投票的请求先到且选民发现候选人节点的日志id大于等于自己的,就会投票,否者不会投票。获得半数以上的票的节点成为主节点(<strong>注意</strong>这并不是说选出来的事务id一定是最大的,。例如下图<code>raft-1</code>a~f六个节点(正方形框里面的数字是选举的轮数term)。在第四轮选举中,a先发出投票,六台机器中,a~e都会投a,即使f不投a,a也会赢得选举。)。如果没有事务id(如刚启动时),就遵循投票请求先来先头。然后Leader将最新的日志复制到各个节点,再对外提供服务。<br>当然除了这些选举限制,还会有其他的情况。如commit限制等保证,Leader选举成功一定包含所有的commit和log</p>
<h4>2.4.5 日志复制过程</h4>
<p><img src="/img/remote/1460000018275823" alt="raft-3-SegmentedLog" title="raft-3-SegmentedLog"></p>
<p>raft日志写入过程,主节点收到一个<code>x=1</code>的请求后,会写入本地日志,然后将<code>x=1</code>的日志广播出去,follower如果收到请求,会将日志写入本地 log ,然后返回成功。当 leader 收到半数以上的节点回应时,会将此日志的状态变为commit,然后广播消息让 follwer 提交日志。节点在 commit 日志后,会更新状态机中的 logindex 。<br>firstLogIndex/lastLogIndex 为节点中开始和结束的索引位置(包含提交,未提交,写入状态机)commitIndex:已提交的索引。applyIndex:已写入状态机中的索引</p>
<p>日志复制的本质是让 follwer 和 Leader 的已提交的日志顺序和内容都完全一样,用于保证一致性。<br>具体的原则就是<br>原则1:两个日志在不同的 raft 节点中,如果有两个相同的 term 和 logIndex<br>,则保证两个日志的内容完全一样。<br>原则2:两段日志在不同的 raft 节点中,如果起始和终止的的 term,logIndex 都相同,那么两段日志中日志内容完全一样。<br>如何保证<br>第一个原则只需要在创建 logIndex 的时候使用新的 logIndex,保证 logIndex 的唯一性。而且创建之后不去更改。那么在 leader 复制到 follwer 之后,logIndex,term 和日志内容都没变。<br>第二个原则,在 Leader 复制给 Follower 时,要传递当前最新日志 currenTermId 和currentLogIndex,以及上一条日志 preCurrentTermId 和 preCurrentLogIndex。如图<code>raft-1</code>,在 d 节点,term7,logIndex12。在给节点节点 a 同步时,发送(term7,logIndex11),(term7,logIndex12),a 节点没有找到(term7,logIndex11)的日志,会让Leader,d 节点重新发送。d 节点会重新发(term6,logIndex10)(term7,logIndex11),还是没有(term6,logIndex10)的日志,依然会拒绝同步。接着发(term6,logIndex9)(term6,logIndex10)。现在a节点有了(term6,logIndex9)。那么 leader节点就会将(term6,logIndex9) ~ (term7,logIndex11)日志内容给节点 a,节点 a 将会和节点d有一样的日志。</p>
<h2>三、Zookeeper 原理</h2>
<h3>3.1 概述</h3>
<p>Google 的粗粒度锁服务 Chubby 的设计开发者 Burrows 曾经说过:“所有一致性协议本质上要么是 Paxos 要么是其变体”。Paxos 虽然解决了分布式系统中,多个节点就某个值达成一致性的通信协议。但是还是引入了其他的问题。由于其每个节点,都可以提议提案,也可以批准提案。当有三个及以上的 proposer 在发送 prepare 请求后,很难有一个 proposer 收到半数以上的回复而不断地执行第一阶段的协议,<strong>在这种竞争下,会导致选举速度变慢</strong>。<br>所以 zookeeper 在 paxos 的基础上,提出了 ZAB 协议,本质上是,只有一台机器能提议提案(Proposer),而这台机器的名称称之为 Leader 角色。其他参与者扮演 Acceptor 角色。为了保证 Leader 的健壮性,引入了 Leader 选举机制。</p>
<p>ZAB协议还解决了这些问题</p>
<ol>
<li>在半数以下节点宕机,依然能对台提供服务</li>
<li>客户端所有的写请求,交由 Leader 来处理。写入成功后,需要同步给所有的 follower 和 observer</li>
<li>leader 宕机,或者集群重启。需要确保已经再 Leader 提交的事务最终都能被服务器提交,并且确保集群能快速回复到故障前的状态</li>
</ol>
<h3>3.2 基本概念</h3>
<ul>
<li>
<p>基本名词</p>
<ul>
<li>数据节点(dataNode):zk 数据模型中的最小数据单元,数据模型是一棵树,由斜杠( / )分割的路径名唯一标识,数据节点可以存储数据内容及一系列属性信息,同时还可以挂载子节点,构成一个层次化的命名空间。</li>
<li>事务及 zxid:事务是指能够改变 Zookeeper 服务器状态的操作,一般包括数据节点的创建与删除、数据节点内容更新和客户端会话创建与失效等操作。对于每个事务请求,zk 都会为其分配一个全局唯一的事务 ID,即 zxid,是一个 64 位的数字,高 32 位表示该事务发生的集群选举周期(集群每发生一次 leader 选举,值加 1),低 32 位表示该事务在当前选择周期内的递增次序(leader 每处理一个事务请求,值加 1,发生一次 leader 选择,低 32 位要清 0)。</li>
<li>事务日志:所有事务操作都是需要记录到日志文件中的,可通过 dataLogDir 配置文件目录,文件是以写入的第一条事务 zxid 为后缀,方便后续的定位查找。zk 会采取“磁盘空间预分配”的策略,来避免磁盘 Seek 频率,提升 zk 服务器对事务请求的影响能力。默认设置下,每次事务日志写入操作都会实时刷入磁盘,也可以设置成非实时(写到内存文件流,定时批量写入磁盘),但那样断电时会带来丢失数据的风险。</li>
<li>事务快照:数据快照是 zk 数据存储中另一个非常核心的运行机制。数据快照用来记录 zk 服务器上某一时刻的全量内存数据内容,并将其写入到指定的磁盘文件中,可通过 dataDir 配置文件目录。可配置参数 snapCount,设置两次快照之间的事务操作个数,zk 节点记录完事务日志时,会统计判断是否需要做数据快照(距离上次快照,事务操作次数等于snapCount/2~snapCount 中的某个值时,会触发快照生成操作,随机值是为了避免所有节点同时生成快照,导致集群影响缓慢)。</li>
</ul>
</li>
<li>
<p>核心角色</p>
<ul>
<li>leader:系统刚启动时或者 Leader 崩溃后正处于选举状态;</li>
<li>follower:Follower 节点所处的状态,Follower 与 Leader 处于数据同步阶段;</li>
<li>observer:Leader 所处状态,当前集群中有一个 Leader 为主进程。</li>
</ul>
</li>
<li>
<p>节点状态</p>
<ul>
<li>LOOKING:节点正处于选主状态,不对外提供服务,直至选主结束;</li>
<li>FOLLOWING:作为系统的从节点,接受主节点的更新并写入本地日志;</li>
<li>LEADING:作为系统主节点,接受客户端更新,写入本地日志并复制到从节点</li>
</ul>
</li>
</ul>
<h3>3.3 常见的误区</h3>
<ul>
<li>写入节点后的数据,立马就能被读到,这是错误的。<strong> zk 写入是必须通过 leader 串行的写入,而且只要一半以上的节点写入成功即可。而任何节点都可提供读取服务</strong>。例如:zk,有 1~5 个节点,写入了一个最新的数据,最新数据写入到节点 1~3,会返回成功。然后读取请求过来要读取最新的节点数据,请求可能被分配到节点 4~5 。而此时最新数据还没有同步到节点4~5。会读取不到最近的数据。<strong>如果想要读取到最新的数据,可以在读取前使用 sync 命令</strong>。</li>
<li>zk启动节点不能偶数台,这也是错误的。zk 是需要一半以上节点才能正常工作的。例如创建 4 个节点,半数以上正常节点数是 3。也就是最多只允许一台机器 down 掉。而 3 台节点,半数以上正常节点数是 2,也是最多允许一台机器 down 掉。4 个节点,多了一台机器的成本,但是健壮性和 3 个节点的集群一样。基于成本的考虑是不推荐的</li>
</ul>
<h3>3.4 选举同步过程</h3>
<h4>3.4.1 发起投票的契机</h4>
<ol>
<li>节点启动</li>
<li>节点运行期间无法与 Leader 保持连接,</li>
<li>Leader 失去一半以上节点的连接</li>
</ol>
<h4>3.4.2 如何保证事务</h4>
<p>ZAB 协议类似于两阶段提交,客户端有一个写请求过来,例如设置 <code>/my/test</code> 值为 1,Leader 会生成对应的事务提议(proposal)(当前 zxid为 0x5000010 提议的 zxid 为Ox5000011),现将<code>set /my/test 1</code>(此处为伪代码)写入本地事务日志,然后<code>set /my/test 1</code>日志同步到所有的follower。follower收到事务 proposal ,将 proposal 写入到事务日志。如果收到半数以上 follower 的回应,那么广播发起 commit 请求。follower 收到 commit 请求后。会将文件中的 zxid ox5000011 应用到内存中。</p>
<p>上面说的是正常的情况。有两种情况。第一种 Leader 写入本地事务日志后,没有发送同步请求,就 down 了。即使选主之后又作为 follower 启动。此时这种还是会日志会丢掉(原因是选出的 leader 无此日志,无法进行同步)。第二种 Leader 发出同步请求,但是还没有 commit 就 down 了。此时这个日志不会丢掉,会同步提交到其他节点中。</p>
<h4>3.4.3 服务器启动过程中的投票过程</h4>
<p>现在 5 台 zk 机器依次编号 1~5</p>
<ol>
<li>节点 1 启动,发出去的请求没有响应,此时是 Looking 的状态</li>
<li>节点 2 启动,与节点 1 进行通信,交换选举结果。由于两者没有历史数据,即 zxid 无法比较,此时 id 值较大的节点 2 胜出,但是由于还没有超过半数的节点,所以 1 和 2 都保持 looking 的状态</li>
<li>节点 3 启动,根据上面的分析,id 值最大的节点 3 胜出,而且超过半数的节点都参与了选举。节点 3 胜出成为了 Leader</li>
<li>节点 4 启动,和 1~3 个节点通信,得知最新的 leader 为节点 3,而此时 zxid 也小于节点 3,所以承认了节点 3 的 leader 的角色</li>
<li>节点 5 启动,和节点 4 一样,选取承认节点 3 的 leader 的角色</li>
</ol>
<h4>3.4.4 服务器运行过程中选主过程</h4>
<p><img src="/img/remote/1460000018275824?w=764&h=1117" alt="zk-快速选举算法.png" title="zk-快速选举算法.png"></p>
<p>1.节点 1 发起投票,<strong>第一轮投票先投自己</strong>,然后进入 Looking 等待的状态<br>2.其他的节点(如节点 2 )收到对方的投票信息。节点 2 在 Looking 状态,则将自己的投票结果广播出去(此时走的是上图中左侧的 Looking 分支);如果不在 Looking 状态,则直接告诉节点 1 当前的 Leader 是谁,就不要瞎折腾选举了(此时走的是上图右侧的 Leading/following 分支)<br>3.此时节点 1,收到了节点 2 的选举结果。如果节点 2 的 zxid 更大,那么清空投票箱,建立新的投票箱,广播自己最新的投票结果。在同一次选举中,如果在收到所有节点的投票结果后,如果投票箱中有一半以上的节点选出了某个节点,那么证明 leader 已经选出来了,投票也就终止了。否则一直循环</p>
<p>zookeeper 的选举,优先比较大 zxid,zxid 最大的节点代表拥有最新的数据。如果没有 zxid,如系统刚刚启动的时候,则比较机器的编号,优先选择编号大的</p>
<h3>3.5 同步的过程</h3>
<p>在选出 Leader 之后,zk 就进入状态同步的过程。其实就是把最新的 zxid 对应的日志数据,应用到其他的节点中。此 zxid 包含 follower 中写入日志但是未提交的 zxid 。称之为服务器提议缓存队列 committedLog 中的 zxid。</p>
<p>同步会完成三个 zxid 值的初始化。</p>
<p><code>peerLastZxid</code>:该 learner 服务器最后处理的 zxid。<br><code>minCommittedLog</code>:leader服务器提议缓存队列 committedLog 中的最小 zxid。<br><code>maxCommittedLog</code>:leader服务器提议缓存队列 committedLog 中的最大 zxid。<br>系统会根据 learner 的<code>peerLastZxid</code>和 leader 的<code>minCommittedLog</code>,<code>maxCommittedLog</code>做出比较后做出不同的同步策略</p>
<h4>3.5.1 直接差异化同步</h4>
<p>场景:<code>peerLastZxid</code>介于<code>minCommittedLogZxid</code>和<code>maxCommittedLogZxid</code>间</p>
<p>此种场景出现在,上文提到过的,Leader 发出了同步请求,但是还没有 commit 就 down 了。 leader 会发送 Proposal 数据包,以及 commit 指令数据包。新选出的 leader 继续完成上一任 leader 未完成的工作。</p>
<p>例如此刻Leader提议的缓存队列为 0x20001,0x20002,0x20003,0x20004,此处learn的peerLastZxid为0x20002,Leader会将0x20003和0x20004两个提议同步给learner</p>
<h4>3.5.2 先回滚在差异化同步/仅回滚同步</h4>
<p>此种场景出现在,上文提到过的,Leader写入本地事务日志后,还没发出同步请求,就down了,然后在同步日志的时候作为learner出现。</p>
<p>例如即将要 down 掉的 leader 节点 1,已经处理了 0x20001,0x20002,在处理 0x20003 时还没发出提议就 down 了。后来节点 2 当选为新 leader,同步数据的时候,节点 1 又神奇复活。如果新 leader 还没有处理新事务,新 leader 的队列为,0x20001, 0x20002,那么仅让节点 1 回滚到 0x20002 节点处,0x20003 日志废弃,称之为仅回滚同步。如果新 leader 已经处理 0x30001 , 0x30002 事务,那么新 leader 此处队列为0x20001,0x20002,0x30001,0x30002,那么让节点 1 先回滚,到 0x20002 处,再差异化同步0x30001,0x30002。</p>
<h4>3.5.3 全量同步</h4>
<p><code>peerLastZxid</code>小于<code>minCommittedLogZxid</code>或者leader上面没有缓存队列。leader直接使用SNAP命令进行全量同步</p>
<h2>四、使用 Raft + RocksDB 有赞分布式 KV 存储服务</h2>
<p>当前开源的缓存 kv 系统,大都是 AP 系统,例如设置主从同步集群 redis,master 异步同步到 slave。虽然在 master 停止服务后,slave 会顶上来。但是在 master 写入了数据,但是还没来得及同步到 slave 就 down 了,然后 slave 被选为主节点继续对外提供服务的情况下,会丢失部分数据。这对于要求强一致性的系统来说是不可接受的。例如很多场景下 redis 做分布式锁,有天然的缺陷在里面,如果 master 停止服务,这个锁不很不可靠的,虽然出现的几率很小,但一旦出现,将是致命的错误。</p>
<p>为了实现 CP 的 KV 存储系统,且要兼容现有的 redis 业务。有赞开发了 ZanKV(先已开源<a href="https://link.segmentfault.com/?enc=s6bZmTJSUnhbvQHOppX9dA%3D%3D.mLZ3R8WauN00PR113VELv7Bd4GC7EsR9kuNJJ3LpPTCBJOEFRLSml4%2BTfcUPPlWt" rel="nofollow">ZanRedisDB</a>)。</p>
<p><img src="/img/remote/1460000018275825" alt="ZanKV整体架构图" title="ZanKV整体架构图"></p>
<p><img src="/img/remote/1460000018275826" alt="ZanKV节点图" title="ZanKV节点图"></p>
<p>底层的存储结构是 RocksDB(底层采用 LSM 数据结构)。一个<code>set x=1</code>的会通过 redis protocol 协议传输,内容会通过 Raft 协议,同步写入到其他的节点的 RocksDB。有了Raft 理论的加持,RocksDB优秀的存储性能,即使遇到网络分区,master 节点 down 掉, slave 节点 down 掉,等一系列异常情况,其都能轻松应对。在扩容方面,系统用选择维护映射表的方式来建立分区和节点的关系,映射表会根据一定的算法并配合灵活的策略生成,来达到方便扩容。具体原理可参见<a href="https://link.segmentfault.com/?enc=sSHf%2BVcvcIyWT2lnsRda5w%3D%3D.otMy10TLwS3L%2FGR4oZFDprzQrcnaBQtfLzWKXR0AX6390zL%2BFWTJnsD84ipfI8zEvN9mSRiFQL683nxJYeHJ3X9umCUE3lovZZuuuW2EP0XkNshMMVoIHvWjIpb9d%2BDd" rel="nofollow">使用开源技术构建有赞分布式KV存储服务</a></p>
<h2>五、总结</h2>
<p>本文从三个方面介绍了一致性,首先是描述分布架构中的核心理论-CAP,以及其简单的证明。第二部分介绍了 CAP 里面协议,重点介绍了 Raft 协议。第三部分,重点介绍了常用的 zookeeper 原理。</p>
<p>为了保证数据 commit 之后不可丢,系统都会采用(WAL write ahead log)(在每次修改数据之前先写操作内容日志,然后再去修改数据。即使修改数据时异常,也可以通过操作内容日志恢复数据)</p>
<p>分布式存储系统中,是假设机器是不稳定,随时都有可能 down 掉的情况下来设计的。也就是说就算机器 down 掉了,用户写入的数据也不能丢,避免单点故障。为此每一份写入的数据,需要在多个副本中同时存放。例如 zk 节点数据复制,etcd 的数据复制。而复制数据给节点又会带来一致性的问题,例如主节点和从节点数据不一致改如何去同步数据。也会带来可用性的问题,如 leader 节点 down 掉,如何快速选主,恢复数据等。好在已有成熟的理论如 Paxos 协议,ZAB 协议 Raft 协议等做为支撑。</p>
<p>参考文章/书籍<br>《从 paxos 到 Zookeeper 分布式一致性原理与实践》</p>
<p><a href="https://link.segmentfault.com/?enc=WCUgZyAfg67ADiy9X6KSCA%3D%3D.hiicKIaYMkhDyvu0AzmeZ0BOjvBu%2FrdNYGNh%2Fxq7wrv2GO9X1Rvuh0UiHF2UW8%2BUv5Ogp2typFStSK3F5oBFvAYmBbg154NvZ2CMeVyEdfsXsCPWqCtVaKr2JGOS%2F0kn" rel="nofollow">使用开源技术构建有赞分布式KV存储服务</a></p>
<p><a href="https://link.segmentfault.com/?enc=SVJijokp7BJ0zDtv8h5q8g%3D%3D.mM6v4hqDFXY8%2BcE127ZW8OoUO%2BUy9OAoyuQiLTPs9WLdPc9eoAQXXFGPk%2FfDGcvC" rel="nofollow">关于分布式事务、两阶段提交协议、三阶提交协议</a></p>
<p><a href="https://link.segmentfault.com/?enc=m4GSO6swyaFclmPzGqkcOw%3D%3D.QC4QmBYiNdU%2FSF7OH5%2BJ%2FtThTYAeIMboZEs8BcSJ2Eh3xwzR5RjX3%2Ba0tPCzlSuox2rnpXA18C0xMtLMaTLs3g%3D%3D" rel="nofollow">zookeeper leader 和 learner 的数据同步</a></p>
<p><a href="https://link.segmentfault.com/?enc=POAngEYu3QwdcdfJLld3BA%3D%3D.AV4Duya5i6VPfS8i1xZ3tl%2BVYOpHTg5m49NBHW5N0RKI6u4V2SPO%2FPVbSOW2XUAB" rel="nofollow">浅析Zookeeper的一致性原理</a></p>
<p>[图解分布式协议- Raft ](<br><a href="https://link.segmentfault.com/?enc=ZqL21muCrSB3UZu6sdE7Zg%3D%3D.f0ixjzhVQ4bIgzec2f8osR3SB4k%2B346wCOYBf1aw9Ok%3D" rel="nofollow">http://ifeve.com/raft/)</a></p>
<p>[Raft协议详解](<br><a href="https://link.segmentfault.com/?enc=tQdR4t4Se%2FBpsUnkvmZIaw%3D%3D.%2Fy0zZL39yqdUx12C42AX%2Fipr%2FZyOfANiqhP9XmZaceXYJ6OtOksP9Pk8Be%2FSf50u" rel="nofollow">https://zhuanlan.zhihu.com/p/...</a></p>
透过 OKR 进行项目过程管理
https://segmentfault.com/a/1190000018275187
2019-02-25T14:14:20+08:00
2019-02-25T14:14:20+08:00
有赞技术
https://segmentfault.com/u/youzantech
7
<p>项目管理是项目的管理者在有限的资源约束下,运用系统的观点、方法和理论,对项目涉及的全部工作进行有效的管理。即从项目的投资决策开始到项目结束的全过程进行计划、组织、指挥、协调、控制和评价, 以实现项目的目标。[1]</p>
<p>在有赞,进行项目前会经过一系列的规划流程,确认资源后即启动项目进行。本文接下来会讨论的是如何透过 OKR 的方式来管理项目研发过程。</p>
<h2>一、简介 OKR</h2>
<p>OKR(Objectives and Key Results) 全称为目标和关键成果[2]。是由英特尔公司制定,由 John Doerr 引入谷歌后,广为大众所知道。在 OKR 的系统中,首先要制定一个「目标」,这目标要非常明确可衡量的,且必须要能符合 SMART (Specific、Measurable、Achievable、Relevant、Time-bounded) 原则,这样才能用来衡量是否已经实现目标。例如:不能说「想让网站成长」,而是要说「让网站的营收较去年同期成长 10%」或者说「让网站的 APRU 每月成长 15%] ,这就是给定一个明确、可量化的目标给大家完成,OKR 的目标不能是模糊、不明确的。</p>
<p>OKR 流程</p>
<ol>
<li>明确事项的目标</li>
<li>对关键性结果进行可量化的定义,并且明确标定「达成目标」与「未达成目标」的措施</li>
<li>共同努力,达成目标</li>
<li>根据项目进展进行评估</li>
</ol>
<h2>二、项目管理与 OKR</h2>
<p>简单介绍过 OKR 后,再回到项目管理上来看,大部分的项目会有几个关键内容:</p>
<ol>
<li>里程碑、完成时间</li>
<li>工作/任务量</li>
<li>资源</li>
</ol>
<p>下图为我司一般通用的项目研发流程</p>
<p><img src="/img/bVboPYm?w=2233&h=512" alt="图片描述" title="图片描述"></p>
<h4>2.1 结合 OKR 与项目过程进行目标拆解</h4>
<p>将 OKR 运用在项目管理的过程里,我们会需要订定长期目标、关键指标,这非常简单,因为项目本身就是一个具有范围定义的,所以:(你也可以结合一些敏捷开发方法论来辅助,像是 Scrum[3]、Kanban[4])</p>
<ul>
<li>目标 (O) - 项目上线,当然,你的项目也可以定义与业务指标相关,这样会让项目更有价值,例如:让 xxx 位使用者能使用等</li>
<li>关键指标 (KR) - 里程碑,当达到一个里程碑时,其实就是完成了一个项目里的关键指标</li>
<li>行动 (AC) - 投入哪些资源、需要哪些团队共同合作的策略、方式等</li>
</ul>
<p>当长期的目标已经制定了,但项目过程中的每个环节仍旧有许多未知、不明确的问题等待挖掘,且不同的项目内容,问题也各不相同。以 Scrum 方法论举例,在 Scrum 的过程中,提到了一些会议环节,像是 Planning Meeting、Daily Meeting 等,在大部分的会议中,我们会强调同步「昨天做了什么」、「今天要做什么」、「目前遇到了什么问题」。我们可以简单的将 OKR 结合进这些会议里,让会议的价值再提升一些。这时候我们就能切出一些更细致的目标、关键指标与行动方式,此时产出的就会是中期目标、短期目标。</p>
<p><img src="/img/bVboPYr?w=807&h=623" alt="图片描述" title="图片描述"></p>
<p>透过一连串的自上而下的拆分、定义目标,会让项目的各个里程碑更明确、更细致。另外,跨团队的协作上,也能根据目标凝聚出当前应该要有的目标意识,某种程度上来说,可以避免不同团队在过程中虽然是奔着大目标在执行,但是在中间环节彼此目标不同所导致的资源等待与浪费。</p>
<p><img src="/img/bVboPYx?w=676&h=462" alt="图片描述" title="图片描述"></p>
<p>随着目标的细分、关键指标的不同,粒度愈细的目标对于项目管理者来说愈容易管理,可以想成是在一个大的项目里,有许多不同时间点的小项目。在笔者手里进行的项目中,一般常用来订目标与检验结果的时间,提供给各位读者参考下:</p>
<ul>
<li>每月第一天,制订当月目标,对于疑问制定行动策略,月底检验关键指标</li>
<li>每周第一天,制订当周目标,对于疑问制定行动策略,每周最后一天检验关键指标</li>
<li>每日上午 10 点制订当日目标,对于疑问制定行动策略,晚上 6 点检验关键指标</li>
</ul>
<p>看似有了更多的会议,但这些会议通常会在 20 分钟内结束,且价值极高,因为目标就在眼前:</p>
<ul>
<li>完成的目标明确且可被检验</li>
<li>对于产生出来的问题,所提出的行动策略能大大的提高响应速度</li>
<li>不同团队间的协作聚焦提升</li>
<li>不同的阶段,目标是自上而下被拆分,能在下层的目标 (日目标、半日目标) 上进行检验,进而调整上层目标 (周目标、月目标等),降低因市场变化等因素,导致最后完成的内容与当初的规划的差异过大</li>
</ul>
<h4>2.2 人员的要求</h4>
<p>在 OKR 的理论中,会要求每个人都能找到自己的目标,这时候就会需要有人来检视这个目标是否合理、是否太大或太小、或是是否跨职能/业务上的目标有一致,在一个项目里我们通常会有几类负责人可以对目标进行把控。</p>
<ul>
<li>业务负责人</li>
<li>产品负责人</li>
<li>技术负责人</li>
<li>测试负责人</li>
<li>运维负责人</li>
</ul>
<p>在不同的目标制定上我们会需要这些负责人能够识别出来各成员、不同职能团队的目标是否在同一目标上。透过这个方式能间接的提升</p>
<ol>
<li>各负责人对于项目与业务目标的对齐、节奏,可以安排更精细节点做出适当的决策,像是是否能在某些功能完成时先上线给使用者使用。</li>
<li>要求各负责人对于全局观的把控更为强烈,在项目的过程中,其实也是培养未来的新团队小组长/负责人的一种路径。</li>
<li>原本可能沟通较少、节奏不一致的情况能够降低,风险也能提前被挖掘出来,并在前期就能先进行调研、修正。</li>
</ol>
<h4>2.3 目标检验的方式</h4>
<p>在每个 O (目标) 订出时,也会有与之相对应的 KR (关键指标) 和相对应完成 KR 的 AC (关键动作),在实际的操作中这些 KR 能提供明确的目标定义,协助项目成员、负责人在进行检视时,有明确的验证标准。像是</p>
<ul>
<li>日目标:完成客户管理模块,KR1:客户管理模块能完全跑通,KR2:单测覆盖率 70%</li>
<li>周目标:完成 CRM 系统,KR1:完成各模块整合且无严重系统流程阻塞,KR2:集成测试覆盖率 70%</li>
</ul>
<p>这时我们便可在目标检验时,进行演示、操作,看看相对应的指标是否达成,未达成的部分可以透过订定新的 AC 来加速、加强各相关人的行动方式,也可以转换 AC 为下一个目标的行动点或关键指标。透过这样一连串的目标管理过程,使得项目的目标明确且可控,各个与目标的相关人也能知道当前的目标与进度,能适时的提出改进点或行动点,进而降低项目偏离预期的可能。</p>
<h2>三、后记</h2>
<p>本文虽然只是简单的介绍了下 OKR 与项目管理过程的结合方式,但是其中有许多敏捷方法隐含在其中,像是 Scrum、Kanban、XP 等,也有些传统的项目管理隐含在其中,像是 WBS、里程碑等。不同的项目类型、复杂度,我们可以选择性的加减某些方法、工具的使用方式与节奏。进而找出计划驱动与价值驱动的平衡点,达到保证一有一个「好过程」与「好结果」的双赢目的。</p>
<p>不同的企业、团队可以有更多丰富、深入的玩法。但其最终目的皆是以达成目标、减少资源浪费、可控为出发点。对于目标制定的方式也可以结合「吃掉那只青蛙[5]」的方式来达到更好的效果。</p>
<p>最后附上一些常见的管理方式比较表</p>
<p><img src="/img/bVboPYA?w=2350&h=574" alt="图片描述" title="图片描述"></p>
<hr>
<h6>[1]项目管理 - <a href="https://link.segmentfault.com/?enc=PUGjSaKaFe%2Br%2B8x0rugEXA%3D%3D.Z3jlBJ8GsihbpjBQmGowTuxtnG%2FaZ3b5E%2BvW77%2BAoVbRGyIZ3I4FJCbcX0ArsqzNe2PItJHB62R%2FRqMvIeLFBI3OhwW7PKP07yzft4FPgzU%3D" rel="nofollow">https://wiki.mbalib.com/wiki/项目管理</a>
</h6>
<h6>[2]OKR - <a href="https://link.segmentfault.com/?enc=OFeHV6sai4rt5h%2BjKhIutQ%3D%3D.F54aG5b0kVkqWWDJfSm7jTdY4S%2FKY6aHXdRBmFot%2BovayD1nl8cGKDvcwg21SEfI" rel="nofollow">https://wiki.mbalib.com/zh-cn/OKR</a>
</h6>
<h6>[3]Scrum - <a href="https://link.segmentfault.com/?enc=a5eZhHk1mR0ZMwxFRhiHKw%3D%3D.UoWgTkw2jxEH3e5eIZWl39cfzf8LguoJISd%2FtY8ZXQRWhCn9UcofJIWnpARWaVo2" rel="nofollow">https://wiki.mbalib.com/wiki/Scrum</a>
</h6>
<h6>[4]Kanban - <a href="https://link.segmentfault.com/?enc=azH6ttnil39LYnY5HvpCxA%3D%3D.bNn94nzk0hHQflRfGie4UbnmngXuRsjvxeWFCgv71P9aiohp88QZdY%2Fcw%2BELsfix" rel="nofollow">https://wiki.mbalib.com/wiki/Kanban</a>
</h6>
<h6>[5]吃掉那只青蛙 - <a href="https://link.segmentfault.com/?enc=AL7Tz%2BAxHz4c22g%2FRz3FnQ%3D%3D.e7ygYvlgF5B8x7jST7tcSDcYbSy%2F9Ox4QsJ5oQEKsIvnf1Qd4pG406ZWaH%2B2AyUh" rel="nofollow">https://book.douban.com/subject/3371165/</a>
</h6>
Flink 在有赞实时计算的实践
https://segmentfault.com/a/1190000017938410
2019-01-18T11:05:47+08:00
2019-01-18T11:05:47+08:00
有赞技术
https://segmentfault.com/u/youzantech
16
<h2>一、前言</h2>
<p>这篇主要由五个部分来组成:</p>
<p>首先是有赞的实时平台架构。</p>
<p>其次是在调研阶段我们为什么选择了 Flink。在这个部分,主要是 Flink 与 Spark 的 structured streaming 的一些对比和选择 Flink 的原因。</p>
<p>第三个就是比较重点的内容,Flink 在有赞的实践。这其中包括了我们在使用 Flink 的过程中碰到的一些坑,也有一些具体的经验。</p>
<p>第四部分是将实时计算 SQL 化,界面化的一些实践。</p>
<p>最后的话就是对 Flink 未来的一些展望。这块可以分为两个部分,一部分是我们公司接下来会怎么去更深入的使用 Flink,另一部分就是 Flink 以后可能会有的的一些新的特性。</p>
<hr>
<h2>二、有赞实时平台架构</h2>
<p>有赞的实时平台架构呢有几个主要的组成部分。</p>
<p><img src="/img/bVbnqIB?w=999&h=841" alt="图片描述" title="图片描述"></p>
<p>首先,对于实时数据来说,一个消息中间件肯定是必不可少的。在有赞呢,除了业界常用的 Kafka 以外,还有 NSQ。与 Kafka 有别的是,NSQ 是使用 Go 开发的,所以公司封了一层 Java 的客户端是通过 push 和 ack 的模式去保证消息至少投递一次,所以 Connector 也会有比较大的差距,尤其是实现容错的部分。在实现的过程中呢,参考了 Flink 官方提供的 Rabbit MQ 的连接器,结合 NSQ client 的特性做了一些改造。</p>
<p>接下来就是计算引擎了,最古老的就是 Storm 了,现在依然还有一些任务在 Storm 上面跑,至于新的任务基本已经不会基于它来开发了,因为除了开发成本高以外,语义的支持,SQL 的支持包括状态管理的支持都做得不太好,吞吐量还比较低,将 Storm 的任务迁移到 Flink 上也是我们接下来的任务之一。还有呢就是 Spark Streaming 了,相对来说 Spark 有一个比较好的生态,但是 Spark Streaming 是微批处理的,这给它带来了很多限制,除了延迟高以外还会比较依赖外部存储来保存中间状态。 Flink 在有赞是比较新的引擎,为什么在有了 Spark 和 Storm 的情况下我们还要引入 Flink 呢,下一个部分我会提到。</p>
<p>存储引擎,除了传统的 MySQL 以外,我们还使用 HBase ,ES 和 ZanKV。ZanKV 是我们公司开发的一个兼容 Redis 协议的分布式 KV 数据库,所以姑且就把它当成 Redis 来理解好了。</p>
<p>实时 OLAP 引擎的话基于 Druid,在多维的统计上面有非常好的应用。</p>
<p>最后是我们的实时平台。实时平台提供了集群管理,项目管理,任务管理和报警监控的功能。。</p>
<p>关于实时平台的架构就简单介绍到这里,接下来是 Flink 在有赞的探索阶段。在这个部分,我主要会对比的 Spark Structured Streaming。</p>
<hr>
<h2>三、为什么选择引入 Flink </h2>
<p>至于为什么和 Spark Structured Streaming(SSS) 进行对比呢?因为这是实时SQL化这个大背景下比较有代表性的两个引擎。</p>
<p>首先是性能上,从几个角度来比较一下。首先是延迟,毫无疑问,Flink 作为一个流式引擎是优于 SSS 的微批引擎的。虽然说 Spark 也引入了一个连续的计算引擎,但是不管从语义的保证上,还是从成熟度上,都是不如 Flink 的。据我所知,他们是通过将 rdd 长期分配到一个结点上来实现的。</p>
<p>其次比较直观的指标就是吞吐了,这一点在某些场景下 Flink 略逊于 Spark 。但是当涉及到中间状态比较大的任务呢,Flink 基于 RocksDB 的状态管理就显示出了它的优势。<br> <br>Flink 在中间状态的管理上可以使用纯内存,也可以使用 RocksDB 。至于 RocksDB ,简单点理解的话就是一个带缓存的嵌入式数据库。借助持久化到磁盘的能力,Flink 相比 SSS 来说可以保存的状态量大得多,并且不容易OOM。并且在做 checkpoint 中选用了增量模式,应该是只需要备份与上一次 checkpoint 时不同的 sst 文件。使用过程中,发现 RocksDB 作为状态管理性能也是可以满足我们需求的。</p>
<p>聊完性能,接下来就说一说 SQL 化,这也是现在的一个大方向吧。我在开始尝试 SSS 的时候,尝试了一个 SQL 语句中有多个聚合操作,但是却抛了异常。 后面仔细看了文档,发现确实这在 SSS 中是不支持的。第二个是 distinct 也是不支持的。这两点 Flink 是远优于 SSS 的。所以从实时 SQL 的角度,Flink 又为自己赢得了一票。除此之外,Flink 有更灵活的窗口。还有输出的话,同样参考的是 DataFlow 模型,Flink 实现支持删除并更新的操作,SSS 仅支持更新的操作。(这边 SSS 是基于 Spark 的 2.3版本)</p>
<p>API 的灵活性。在 SSS 中,诚然 table 带来了比较大的方便,但是对于有一些操作依然会想通过 DStream 或者 rdd 的形式来操作,但是 SSS 并没有提供这样的转换,只能编写一些 UDF。但是在 Flink 中,Table 和 DataStream 可以灵活地互相转换,以应对更复杂的场景。</p>
<hr>
<h2>四、Flink在有赞的实践</h2>
<p>在真正开始使用 Flink 之前呢,第一个要考虑的就是部署的问题。因为现有的技术栈,所以选择了部署在 Yarn 上,并且使用的是 Single Job 的模式,虽然会有更多的 ApplicationMaster,但无疑是增加了隔离性的。</p>
<h3>4.1 问题一: FLINK-9567</h3>
<p>在开始部署的时候我遇到了一个比较奇怪的问题。先讲一下背景吧,因为还处于调研阶段,所以使用的是 Yarn 的默认队列,优先级比较低,在资源紧张的时候也容易被抢占。<br>有一个上午,我起了一个任务,申请了5个 Container 来运行 TaskExecutor ,一个比较简单地带状态的流式任务,想多跑一段时间看看稳定不稳定。这个 Flink 任务最后占了100多个 container,还在不停增加,但是只有五个 Container 在工作,其他的 container 都注册了 slot,并且 slot 都处于闲置的状态。以下两张图分别代表正常状态下的任务,和出问题的任务。</p>
<p><img src="/img/bVbnqIH?w=1564&h=444" alt="图片描述" title="图片描述"></p>
<p>出错后</p>
<p><img src="/img/bVbnqIU?w=1564&h=964" alt="图片描述" title="图片描述"></p>
<p>在涉及到这个问题细节之前,我先介绍一下 Flink 是如何和 Yarn 整合到一块的。根据下图,我们从下往上一个一个介绍这些组件是做什么的。</p>
<p><img src="/img/bVbnqJd?w=1844&h=1964" alt="图片描述" title="图片描述"></p>
<p>TaskExecutor 是实际任务的执行者,它可能有多个槽位,每个槽位执行一个具体的子任务。每个 TaskExecutor 会将自己的槽位注册到 SlotManager 上,并汇报自己的状态,是忙碌状态,还是处于一个闲置的状态。</p>
<p>SlotManager 既是 Slot 的管理者,也负责给正在运行的任务提供符合需求的槽位。还记录了当前积压的槽位申请。当槽位不够的时候向Flink的ResourceManager申请容器。</p>
<p>Pending slots 积压的 Slot 申请及计数器</p>
<p>Flink 的 ResourceManager 则负责了与 Yarn 的 ResourceManager 进行交互,进行一系列例如申请容器,启动容器,处理容器的退出等等操作。因为采用的是异步申请的方式,所以还需要记录当前积压的容器申请,防止接收过多容器。</p>
<p>Pending container request 积压容器的计数器</p>
<p>AMRMClient 是异步申请的执行者,CallbackHandler 则在接收到容器和容器退出的时候通知 Flink 的 ResourceManager。</p>
<p>Yarn 的 ResourceManager 则像是一个资源的分发器,负责接收容器请求,并为 Client 准备好容器。</p>
<p>这边一下子引入的概念有点多,下面我用一个简单地例子来描述一下这些组件在运行中起到的角色。</p>
<p>首先,我们的配置是3个 TaskManager,每个 TaskManager 有两个 Slot,也就是总共需要6个槽位。当前已经拥有了4个槽位,任务的调度器向 Slot 申请还需要两个槽位来运行子任务。 </p>
<p><img src="/img/bVbnqJg?w=1316&h=1964" alt="图片描述" title="图片描述"></p>
<p>这时 SlotManager 发现所有的槽位都已经被占用了,所以它将这个 slot 的 request 放入了 pending slots 当中。所以可以看到 pending slots 的那个计数器从刚才的0跳转到了现在的2. 之后 SlotManager 就向 Flink 的 ResourceManager 申请一个新的 TaskExecutor,正好就可以满足这两个槽位的需求。于是 Flink 的 ResourceManager 将 pending container request 加1,并通过 AMRM Client 去向 Yarn 申请资源。</p>
<p><img src="/img/bVbnqJq?w=1316&h=1964" alt="图片描述" title="图片描述"></p>
<p>当 Yarn 将相应的 Container 准备好以后,通过 CallbackHandler 去通知 Flink 的 ResourceManager。Flink 就会根据在每一个收到的 container 中启动一个 TaskExecutor ,并且将 pending container request 减1,当 pending container request 变为0之后,即使收到新的 container 也会马上退回。</p>
<p><img src="/img/bVbnqJs?w=1884&h=1964" alt="图片描述" title="图片描述"></p>
<p>当 TaskExecutor 启动之后,会向 SlotManager 注册自己的两个 Slot 可用,SlotManager 便会将两个积压的 SlotRequest 完成,通知调度器这两个子任务可以到这个新的 TaskExecutor 上执行,并且 pending requests 也被置为0. 到这儿一切都符合预期。</p>
<p><img src="/img/bVbnqJt?w=1884&h=1964" alt="图片描述" title="图片描述"></p>
<p>那这个超发的问题又是如何出现的呢?首先我们看一看这就是刚刚那个正常运行的任务。它占用了6个 Slot。</p>
<p>如果在这个时候,出现了一些原因导致了 TaskExecutor 非正常退出,比如说 Yarn 将资源给抢占了。这时 Yarn 就会通知 Flink 的 ResourceManager 这三个 Container 已经异常退出。所以 Flink 的 ResourceManager 会立即申请三个新的 container。在这儿会讨论的是一个 worst case,因为这个问题其实也不是稳定复现的。</p>
<p>CallbackHandler 两次接收到回调发现 Container 是异常退出,所以立即申请新的 Container,pending container requests 也被置为了3.</p>
<p><img src="/img/bVbnqJx?w=1912&h=1976" alt="图片描述" title="图片描述"></p>
<p>如果在这时,任务重启,调度器会向 SlotManager 申请6个 Slot,SlotManager 中也没有可用 Slot,就会向 Flink 的 ResourceManager 申请3个 Container,这时 pending container requests 变为了6.</p>
<p><img src="/img/bVbnqJD?w=1896&h=1976" alt="图片描述" title="图片描述"></p>
<p>最后呢结果就如图所示,起了6个 TaskExecutor,总共12个 Slot,但是只有6个是被正常使用的,还有6个一直处于闲置的状态。</p>
<p><img src="/img/bVbnqJG?w=1884&h=2244" alt="图片描述" title="图片描述"></p>
<p>在修复这个问题的过程中,我有两次尝试。第一次尝试,在 Container 异常退出以后,我不去立即申请新的 container。但是问题在于,如果 Container 在启动 TaskExecutor 的过程中出错,那么失去了这种补偿的机制,有些 Slot Request 会被一直积压,因为 SlotManager 已经为它们申请了 Container。<br> <br>第二次尝试是在 Flink 的 ResourceManager 申请新的 container 之前先去检查 pending slots,如果当前的积压 slots 已经可以被积压的 container 给满足,那就没有必要申请新的 container 了。</p>
<h3>4.2 问题二: 监控</h3>
<p>我们使用过程中踩到的第二个坑,其实是跟延迟监控相关的。例子是一个很简单的任务,两个 source,两个除了 source 之外的 operator,并行度都是2. 每个 source 和 operator 它都有两个子任务。</p>
<p><img src="/img/bVbnqJH?w=1484&h=1444" alt="图片描述" title="图片描述"></p>
<p>任务的逻辑是很简单,但是呢当我们打开延时监控。即使是这么简单的一个任务,它会记录每一个 source 的子任务到每一个算子的子任务的延迟数据。这个延迟数据里还包含了平均延迟,最大延迟,百分之99的延迟等等等等。那我们可以得出一个公式,延迟数据的数量是 source 的子任务数量乘以的 source 的数量乘以算子的并行度乘以算子的数量。N = n(subtasks per source) <em> n(sources) </em> n(subtasks per operator) * n(operator)</p>
<p>这边我做一个比较简单地假设,那就是 source 的子任务数量和算则的子任务数量都是 p - 并行度。从下面这个公式我们可以看出,监控的数量随着并行度的上升呈平方增长。N = p^2 <em> n(sources) </em> n(operator)</p>
<p><img src="/img/bVbnqJ5?w=1484&h=1444" alt="图片描述" title="图片描述"></p>
<p>如果我们把上个任务提升到10个并行度,那么就会收到400份的延迟数据。这可能看起来还没有太大的问题,这貌似并不影响组件的正常运行。</p>
<p>但是,在 Flink 的 dev mailing list 当中,有一个用户反馈在开启了延迟监控之后,JobMaster 很快就会挂掉。他收到了24000+的监控数据,并且包含这些数据的 ConcurrentHashMap 在内存中占用了1.6 G 的内存。常规情况 Flink 的 JobMaster 时会给到多少内存,我一般会配1-2 g,最后会导致长期 FullGC 和 OOM 的情况。</p>
<p>那怎么去解决这个问题呢?当延迟监控已经开始影响到系统的正常工作的时候,最简单的办法就是把它给关掉。可是把延时监控关掉,一方面我们无法得知当前任务的延时,另一方面,又没有办法去针对延时做一些报警的功能。<br> <br>所以另一个解决方案就如下。首先是 Flink-10243,它提供了更多的延迟监控粒度的选项,从源头上减少数量。比如说我们使用了 Single 模式去采集这些数据,那它只会记录每个 operator 的子任务的延迟,忽略是从哪个 source 或是 source 的子任务中来。这样就可以得出这样一个公式,也能将之前我们提到的十个并行度的任务产生的400个延时监控降低到了40个。这个功能发布在了1.7.0中,并且 backport 回了1.5.5和1.6.2.<br> <br>此外,Flink-10246 提出了改进 MetricQueryService。它包含了几个子任务,前三个子任务为监控服务建立了一个专有的低优先级的 ActorSystem,在这里可以简单的理解为一个独立的线程池提供低优先级的线程去处理相关任务。它的目的也是为了防止监控任务影响到主要的组件。这个功能发布在了1.7.0中。<br> <br>还有一个就是 Flink-10252,它还依旧处于 review 和改进当中,目的是为了控制监控消息的大小。</p>
<h3> </h3>
<h3>4.3 具体实践一</h3>
<p>接下来会谈一下 Flink 在有赞的一些具体应用。<br> <br>首先是 Flink 结合 Spring。为什么要将这两者做结合呢,首先在有赞有很多服务都只暴露了 Dubbo 的接口,而用户往往都是通过 Spring 去获取这个服务的 client,在实时计算的一些应用中也是如此。<br> <br>另外,有不少数据应用的开发也是 Java 工程师,他们希望能在 Flink 中使用 Spring 以及生态中的一些组件去简化他们的开发。用户的需求肯定得得到满足。接下来我会讲一些错误的典型,以及最后是怎么去使用的。</p>
<p>第一个错误的典型就是在 Flink 的用户代码中启动一个 Spring 环境,然后在算子中取调用相关的 bean。但是事实上,最后这个 Spring Context 是启动在 client 端的,也就是提交任务的这一端,在图中有一个红色的方框中间写着 Spring Context 表示了它启动的位置。可是用户在实际调用时确实在 TaskManager 的 TaskSlot 中,它们都处在不同的 jvm,这明显是不合理的。所以呢我们又遇到了第二个错误。</p>
<p><img src="/img/bVbnqKr?w=3344&h=2412" alt="图片描述" title="图片描述"></p>
<p>第二个错误比第一个错误看起来要好多了,我们在算子中使用了 RichFunction,并且在 open 方法中通过配置文件获取了一个 Spring Context。但是先不说一个 TaskManager 中启动几个 Spring Context 是不是浪费,一个 Jvm 中启动两个 Spring Context 就会出问题。可能有用户就觉得,那还不简单,把 TaskSlot 设为1不就行了。可是还有 OperatorChain 这个机制将几个窄依赖的算子绑定到一块运行在一个 TaskSlot 中。那我们关闭 OperatorChain 不就行了?还是不行,Flink可能会做基于 CoLocationGroup 的优化,将多个 subtask 放到一个 TaskSlot 中轮番执行。</p>
<p><img src="/img/bVbnqKw?w=3344&h=2412" alt="图片描述" title="图片描述"></p>
<p>但其实最后的解决方案还是比较容易的,无非是使用单例模式来封装 SpringContext,确保每个jvm中只有一个,在算子函数的 open 方法中通过这个单例来获取相应的 Bean。</p>
<p><img src="/img/bVbnqKD?w=3344&h=2412" alt="图片描述" title="图片描述"></p>
<p>可是在调用 Dubbo 服务的时候,一次响应往往最少也要在10 ms 以上。一个 TaskSlot 最大的吞吐也就在一千,可以说对性能是大大的浪费。那么解决这个问题的话可以通过异步和缓存,对于多次返回同一个值的调用可以使用缓存,提升吞吐我们可以使用异步。</p>
<h3>4.4 具体实践二</h3>
<p>可是如果想同时使用异步和缓存呢?刚开始我觉得这是一个挺容易实现的功能,但在实际写 RichAsyncFunction 的时候我发现并没有办法使用 Flink 托管的 KeyedState。所以最初想到的方法就是做一个类似 LRU 的 Cache 去缓存数据。但是这完全不能借助到 Flink 的状态管理的优势。所以我研究了一下实现。</p>
<p>为什么不支持呢?</p>
<p>当一条记录进入算子的时候,Flink 会先将 key 提取出来并将 KeyedState 指向与这个 key 关联的存储空间,图上就指向了 key4 相关的存储空间。但是如果此时 key1 关联的异步操作完成了,希望把内容缓存起来,会将内容写入到 key4 绑定的存储空间。当下一次 key1 相关的记录进入算子时,回去 key1 关联的存储空间查找,可是根本找不到数据,只好再次请求。</p>
<p><img src="/img/bVbnqKM?w=1488&h=1488" alt="图片描述" title="图片描述"></p>
<p>所以解决的方法是定制一个算子,每条记录进入系统,都让它指向同一个公用 key 的存储空间。在这个空间使用 MapState 来做缓存。最后算子运行的 function 继承 AbstractRichFunction 在 open 方法中来获取 KeyedState,实现 AsyncFunction 接口来做异步操作。</p>
<p><img src="/img/bVbnqKQ?w=1484&h=1484" alt="图片描述" title="图片描述"></p>
<hr>
<h2>五、实时计算 SQL 化与界面化</h2>
<p>最早我们使用 SDK 的方式来简化 SQL 实时任务的开发,但是这对用户来说也不算非常友好,所以现在讲 SQL 实时任务界面化,用 Flink 作为底层引擎去执行这些任务。</p>
<p>在做 SQL 实时任务时,首先是外部系统的抽象,将数据源和数据池抽象为流资源,用户将它们数据的 Schema 信息和元信息注册到平台中,平台根据用户所在的项目组管理读写的权限。在这里消息源的格式如果能做到统一能降低很多复杂度。比如在有赞,想要接入的用户必须保证是 Json 格式的消息,通过一条样例消息可以直接生成 Schema 信息。</p>
<p>接下来是根据用户选择的数据源和数据池,获取相应的 Schema 信息和元信息,在 Flink 任务中注册相应的外部系统 Table 连接器,再执行相应的 SQL 语句。</p>
<p>在 SQL 语义不支持的功能上尽量使用 UDF 的方式来拓展。</p>
<p>有数据源和数据池之间的元信息,还可以获取实时任务之间可能存在的依赖关系,并且能做到整个链路的监控</p>
<hr>
<h2>六、未来与展望</h2>
<p>Flink 的批处理和 ML 模块的尝试,会跟 Spark 进行对比,分析优劣势。目前还处于调研阶段,目前比较关注的是 Flink 和 Hive的结合,对应 FLINK-10566 这个 issue。</p>
<p>从 Flink 的发展来讲呢,我比较关注并参与接下来对于调度和资源管理的优化。现在 Flink 的调度和任务执行图是耦合在一块的,使用比较简单地调度机制。通过将调度器隔离出来,做成可插拔式的,可以应用更多的调度机制。此外,基于新的调度器,还可以去做更灵活的资源补充和减少机制,实现 Auto Scaling。这可能在接下来的版本中会是一个重要的特性。对应 FLINK-10404 和 FLINK-10429 这两个 issue。</p>
<hr>
<p>最后打个小广告,有赞大数据团队基础设施团队,主要负责有赞的数据平台(DP), 实时计算(Storm, Spark Streaming, Flink),离线计算(HDFS,YARN,HIVE, SPARK SQL),在线存储(HBase),实时 OLAP(Druid) 等数个技术产品,欢迎感兴趣的小伙伴联系 yangshimin@youzan.com</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
有赞业务对账平台的探索与实践
https://segmentfault.com/a/1190000017918729
2019-01-16T21:15:32+08:00
2019-01-16T21:15:32+08:00
有赞技术
https://segmentfault.com/u/youzantech
17
<h2>一、引子</h2>
<p>根据CAP原理,分布式系统无法在保证了可用性(Availability)和分区容忍性(Partition)之后,继续保证一致性(Consistency)。我们认为,只要存在网络调用,就会存在调用失败的可能,系统之间必然存在着长或短的不一致状态。在服务化流行的今天,怎样及时发现系统服务间的不一致状态,以及怎样去量化衡量一个系统的数据一致性,成为每个分布式环境下的开发者需要考虑并解决的问题。</p>
<h2>二、背景</h2>
<p>以交易链路为例,存在着如下一些潜在的不一致场景:</p>
<ul>
<li>订单支付成功了,但是订单状态却还是“待付款”</li>
<li>物流已经发货了,但是订单上面还是“待发货”</li>
<li>银行退款已经到账了,但是订单上面还是“退款中”</li>
<li>订单发货已经超过7天了,但是却没有自动完成</li>
<li>…</li>
</ul>
<p>上述每个业务场景,都可能产生用户反馈,给用户带来困扰。业务对账平台的核心目的,就是及时发现类似问题,并及时修复。使问题在反馈前即被提前处理。</p>
<h2>三、挑战</h2>
<p>那么一个业务对账平台,会面临着哪些挑战?</p>
<p><img src="/img/bVbnlC0?w=1450&h=860" alt="图片描述" title="图片描述"></p>
<p>我们对于一个业务对账平台的核心诉求,主要包括要方便业务系统快速接入,要能处理业务方海量的数据,并保证一定的实时性。这会深刻影响业务对账平台的系统设计。</p>
<h2>四、架构</h2>
<p>从局部到整体,本文先从解决上面三个问题的角度,来看有赞业务对账平台的局部设计,再来看整体系统结构。</p>
<h3>4.1 易于接入</h3>
<p>我们认为所有的对账流程,都可以分解为“数据加载”、“转换解析”、“对比”、“结果处理”这 4 步。为了适应多样化的业务场景,其中的每一步都需要做到可编排,放置各种差异化的执行组件。在每一个流程节点,需要通过规则可以自由选择嵌入哪个组件。其次,需要把数据从原始格式,转换到对账的标准格式(基于标准格式,就能做标准的通用对比器)。总结起来,我们认为对账引擎需要具备以下的能力:</p>
<ul>
<li>流程编排能力</li>
<li>规则能力</li>
<li>插件化接入能力</li>
</ul>
<p>目前业务对账平台的对账引擎结构如下:</p>
<p><img src="/img/bVbnlC1?w=1260&h=938" alt="图片描述" title="图片描述"></p>
<p>其中的 ResourceLoader 、 Parser 、 Checker 、 ResultHandler 均为标准接口,所有实现了对应接口的 spring bean ,都能被编排到对账流程之中,包括业务方自己实现的 plugin。这样就实现了插件化和可编排。每个流程节点的功能如下:</p>
<ul>
<li>
<strong>ResourceLoader</strong> :基于各种数据源(DB、FILE、RPC、REST等)提供加载器工厂,加载各个数据源的原始数据。加载的方式支持驱动加载、并行加载、多方加载等方式。业务方也可以自己实现加载器,利用流程编排能力嵌入到对账流程中。</li>
<li>
<strong>Parser</strong> :对已加载的原始数据进行建模,转换为对账标准模型。利用规则引擎,提供脚本化(Groovy)的转换方式。</li>
<li>
<strong>Checker</strong> :按照配置对指定字段、按指定规则进行比较,并产生对账结果。支持 findFirst(找出第一个不一致)、full(找出所有不一致)等对比策略。</li>
<li>
<strong>ResultHandler</strong> :使用指定的handler对结果进行处理,常见的处理器包括持久化、发送报警邮件、甚至直接修复数据等等。</li>
</ul>
<p>通过统一的 facade,将整个对账流程进行串联。在执行不同节点时,根据配置选择不同的默认组件或者插件来执行。可以在管理后台,对每个流程节点进行编排:</p>
<p><img src="/img/bVbnlC9?w=2200&h=1102" alt="图片描述" title="图片描述"></p>
<h3>4.2 高吞吐量</h3>
<p>一些离线定时对账场景,单次对账的数据量可能达到百万级,甚至千万级。这对对账平台的吞吐量造成了挑战。我们面对海量数据问题的通常解决思路,就是“拆”。分布式任务拆分+单机内任务拆分,将数据块变小。同时,也可以利用一些大数据的工具来帮我们减轻负担。</p>
<p><img src="/img/bVbnlDa?w=1258&h=504" alt="图片描述" title="图片描述"></p>
<p>目前的对账有 2 种模式:一种常规模式,是通过数据平台(包含了所有要进行对账的原始主键数据,如订单号)将数据 push 到对账中心的 DB ,然后订单中心集群通过分片策略,并按分页分批加载,加载数据进行对比。另一种,则是当数据量超过千万时,利用数据平台的 spark 引擎从 hive 表中获取数据,然后投递到 nsq(自研消息队列)。nsq 会选择其中一个 consumer 进行投递(不会投递到多个consumer)。这样千万级的数据会变成消息被分散的对账服务器执行。</p>
<p>对账任务一般会选择在业务量较小的凌晨进行,是因为在对账过程中会需要通过反查业务接口,来获取实时数据。而更好的情况是,对账时能去除对业务接口的反查。因此,会需要对业务数据进行准实时同步,提前进入对账中心的 DB 集群。</p>
<p><img src="/img/bVbnlz0?w=1084&h=466" alt="图片描述" title="图片描述"></p>
<p>主要思路是基于业务 DB 的 binlog 日志或者业务系统自身的消息,进行数据同步。后续流程则类似。</p>
<h3>4.3 高实时性</h3>
<p>一些特定的业务场景,比如买家已经付款成功了,但是由于银行第三方的支付状态回调延迟,导致订单状态还是待付款。这种情况,买家会比较焦急,可能产生投诉。面对这样一些场景,就需要进行实时对账,也可以叫做秒级对账。</p>
<p>秒级对账往往基于业务消息进行触发,需要在事件触发后的短时间内执行完对账任务。且事件消息的触发,往往具有高并发的特点,因此需要相应的架构来进行支持。</p>
<p><img src="/img/bVbnlDi?w=1268&h=1076" alt="图片描述" title="图片描述"></p>
<p>设计中主要加入了 EventPool 来缓冲处理高并发的事件消息,并加入限流、取样、路由、处理的 pipeline。同时在进入事件处理线程池之前,需要进入阻塞队列,避免大量的请求直接耗尽线程资源,同时实现事件处理的异步化。处理线程批量定时从阻塞队列获取任务来执行。同时,利用延迟阻塞队列,还可以实现延迟对账的特性。(我们认为出现不一致的情况时,如果立即去进行对比,往往还是不一致的。所以就需要根据情况,在事件发生后的一段时间内,再触发对比)</p>
<h3>4.4 整体设计</h3>
<p>上面介绍了业务对账平台的各个局部设计,下面来看下整体结构。</p>
<p><img src="/img/bVbnlDw?w=1088&h=848" alt="图片描述" title="图片描述"></p>
<p>整体上主要采用调度层+对账引擎(core+plugin)+基础设施的分层架构。调度层主要负责任务触发、任务拆分、调度;对账引擎则负责执行可编排的对账流程;基础设置层则提供规则引擎、流程引擎、泛化调用、监控等基础能力。</p>
<h2>五、健康度</h2>
<p>对账中心可以拿到业务系统及其所在整个链路的数据一致性信息。基于此,对账平台具备了给予业务系统和链路健康度反馈的能力。</p>
<p><img src="/img/bVbnlDF?w=962&h=730" alt="图片描述" title="图片描述"></p>
<h2>六、共建</h2>
<p>前面有提到,对账的流程被拆分为四个固定的流程节点,且有四个对应的标准接口。通过流程引擎和规则引擎的能力,可以根据 spring bean name,来将系统默认组件或者插件编排到对账流程之中。基于这种开放性的设计,业务对账平台支持与业务团队进行共建。</p>
<p>首先,对账平台提供标准接口的 API jar 包,业务方通过引入 jar,实现相关接口,并将 impl 打包。这样,对账平台通过 spi 的方式,可以引入业务方的插件包,并加载到对账中心的 JVM 中执行。</p>
<h2>七、未来</h2>
<p>业务对账平台,是面向业务场景建立,但同时属于数据密集型应用。平台上线以来,已经接入公司各团队数十个对账任务,每天处理千万级的数据。展望未来,业务对账平台的使命会从主要进行离线数据分析处理,演进到利用应用系统的健康度数据帮助系统进行实时调整的方向。在分布式环境下,没有人能回避数据一致性问题,我们对此充满着敬畏。欢迎联系 zhangchaoyue@youzan.com,交流心得。</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
SparkSQL 在有赞的实践
https://segmentfault.com/a/1190000017918500
2019-01-16T20:43:40+08:00
2019-01-16T20:43:40+08:00
有赞技术
https://segmentfault.com/u/youzantech
5
<h2>前言</h2>
<p>有赞数据平台从2017年上半年开始,逐步使用 SparkSQL 替代 Hive 执行离线任务,目前 SparkSQL 每天的运行作业数量5000个,占离线作业数目的55%,消耗的 cpu 资源占集群总资源的50%左右。本文介绍由 SparkSQL 替换 Hive 过程中碰到的问题以及处理经验和优化建议,包括以下方面的内容:</p>
<ul>
<li>有赞数据平台的整体架构。</li>
<li>SparkSQL 在有赞的技术演进。</li>
<li>从 Hive 到 SparkSQL 的迁移之路。</li>
</ul>
<h2>一. 有赞数据平台介绍</h2>
<p>首先介绍一下有赞大数据平台总体架构:</p>
<p>如下图所示,底层是数据导入部分,其中 DataY 区别于开源届的全量导入导出工具 alibaba/DataX,是有赞内部研发的离线 Mysql 增量导入 Hive 的工具,把 Hive 中历史数据和当天增量部分做合并。DataX / DataY 负责将 Mysql 中的数据同步到数仓当中,Flume 作为日志数据的主要通道,同时也是 Mysql binlog 同步到 HDFS 的管道,供 DataY 做增量合并使用。</p>
<p>第二层是大数据的计算框架,主要分成两部分:分布式存储计算和实时计算,实时框架目前主要支持 JStorm,Spark Streaming 和 Flink,其中 Flink 是今年开始支持的;而分布式存储和计算框架这边,底层是 Hadoop 和 Hbase,ETL主要使用 Hive 和 Spark,交互查询则会使用 Spark,Presto,实时 OLAP 系统今年引入了 Druid,提供日志的聚合查询能力。</p>
<p>第三层是数据平台部分,数据平台是直接面对数据开发者的,包括几部分的功能,数据开发平台,包括日常使用的调度,数据传输,数据质量系统;数据查询平台,包括ad-hoc查询以及元数据查询。有关有赞数据平台的详细介绍可以参考往期有赞数据平台的<a href="https://link.segmentfault.com/?enc=OV1Usaqqym9Rc0dBCf3GVQ%3D%3D.VUzydvSbm2GFkH7hnxgih56LKzZeENpZOGqv%2FBUDgw2Yv8RPiunh1zPPhNcsudd0" rel="nofollow">博客内容</a>。<br> <br><img src="/img/bVbnly7?w=1405&h=941" alt="图片描述" title="图片描述"></p>
<h2>二. SparkSQL技术演进</h2>
<p><br><br>从2017年二季度,有赞数据组的同学们开始了 SparkSQL 方面的尝试,主要的出发点是当时集群资源是瓶颈,Hive 跑任务已经逐渐开始乏力,有些复杂的 SQL,通过 SQL 的逻辑优化达到极限,仍然需要几个小时的时间。业务数据量正在不断增大,这些任务会影响业务对外服务的承诺。同时,随着 Spark 以及其社区的不断发展,Spark 及 Spark SQL 本身技术的不断成熟,Spark 在技术架构和性能上都展示出 Hive 无法比拟的优势。</p>
<p>从开始上线提供离线任务服务,再到 Hive 任务逐渐往 SparkSQL 迁移,踩过不少坑,也填了不少坑,这里主要分两个方面介绍,一方面是我们对 SparkSQL 可用性方面的改造以及优化,另一方面是 Hive 迁移时遇到的种种问题以及对策。</p>
<h3>2.1 可用性改造 </h3>
<p>可用性问题包括两方面,一个是系统的稳定性,监控/审计/权限等,另一个是用户使用的体验,用户以前习惯用 Hive,如果 SparkSQL 的日志或者 Spark thrift server 的 UI 不能够帮助用户定位问题,解决问题,那也会影响用户的使用或者迁移意愿。所以我首先谈一下用户交互的问题。</p>
<h4>用户体验</h4>
<p>我们碰到的第一个问题是用户向我们抱怨通过 JDBC 的方式和 Spark thrift server(STS) 交互,执行一个 SQL 时,没有执行的进度信息,需要一直等待执行成功,或者任务出错时接收任务报错邮件得知执行完。于是执行进度让用户可感知是一个必要的功能。我们做了 Spark 的改造,增加运行时的 operation 日志,并且向社区提交了 patch(spark-22496), 而在我们内部,更增加了执行进度日志,每隔2秒打印出当前执行的 job/stage 的进度,如下图所示。</p>
<p><img src="/img/bVbnly8?w=1870&h=754" alt="图片描述" title="图片描述"></p>
<h4>监控</h4>
<p>SparkSQL 需要收集 STS 上执行的 SQL 的审计信息,包括提交者执行的具体 SQL,开始结束时间,执行完成状态。原生 STS 会把这些信息通过事件的方式 post 到事件总线,监听者角色 (HiveThriftServer2Listener) 在事件总线上注册,订阅消费事件,但是这个监听者只负责 Spark UI 的 JDBC Tab 上的展示,我们改造了 SparkListener 类,将 session 以及执行的 sql statement 级别的消息也放到了总线上,监听者可以在总线上注册,以便消费这些审计信息,并且增加了一些我们感兴趣的维度,如使用的 cpu 资源,归属的工作流(airflowId)。同时,我们增加了一种新的完成状态 cancelled,以方便区分是用户主动取消的任务。</p>
<p><img src="/img/bVbnly9?w=2496&h=788" alt="图片描述" title="图片描述"></p>
<h4>Thrift Server HA</h4>
<p>相比于 HiveServer,STS 是比较脆弱的,一是由于 Spark 的 driver 是比较重的,所有的作业都会通过 driver 编译 sql,调度 job/task 执行,分发 broadcast 变量,二是对于每个 SQL,相比于 HiveServer 会新起一个进程去处理这个 SQL 的执行,STS 只有一个进程去处理,如果某个 SQL 有异常,查询了过多的数据量, STS 有 OOM 退出的风险,那么生产环境维持 STS 的稳定性就显得无比重要。</p>
<p>除了必要的存活报警,首先我们区分了 ad-hoc 查询和离线调度的 STS 服务,因为离线调度的任务往往计算结束时是把结果写入 table 的,而 ad-hoc 大部分是直接把结果汇总在 driver,对 driver 的压力比较大;此外,我们增加了基于 ZK 的高可用。对于一种类型的 STS(事实上,有赞的 STS 分为多组,如 ad-hoc,大内存配置组)在 ZK 上注册一个节点,JDBC 的连接直接访问 ZK 获取随机可用的 STS 地址。这样,偶然的 OOM ,或者 bug 被触发导致 STS 不可用,也不会严重到影响调度任务完全不可用,给开发运维人员比较充足的时间定位问题。</p>
<h4>权限控制</h4>
<p>之后有另一个文章详细介绍我们对于安全和权限的建设之路,这里简单介绍一下,Hive的权限控制主要包括以下几种:</p>
<blockquote><ul>
<li>SQL Standards Based Hive Authorization</li>
<li>Storage Based Authorization in the Metastore</li>
<li>ServerAuthorization using Apache Ranger & Sentry</li>
</ul></blockquote>
<p>调研对比各种实现方案之后,由于我们是从无到有的增加了权限控制,没有历史负担。我们直接选择了ranger + 组件 plugin 的权限管理方案。</p>
<p>除了以上提到的几个点,我们还从社区 backport 了数十个 patch 以解决影响可用性的问题,如不识别 hiveconf/hivevar (SPARK-13983),最后一行被截断(HIVE-10541) 等等。</p>
<h3>2.2 性能优化</h3>
<p>之前谈到,STS 只有一个进程去处理所有提交 SQL 的编译,所有的 SQL Job 共享一个 Hive 实例,更糟糕的是这个 Hive 实例还有处理 loadTable/loadPartition 这样的 IO 操作,会阻塞其他任务的编译,存在单点问题。我们之前测试一个上万 partition 的 Hive 表在执行 loadTable 操作时,会阻塞其他任务提交,时间长达小时级别。对于 loadTable 这样的IO操作,要么不加锁,要么减少加锁的时间。我们选择的是后者,首先采用的是社区 SPARK-20187 的做法,将 loadTable 实现由 copyFile 的方式改为 moveFile,见下图:</p>
<p><img src="/img/bVbnly9?w=2496&h=788" alt="图片描述" title="图片描述"></p>
<p>之后变更了配置spark.sql.hive.metastore.jars=maven,运行时通过 Maven 的方式加载 jar 包,解决包依赖关系,使得加载的 Hive 类是2.1.1的版本,和我们 Hive 版本一致,这样得好处是很多行为都会和 Hive 的相一致,方便排查问题;比如删除文件到 Trash,之前 SparkSQL 删除表或者分区后是不会落到 Trash 的。</p>
<h3>2.3 小文件问题</h3>
<p>我们在使用 SparkSQL 过程中,发现小文件的问题比较严重,SparkSQL 在写数据时会产生很多小文件,会对 namenode 产生很大的压力,进而带来整个系统稳定性的隐患,最近三个月文件个数几乎翻了个倍。对于小文件问题,我们采用了社区 SPARK-24940 的方式处理,借助 SQL hint 的方式合并小文件。同时,我们有一个专门做 merge 的任务,定时异步的对天级别的分区扫描并做小文件合并。</p>
<p>还有一点是spark.hadoop.mapreduce.fileoutputcommitter.algorithm.version=2, MapReduce-4815 详细介绍了 fileoutputcommitter 的原理,实践中设置了 version=2 的比默认 version=1 的减少了70%以上的 commit 时间。</p>
<h2>三. SparkSQL 迁移之路</h2>
<p><br><br>解决了大部分的可用性问题以后,我们逐步开始了 SparkSQL 的推广,引导用户选择 SparkSQL 引擎,绝大部分的任务的性能能得到较大的提升。于是我们进一步开始将原来 Hive 执行的任务向 SparkSQL 转移。</p>
<p>在 SparkSQL 迁移之初,我们选择的路线是遵循二八法则,从优化耗费资源最多的头部任务开始,把Top100的任务从 Hive 往 SparkSQL 迁移,逐步积累典型错误,包括 SparkSQL 和Hive的不一致行为,比较典型的问题由ORC格式文件为空,Spark会抛空指针异常而失败,ORC 格式和 metastore 类型不一致,SparkSQL 也会报错失败。经过一波人工推广之后,头部任务节省的资源相当客观,在2017年底,切换到 SparkSQL 的任务数占比5%,占的资源20%,资源使用仅占 Hive 运行的10%-30%。</p>
<p>在 case by case 处理了一段时间以后,我们发现这种方式不太能够扩展了。首先和作业的 owner 协商修改需要沟通成本,而且小作业的改动收益不是那么大,作业的 owner 做这样的改动对他来说收益比较小,反而有一定概率的风险。所以到这个阶段 SparkSQL 的迁移之路进展比较缓慢。</p>
<p>于是我们开始构思自动化迁移方式,构思了一种执行引擎之上的智能执行引擎选择服务 SQL Engine Proposer(proposer),可以根据查询的特征以及当前集群中的队列状态为 SQL 查询选择合适的执行引擎。数据平台向某个执行引擎提交查询之前,会先访问智能执行引擎选择服务。在选定合适的执行引擎之后,数据平台将任务提交到对应的引擎,包括 Hive,SparkSQL,以及较大内存配置的 SparkSQL。</p>
<p><img src="/img/bVbnlz0?w=1084&h=466" alt="图片描述" title="图片描述"></p>
<p>并且在 SQL Engine Proposer,我们添加了一系列策略:</p>
<ul>
<li>规则策略,这些规则可以是某一种 SQL pattern,proposer 使用 Antlr4 来处理执行引擎的语法,对于某些迁移有问题的问题,将这种 pattern 识别出来,添加到规则集合中,典型的规则有没有发生 shuffle 的任务,或者只发生 broadcast join 的任务,这些任务有可能会产生很多小文件,并且逻辑一般比较简单,使用Hive运行资源消耗不会太多。</li>
<li>白名单策略,有些任务希望就是用Hive执行,就通过白名单过滤。当 Hive 和 SparkSQL 行为不一致的时候,也可以先加入这个集合中,保持执行和问题定位能够同时进行。</li>
<li>优先级策略,在灰度迁移的时候,是从低优先级任务开始的,在 proposer 中我们配置了灰度的策略,从低优先级任务切一定的流量开始迁移,逐步放开,在优先级内达到全量,目前放开了除 P1P2 以外的3级任务。</li>
<li>过往执行记录,proposer 选择时会根据历史执行成功情况以及执行时间,如果 SparkSQL 效率比 Hive 有显著提升,并且在过去一直执行成功,那么 proposer 会更倾向于选择 SparkSQL。</li>
</ul>
<p>截止目前,执行引擎选择的作业数中 SparkSQL 占比达到了73%,使用资源仅占32%,迁移到 SparkSQL 运行的作业带来了67%资源的节省。</p>
<p><img src="/img/bVbnlz4?w=1136&h=932" alt="图片描述" title="图片描述"><br><img src="/img/bVbnlz8?w=1046&h=442" alt="图片描述" title="图片描述"></p>
<h2>未来展望</h2>
<p>我们计划 Hadoop 集群资源进一步向 SparkSQL 方向转移,达到80%,作业数达70%,把最高优先级也开放到选择引擎,引入 Intel 开源的<a href="https://link.segmentfault.com/?enc=Tgwq80s5XJgECXAd3kcv7A%3D%3D.8fS%2BTqZIE9b43ZYeJBCMfX%2FATdpIfLihTG20IIHYiBkyCKeJGW5Iq1gZFqe0ISNc" rel="nofollow"> Adaptive Execution </a>功能,优化执行过程中的 shuffle 数目,执行过程中基于代价的 broadcast<br> join 优化,替换 sort merge join,同时更彻底解决小文件问题。</p>
<p>最后打个小广告,有赞大数据团队基础设施团队,主要负责有赞的数据平台(DP), 实时计算(Storm, Spark Streaming, Flink),离线计算(HDFS, YARN, HIVE, SPARK SQL),在线存储(HBase),实时 OLAP(Druid) 等数个技术产品,欢迎感兴趣的小伙伴联系 zouchenjun@youzan.com</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
HBase写吞吐场景资源消耗量化分析及优化
https://segmentfault.com/a/1190000017915755
2019-01-16T17:18:14+08:00
2019-01-16T17:18:14+08:00
有赞技术
https://segmentfault.com/u/youzantech
4
<h2>一. 概述</h2>
<p>HBase 是一个基于 Google BigTable 论文设计的高可靠性、高性能、可伸缩的分布式存储系统。 网上关于 HBase 的文章很多,官方文档介绍的也比较详细,本篇文章不介绍HBase基本的细节。</p>
<p>本文从 HBase 写链路开始分析,然后针对少量随机读和海量随机写入场景入手,全方面量化分析各种资源的开销, 从而做到以下两点:</p>
<ol>
<li>在给定业务量级的情况下,预先评估好集群的合理规模</li>
<li>在 HBase 的众多参数中,选择合理的配置组合</li>
</ol>
<h2>二. HBase 写链路简要分析</h2>
<p>HBase 的写入链路基于 LSM(Log-Structured Merge-Tree), 基本思想是把用户的随机写入转化为两部分写入:</p>
<p>Memstore 内存中的 Map, 保存随机的随机写入,待 memstore 达到一定量的时候会异步执行 flush 操作,在 HDFS 中生成 HFile 中。 同时会按照写入顺序,把数据写入一份到 HDFS 的 WAL(Write Ahead Log)中,用来保证数据的可靠性,即在异常(宕机,进程异常退出)的场景下,能够恢复 Memstore 中还没来得及持久化成 HFile 的数据.</p>
<p><img src="/img/bVbnkMI?w=1020&h=734" alt="图片描述" title="图片描述"></p>
<h2>三. Flush & Compaction</h2>
<p>上一节中,介绍了 HBase 的写路径,其中 HFile 是 HBase 数据持久化的最终形态, 本节将介绍 HBase 如何生成 HFile 和管理 HFile。关于 HFile, 主要涉及到两个核心操作:</p>
<ol>
<li>Flushing</li>
<li>Compaction</li>
</ol>
<p>上一节中提到,HBase 的写入最先会放入内存中,提供实时的查询,当 Memstore 中数据达到一定量的阈值(128MB),会通过 Flush 操作生成 HFile 持久化到 HDFS 中,随着用户的写入,生成的 HFile 数目会逐步增多,这会影响用户的读操作,同时也会系统占用(HDFS 层 block 的数目, regionserver 服务器的文件描述符占用), region split 操作,region reopen 操作也会受到不同程度影响。 <br>HBase 通过 Compaction 机制将多个 HFile 合并成一个 HFile 以控制每个 Region 内的 HFile 的数目在一定范围内, 当然 Compaction 还有其他的作用,比如数据本地化率,多版本数据的合并,数据删除标记的清理等等,本文不做展开。</p>
<p>另外还有一点需要知道的是,HBase 中 Flush 操作和 Compaction 操作和读写链路是由独立线程完成的,互不干扰。</p>
<h2>四. 系统开销定量分析</h2>
<p>为了简化计算,本节针对事件类数据写吞吐型场景,对 HBase 系统中的开销做定量的分析,做以下假设:</p>
<ol>
<li>数据写入的 Rowkey 是打散的,不存在写热点</li>
<li>数据写入量及总量是可评估的,会对数据做预先分区,定量分析基于 region 分布稳定的情况下</li>
<li>假设随机读的数目很小,小到可以忽略 IO 开销,且对读 RT 不敏感</li>
<li>数据没有更新,没有删除操作,有生命周期TTL设置</li>
<li>HBase 写入链路中不存在随机磁盘,所以随机 IOPS 不会成为瓶颈</li>
<li>一般大数据机型的多个 SATA 盘的顺序写吞吐大于万兆网卡</li>
<li>忽略掉RPC带来的额外的带宽消耗</li>
</ol>
<h3>4.1 系统变量</h3>
<ol>
<li>单条数据大小 -> s (bytes)</li>
<li>峰值写 TPS -> T</li>
<li>HFile 副本数→ R1 (一般为3)</li>
<li>WAL 副本数 → R2 (一般为3)</li>
<li>WAL 数据压缩比 → Cwal (一般是1)</li>
<li>HFile 压缩比 → C (采用 DIFF + LZO, 日志场景压缩比一般为 0.2左右)</li>
<li>FlushSize → F (这里跟 regionserver 的 memstore 内存容量,region 数目,写入是否平均和 flushsize 的配置有关,简化分析,认为内存是足够的 128MB)</li>
<li>hbase.hstore.compaction.min → CT (默认是 3, 一般情况下,决定了归并系数,即每次 compaction 参与的文件数目,在不存在 compaction 积压的情况下, 实际运行时也是在 3 左右)</li>
<li>数据生命周期 → TTL (决定数据量的大小,一般写吞吐场景,日志会有一定的保存周期, 单位天)</li>
<li>单机数据量水位 → D ( 单位 T,这里指 HDFS 上存放 HFile 数据的数据量平均分担到每台机器上)</li>
<li>MajorCompaction 周期 → M( hbase.hregion.majorcompaction 决定,默认 20 天)</li>
</ol>
<p>以上 11 个参数,是本次量化分析中需要使用到的变量,系统资源方面主要量化以下两个指标:</p>
<ol>
<li>磁盘开销</li>
<li>网络开销</li>
</ol>
<h3>4.2 磁盘容量开销量化分析</h3>
<p>这里只考虑磁盘空间方面的占用,相关的变量有:</p>
<ol>
<li>单条数据大小 s</li>
<li>峰值写入 TPS</li>
<li>HFile 副本数 R1</li>
<li>HFile 压缩比 c</li>
<li>数据生命周期 TTL</li>
</ol>
<p>HFile的磁盘容量量化公式</p>
<blockquote>V = TTL <em> 86400 </em> T <em> s </em> C * R1</blockquote>
<p>假设 s = 1000, TTL = 365, T = 200000, C = 0.2 , R1 = 3 的情况下,HFile 磁盘空间需求是:</p>
<pre><code> V = 30 * 86400 * 200000 * 1000 * 0.2 * 3
= 311040000000000.0 bytes
= 282T </code></pre>
<p>在这里我们忽略了其他占用比较小的磁盘开销,比如:</p>
<ol>
<li>WAL的磁盘开销,在没有 Replication,写入平均的情况下,WAL 的日志量约定于 (hbase.master.logcleaner.ttl /1000) <em> s </em> TPS + totalMemstoreSize</li>
<li>Compaction 临时文件,Split 父 Region 文件等临时文件</li>
<li>Snapshot 文件</li>
<li>等等</li>
</ol>
<h3>4.3 网络开销量化分析</h3>
<p>HBase中会造成巨大网络开销的主要由一下三部分组成,他们是相互独立,异步进行的,这里做个比方,HBase 这三个操作和人吃饭很像,这里做个类比</p>
<p><img src="/img/bVbnkMY?w=1194&h=380" alt="图片描述" title="图片描述"></p>
<p>回归正题,下面按照发生顺序,从三个角度分别分析:</p>
<ol>
<li>写路径</li>
<li>Flush</li>
<li>Compaction</li>
</ol>
<h4>4.3.1 写路径</h4>
<p>写路径的网络开销,主要是写 WAL 日志方面, 相关的变量有:</p>
<ol>
<li>单条数据大小 s</li>
<li>峰值写入 TPS</li>
<li>WAL 副本数 R2</li>
<li>WAL 压缩比 Cwal</li>
</ol>
<p>写路径中,产生的网络流量分为两部分,一部分是写 WAL 产生的流量,一部分是外部用户 RPC 写入的流量, In 流量和 Out 流量计算公式为:</p>
<blockquote>NInWrite = T <em> s </em> Cwal <em> (R2 - 1) + (T </em> s )<p>NOutWrite = T <em> s </em> Cwal * (R2 - 1)</p>
</blockquote>
<p>假设 T = 20W,s = 1000, Cwal = 1.0, R2 = 3</p>
<pre><code> NInwrite = 200000 * 1000 * 1 * (3-1) + 200000 * 1000
= 600000000 bytes/s
= 572MB/s
NOutwrite = 200000 * 1000* 1 * (3-1)
= 400000000 bytes/s
= 381MB/s</code></pre>
<h4>4.3.2 Flush</h4>
<p>Flush 的网络开销,主要是生成 HFile 后,将 HFile 写入到 HDFS 的过程,相关的变量有:</p>
<ol>
<li>单条数据大小 s</li>
<li>峰值写入 T</li>
<li>HFIle 副本数 R1</li>
<li>HFile 压缩比 C</li>
</ol>
<p>Flush 产生的 In 流量和 Out 流量计算公式为:</p>
<blockquote>NInWrite = s <em> T </em> (R1 - 1) * C<p>NOutWrite = s <em> T </em> (R1 - 1) * C</p>
</blockquote>
<p>假设 T = 20W, S = 1000, R1 = 3, C = 0.2</p>
<pre><code> NInwrite = 200000 * 1000 * (3 - 1) * 0.2
= 80000000.0 bytes/s
=76.3MB/s
NOutwrite = 200000 * 1000 * (3 - 1) * 0.2
= 120000000.0 bytes/s
=76.3MB/s</code></pre>
<h4>4.3.3 Compaction</h4>
<p>Compaction 比较复杂,在有预分区不考虑 Split 的情况下分为两类:</p>
<ol>
<li>Major Compaction</li>
<li>Minor Compaction</li>
</ol>
<p>两者是独立的,下面将分别针对两种 Compaction 做分析,最后取和:</p>
<h5>4.3.3.1 Major Compaction</h5>
<p>Major Compaction 的定义是由全部 HFile 参与的 Compaction, 一般在发生在 Split 后发生,或者到达系统的 MajorCompaction 周期, 默认的 MajorCompaction 周期为 20 天,这里我们暂时忽略 Split 造成的 MajorCompaction 流量. 最终 Major Compaction 开销相关的变量是:</p>
<ol>
<li>单机数据量水位 D</li>
<li>HFIle 副本数 R1</li>
<li>MajorCompaction 周期 → M (默认 20 天)</li>
</ol>
<p>这里假设数据是有本地化的,所以 MajorCompaction 的读过程,走 ShortCircuit,不计算网络开销,并且写 HFile 的第一副本是本地流量,也不做流量计算,所以 MajorCompaction 的网络流量计算公式是:</p>
<blockquote>NInMajor = D * (R1 - 1) / M<p>NOutMajor = D * (R1 - 1) / M</p>
</blockquote>
<p>假设 D = 10T, R1 = 3, M = 20</p>
<pre><code> NInMajor = 10 * 1024 * 1024 * 1024 * 1024 * (3 - 1) / (20 * 86400)
= 12725829bytes/s
= 12MB/s
NOutMajor = 10 * 1024 * 1024 * 1024 * 1024 * (3 - 1) / (20 * 86400)
= 12725829bytes /s
= 12MB/s</code></pre>
<h5>4.3.3.2 Minor Compaction</h5>
<p>量化之前,先问一个问题,每条数据在第一次 flush 成为 HFile 之后,会经过多少次 Minor Compaction? </p>
<p>要回答这个问题之前,要先了解现在 HBase 默认的 compaction 的文件选取策略,这里不展开,只做简单分析,MinorCompaction 选择的文件对象数目,一般处于 hbase.hstore.compaction.min(默认 3)和 hbase.hstore.compaction.max(默认 10)之间, 总文件大小小于 hbase.hstore.compaction.max.size(默认 Max), 如果文件的 Size 小于 hbase.hstore.compaction.min.size(默认是 flushsize), 则一定会被选中; 并且被选中的文件size的差距不会过大, 这个由参数 hbase.hstore.compaction.ratio 和 hbase.hstore.compaction.ratio.offpeak 控制,这里不做展开.</p>
<p>所以,在 Compaction 没有积压的情况下,每次 compaction 选中的文件数目会等于 hbase.hstore.compaction.min 并且文件 size 应该相同量级, 对稳定的表,对每条数据来说,经过的 compaction 次数越多,其文件会越大. 其中每条数据参与 Minor Compaction 的最大次数可以用公式 math.log( 32000 / 25.6, 3) = 6 得到</p>
<p>这里用到的两个变量是:</p>
<ol>
<li>FlushSize 默认是 128 MB</li>
<li>HFile 压缩比例,假设是 0.2</li>
</ol>
<p>所以刚刚 Flush 生成的 HFile 的大小在 25.6MB 左右,当集齐三个 25.6MB 的 HFile 后,会触发第一次 Minor Compaction, 生成一个 76.8MB 左右的 HFile</p>
<p><img src="/img/bVbnkM6?w=948&h=278" alt="图片描述" title="图片描述"></p>
<p>对于一般情况,单个 Region 的文件 Size 我们会根据容量预分区好,并且控制单个 Region 的 HFile 的总大小 在 32G 以内,对于一个 Memstore <br> 128MB, HFile 压缩比 0.2, 单个 Region 32G 的表,上表中各个 Size 的 HFile 数目不会超过 2 个(否则就满足了触发 Minor Compaction 的条件)</p>
<p>32G = 18.6G + 6.2G + 6.2G + 690MB + 230MB + 76.8MB + 76.8MB</p>
<p>到这里,我们知道每条写入的数据,从写入到 TTL 过期,经过 Minor Compaction 的次数是可以计算出来的。 所以只要计算出每次 Compaction 的网络开销,就可以计算出,HBase 通过 Minor Compaction 消化每条数据,所占用的总的开销是多少,这里用到的变量有:</p>
<ol>
<li>单条数据大小 s</li>
<li>峰值写入 T</li>
<li>HFIle 副本数 R1</li>
<li>HFile 压缩比 C</li>
</ol>
<p>计算公式如下:</p>
<blockquote>NInMinor = S <em> T </em> (R1-1) <em> C </em> 总次数<p>NOutMinor = S <em> T </em> (R1-1) <em> C </em> 总次数</p>
</blockquote>
<p>假设 S = 1000, T = 20W, R1 = 3, C = 0.2, 总次数 = 6</p>
<pre><code> NInminor = 1000 * 200000 * (3 - 1) * 0.2 * 6
= 480000000.0bytes/s
= 457.8MB/s
NOutminor = 1000 * 200000 * (3 - 1) * 0.2 * 6
= 480000000.0bytes/s
= 457.8MB/s </code></pre>
<h4>4.3.4 网络资源定量分析小结</h4>
<p>在用户写入 TPS 20W, 单条数据大小 1000 bytes的场景下,整体网络吞吐为:</p>
<pre><code>NIntotal = NInwrite + NInflush + NInmajor + NInminor
= 572MB/s + 76.3MB/s + 12MB/s + 457.8MB/s
= 1118.1MB/s
NOuttotal = NOutwrite + NOutflush + NOutmajor + NOutminor
= 381MB/s + 76.3MB/s + 12MB/s + 457.8MB/s
= 927.1MB</code></pre>
<p>当然这是理想情况下的最小开销,有很多种情况,可以导致实际网络开销超过这个理论值, 以下情况都会导致实际流量的升高:</p>
<ol>
<li>预分区不足或者业务量增长,导致 Region 发生 Split, Split 会导致额外的 Compaction 操作</li>
<li>分区写入不平均,导致少量 region 不是因为到达了 flushsize 而进行 flush,导致 flush 下来的文件 Size 偏小</li>
<li>HFile 因为 balance 等原因导致本地化率低,也会导致 compaciton 产生更多的网卡开销</li>
<li>预分区数目过多,导致全局 memstore 水位高,memstore 没办法到达 flushsize 进行 flush,从而全局都 flush 出比较小的文件</li>
<li>等等</li>
</ol>
<p>有了这个量化分析后,我们能做什么优化呢? 这里不深入展开,简单说几点已经在有赞生产环境得到验证具有实效的优化点:</p>
<ol>
<li>业务接入初期,协助业务做 Rowkey 的设计,避免写入热点</li>
<li>增加 hbase.hstore.compaction.min,增加每次 Compaction参加的文件数,相当于减少了每条数据整个生命周期经历过的 Compaction 次数</li>
<li>根据业务稳态的规模,做好预分区,尽量减少 Split 造成的额外开销</li>
<li>对于读 RT 不敏感的业务,可以设置 hbase.hstore.compaction.max.size 为 4g,尽可能减少过大的文件做 Compaction,因为大文件做 compaction 的 ROI 实在太低</li>
<li>对于没有多版本并且有 TTL 的数据,可以关闭系统的 MajorCompaction 周期,数据过期采用文件整体过期的方式,消除 MajorCompaction 的系统开销</li>
<li>对于吞吐大的场景,用户在写入数据的时候就对数据做压缩,减小写路径造成的网络开销,毕竟 WAL 是不能压缩的(压缩功能形同虚设)</li>
<li>调整 Memstore 的内存比例,保证单机上每个 Region 尽可能的分配到 Flushsize 大小的内存,尽可能的 flush 大文件,从而减少后续 Compaction 开销</li>
</ol>
<h2>五. 总结</h2>
<p>到这里,HBase 的写吞吐场景的资源定量分析和优化的介绍就算结束了,本文基于 HBase1.2.6 版本。 对很多 HBase 的细节没有做展开说明,有些地方因为作者认知有限,难免纰漏,欢迎各位同行指出。</p>
<p>最后打个小广告,有赞大数据团队基础设施团队,主要负责有赞的数据平台(DP), 实时计算(Storm, Spark Streaming, Flink),离线计算(HDFS,YARN,HIVE, SPARK SQL),在线存储(HBase),实时 OLAP(Druid) 等数个技术产品,欢迎感兴趣的小伙伴联系 hefei@youzan.com</p>
<p>参考文献</p>
<ol>
<li><a href="https://link.segmentfault.com/?enc=6haSep2uxur3F5SaJUdzRw%3D%3D.6YOs2e9fCwSYWCYGCHKcJRTMoXRyoFsubnxGRWOxUSMmTCrvb2aXjGo5%2FXYDWOaJb1mAbxLP%2Bg782G%2FHPFz8VBjnbw%2FR7Uit4MSuWtpVJoMINGLr6FZzl%2F7s6muemcxM" rel="nofollow">Google BigTable</a></li>
<li><a href="https://link.segmentfault.com/?enc=CLZxqAD%2Fj0tsAZx5pvayQQ%3D%3D.2P3%2FIHj%2By55EM%2FUOf92wiUcMH3P6AHo9vVZMfm7aaSQ%3D" rel="nofollow">HBase 官方网站</a></li>
</ol>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
响应式架构与 RxJava 在有赞零售的实践
https://segmentfault.com/a/1190000017828854
2019-01-09T15:03:46+08:00
2019-01-09T15:03:46+08:00
有赞技术
https://segmentfault.com/u/youzantech
16
<p>随着有赞零售业务的快速发展,系统和业务复杂度也在不断提升。如何解决系统服务化后,多个系统之间的耦合,提升业务的响应时间与吞吐量,有效保证系统的健壮性和稳定性,是我们面临的主要问题。结合目前技术体系和业务特点的思考,我们在业务中实践了响应式架构以及RxJava框架,来解决系统与业务复杂所带来的问题。</p>
<h2>实践响应式架构</h2>
<p>响应式架构是指业务组件和功能由<strong>事件驱动</strong>,每个组件异步驱动,可以并行和分布式部署及运行。</p>
<p>响应式架构可以带来以下优势:</p>
<ul>
<li>大幅度降低应用程序内部的耦合性</li>
<li>事件传递形式简化了并行程序的开发工作,使开发人员无须与并发编程基础元素打交道,同时可以解决许多并发编程难题,如死锁等。</li>
<li>响应式架构能够大幅度提高调用方法的安全性和速度。</li>
<li>对复杂业务系统的领域建模,响应式架构可以天然支持。每个系统组件就可以对应到一个业务实体,业务实体之间通过接收事件来完成一次业务操作。</li>
</ul>
<p>我们使用响应式架构主要是为解决多个系统间的多次远程调用带来的分布式问题,尤其在长任务场景中,响应式架构显得尤其必要。</p>
<p>有赞连锁出现后,随着连锁商家经营规模的扩张,会在系统中创建新的门店。创建新门店会引发一系列业务初始化工作,例如店铺、员工、仓库、商品、库存等业务域,并且各业务域之间存在一定的依赖关系(如图1所示),例如商品依赖仓库初始化完成。</p>
<p><img src="/img/remote/1460000017828857?w=865&h=414" alt="图1连锁新建分店系统依赖关系" title="图1连锁新建分店系统依赖关系"></p>
<p>图1 连锁新建分店系统依赖关系</p>
<p>商家新增门店时,在店铺初始化完成后,连锁系统发送店铺初始化成功消息,相应系统对事件进行响应,处理完成(成功/失败)后将回执给连锁系统,连锁系统根据相关业务的反馈,决定是继续通知下游业务,还是结束整个过程。新建门店部分流程如图2所示。</p>
<p>在创建门店业务中,每个系统响应连锁系统发出的消息,处理完成后进行回执。通过这种模式,业务系统本身不关心其他系统是否成功或失败,只需对通知的事件进行处理,整体初始化进度与异常处理由连锁系统来控制。这种设计使得各业务系统之间没有直接耦合并保持相互独立。</p>
<p><img src="/img/remote/1460000017828858?w=865&h=927" alt="图2 连锁体系新增分店消息驱动图" title="图2 连锁体系新增分店消息驱动图"></p>
<p>图2 连锁体系新增分店消息驱动图</p>
<p>上面的案例介绍了在复杂业务场景下系统间对响应式架构的实践,系统内部同样会遇到复杂业务场景。下面介绍下在系统内部应对复杂业务的实践。</p>
<h2>RxJava在有赞零售实践</h2>
<p>Rxjava是用来编写异步和基于消息的程序的类库。RxJava在Android有着广泛的使用,主要应用在用户界面绘制与服务端通讯等场景。RxJava的核心思想是响应式编程以及事件、异步这两个特点。响应式编程是一种通过异步和事件流来构建程序的编程模型。在复杂的业务开发中,最棘手的问题就是如何清晰直观的展现复杂的业务逻辑,并且方便后续的业务维护与扩展。</p>
<h3>响应式编程使得复杂业务逻辑更清晰</h3>
<p>有赞零售的业务场景中有着复杂的业务逻辑,有赞目前提供多种产品供商家选择,商家在不同产品进行切换时,为了商家更好的体验,不同业务的切换会进行数据初始化与处理。例如有赞微商城转换到有赞零售。</p>
<p>这里拿着微商城升级零售的业务场景给大家举例。微商城升级为零售时需要对商品进行转换。首先初始化店铺基础信息。然后读取商品流,将微商城的商品类型转换成零售支持的商品类型。最后读取规格,为规格创建供应链商品库,创建门店商品与添加网店商品的供应链商品关联关系。整体转换流程如图3所示。图中也画出了可以并发处理的场景。</p>
<p><img src="/img/remote/1460000017828859" alt="图3 微商城升级有赞零售流程" title="图3 微商城升级有赞零售流程"></p>
<p>图3 微商城升级有赞零售流程</p>
<p>如果单纯使用设计模式来解决上面这种场景单一、但业务逻辑特别复杂的场景,是很难做到的。也可以看到除了初始化信息那一步,后面的商品模型转化自始至终在业务中流转的事件都是商品,这里就可以使用RxJava来优化业务代码使得处理流程可以并发,加快升级速度。</p>
<p>最终我们按照图3的流程处理升级逻辑,其中的并发场景,比如保存完零售商品后,并发处理库存、和销售渠道,使用rxjava封装的方法帮助我们进行并发操作。如下所示代码结构清晰,对外屏蔽了复杂的并发处理逻辑。</p>
<pre><code>Observable.zip(
callAsync(()->处理库存相关操作),
callAsync(()->更新商品库门店销售渠道),
callAsync(()->创建商品库与网店商品关联关系),
(sku1,sku2,sku3)-> sku
).blockingFirst();
</code></pre>
<p>最终我们的整体的代码</p>
<pre><code>UpgradeItem.listItems(manager, shop)
.flatMap(item-> fromCallable(()->更新为零售商品类型))
.flatMap(item-> fromCallable(()->并发处理商品操作), true)
.flatMap(item-> 商品流转化为sku流, true)
.flatMap(sku-> fromCallable(()->保存零售商品))
.flatMap(sku-> fromCallable(()->并发处理保存商品后续操作, true)
.subscribeOn(Schedulers.io());
</code></pre>
<p>整个商品处理流程就是上面这段代码,一目了然,后面扩展可以自己在中间加入处理流程,也可以在对应业务方法中修改逻辑。</p>
<h3>多服务、数据源组合</h3>
<p>随着微服务架构兴起,我们将不同的业务域拆分成不同的系统。这样方便了系统的维护,提升了系统的扩展性,但是给上层业务系统也带来了很多麻烦。往往我们为了展示一个页面会涉及到2-3个或更多的应用,而多次的分布式调用不但使得系统的rt增加,也使得核心页面的出错风险更高。</p>
<p><strong>降低rt</strong>:在假设第三方接口已经达到性能顶点的情况下,并发是解决多次分布式调用降低rt的常用方法。</p>
<p><strong>自动降级</strong>:传统编程方法中,自动降级处理,意味着我们代码中会出现一大堆try/catch,而使用rxjava,我们可以直接定义当流处理异常时,程序需要怎么做,这样的代码看起来非常简洁。</p>
<p>商品搜索作为商品管理的核心入口,根据不同场景聚合商品、优惠、库存等信息。由于商品列表页展示的信息涉及到多服务数据的整合,一方面需要保证整个接口的rt,另一方面不希望由于一个商品数据或外部服务的异常影响到整个商品列表的加载。因此该场景非常适用于RxJava。</p>
<p><img src="/img/remote/1460000017828860?w=728&h=954" alt="图4 商品后台搜索业务流程" title="图4 商品后台搜索业务流程"></p>
<p>最终我们的代码</p>
<p>1.根据入参获取商品加载器</p>
<pre><code>//只有包含的merger才会加载
List<SkuAttrMerger> validMergers =
Observable.fromIterable(skuAttrMergers).filter(loader -> request.getAttributes().contains(loader.supportAttribute().getValue())).toList().blockingGet();
</code></pre>
<p>2.根据es结果获取商品各个属性详情并加载到SkuAttrContext中(某类属性加载失败则忽略)</p>
<pre><code>//调用load并发加载数据到商品属性上下文中
Observable.fromIterable(商品信息加载器列表)
.flatMap(商品信息加载器-> Observable.fromCallable(() ->异步加载商品信息))
.onErrorResumeNext(Observable.empty())//如果失败则忽略
.subscribeOn(Schedulers.io()),false,线程数(为加载器数
量)).blockingSubscribe();
</code></pre>
<p>3.组装搜索结果(如果某个sku组装失败则直接忽略)</p>
<pre><code>//调用merge将数据合并到目标对象
商品搜索返回结果列表 = Observable.fromIterable(商品id列表)
.map(商品id->初始化商品搜索结果返回对象)
.flatMap(商品搜索结果返回对象-> {
val observables=Observable.fromIterable(商品加载器列表)
.map(loader -> Observable.fromCallable(() ->合并每个sku的不同属性)).toList().blockingGet();
return Observable.zipIterable(observables, (a) -> sku, false, 线程数)
.onErrorResumeNext(Observable.empty()); //如果失败则忽略
}, false, 1)
.toList()
.blockingGet();
</code></pre>
<h2>后记</h2>
<p>本文主要介绍了响应式架构与RxJava在有赞零售的使用场景。目前我们对响应式架构的实践方式是:在系统间使用消息中间件来进行实现,在系统内则使用RxJava实现异步化和响应式编程。对于响应式架构的思想,我们也在探索阶段,并在部分业务场景进行实践。未来面对越来越复杂的零售业务场景,会用响应式架构全面实现系统业务的异步化。总的来说响应式架构思想为提升复杂业务系统健壮性、灵活性提供了强有力的支撑。后面大家如果想更多的讨论响应式架构与编程的实践,欢迎联系我们。</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
Dubbo压测插件的实现——基于Gatling
https://segmentfault.com/a/1190000017497987
2018-12-24T17:54:53+08:00
2018-12-24T17:54:53+08:00
有赞技术
https://segmentfault.com/u/youzantech
11
<p><strong>Dubbo 压测插件已开源,本文涉及代码详见<a href="https://link.segmentfault.com/?enc=1bGoF4vES9kh2YKW9U3cEQ%3D%3D.HB8%2BeUaJVdpPeX3c1tO%2F8Uvdu%2BenSxvfndwKu6IIi8fjn2sJTw7wegUB%2Bd%2Bhp2uy" rel="nofollow">gatling-dubbo</a></strong></p>
<p>Gatling 是一个开源的基于 Scala、Akka、Netty 实现的高性能压测框架,较之其他基于线程实现的压测框架,Gatling 基于 AKKA Actor 模型实现,请求由事件驱动,在系统资源消耗上低于其他压测框架(如内存、连接池等),使得单台施压机可以模拟更多的用户。此外,Gatling 提供了一套简单高效的 DSL(领域特定语言)方便我们编排业务场景,同时也具备流量控制、压力控制的能力并提供了良好的压测报告,所以有赞选择在 Gatling 基础上扩展分布式能力,开发了自己的全链路压测引擎 MAXIM。全链路压测中我们主要模拟用户实际使用场景,使用 HTTP 接口作为压测入口,但有赞目前后端服务中 Dubbo 应用比重越来越高,如果可以知道 Dubbo 应用单机水位将对我们把控系统后端服务能力大有裨益。基于 Gatling 的优势和在有赞的使用基础,我们扩展 Gatling 开发了 gatling-dubbo 压测插件。</p>
<h2>插件主要结构</h2>
<p>实现 Dubbo 压测插件,需实现以下四部分内容:</p>
<ul><li><strong>Protocol 和 ProtocolBuild</strong></li></ul>
<p>协议部分,这里主要定义 Dubbo 客户端相关内容,如协议、泛化调用、服务 URL、注册中心等内容,ProtocolBuild 则为 DSL 使用 Protocol 的辅助类</p>
<ul><li><strong>Action 和 ActionBuild</strong></li></ul>
<p>执行部分,这里的作用是发起 Dubbo 请求,校验请求结果并记录日志以便后续生成压测报告。ActionBuild 则为 DSL 使用 Action 的辅助类</p>
<ul><li><strong>Check 和 CheckBuild</strong></li></ul>
<p>检查部分,全链路压测中我们都使用<code>Json Path</code>检查请求结果,这里我们实现了一样的检查逻辑。CheckBuild 则为 DSL 使用 Check 的辅助类</p>
<ul><li><strong>DSL</strong></li></ul>
<p>Dubbo 插件的领域特定语言,我们提供了一套简单易用的 API 方便编写 Duboo 压测脚本,风格上与原生 HTTP DSL 保持一致</p>
<p><img src="/img/bVblAbH?w=1588&h=958" alt="图片描述" title="图片描述"></p>
<h2>Protocol</h2>
<p>协议部分由 5 个属性组成,这些属性将在 Action 初始化 Dubbo 客户端时使用,分别是:</p>
<ul><li><strong>protocol</strong></li></ul>
<p>协议,设置为<code>dubbo</code></p>
<ul><li><strong>generic</strong></li></ul>
<p>泛化调用设置,Dubbo 压测插件使用泛化调用发起请求,所以这里设置为<code>true</code>,有赞优化了泛化调用的性能,为了使用该特性,引入了一个新值<code>result_no_change</code>(去掉优化前泛化调用的序列化开销以提升性能)</p>
<ul><li><strong>url</strong></li></ul>
<p>Dubbo 服务的地址:<code>dubbo://IP地址:端口</code></p>
<ul><li><strong>registryProtocol</strong></li></ul>
<p>Dubbo 注册中心的协议,设置为<code>ETCD3</code></p>
<ul><li><strong>registryAddress</strong></li></ul>
<p>Dubbo 注册中心的地址</p>
<p>如果是测试 Dubbo 单机水位,则设置 url,注册中心设置为空;如果是测试 Dubbo 集群水位,则设置注册中心(目前支持 ETCD3),url 设置为空。由于目前注册中心只支持 ETCD3,插件在 Dubbo 集群上使用缺乏灵活性,所以我们又实现了客户端层面的负载均衡,如此便可抛开特定的注册中心来测试 Dubbo 集群水位。该特性目前正在内测中。</p>
<pre><code class="scala">object DubboProtocol {
val DubboProtocolKey = new ProtocolKey {
type Protocol = DubboProtocol
type Components = DubboComponents
def protocolClass: Class[io.gatling.core.protocol.Protocol] = classOf[DubboProtocol].asInstanceOf[Class[io.gatling.core.protocol.Protocol]]
def defaultProtocolValue(configuration: GatlingConfiguration): DubboProtocol = throw new IllegalStateException("Can't provide a default value for DubboProtocol")
def newComponents(system: ActorSystem, coreComponents: CoreComponents): DubboProtocol => DubboComponents = {
dubboProtocol => DubboComponents(dubboProtocol)
}
}
}
case class DubboProtocol(
protocol: String, //dubbo
generic: String, //泛化调用?
url: String, //use url or
registryProtocol: String, //use registry
registryAddress: String //use registry
) extends Protocol {
type Components = DubboComponents
}</code></pre>
<p>为了方便 Action 中使用上面这些属性,我们将其装进了 Gatling 的 ProtocolComponents:</p>
<pre><code class="scala">case class DubboComponents(dubboProtocol: DubboProtocol) extends ProtocolComponents {
def onStart: Option[Session => Session] = None
def onExit: Option[Session => Unit] = None
}</code></pre>
<p>以上就是关于 Protocol 的定义。为了能在 DSL 中配置上述 Protocol,我们定义了 DubboProtocolBuilder,包含了 5 个方法分别设置 Protocol 的 protocol、generic、url、registryProtocol、registryAddress 5 个属性。</p>
<pre><code class="scala">object DubboProtocolBuilderBase {
def protocol(protocol: String) = DubboProtocolBuilderGenericStep(protocol)
}
case class DubboProtocolBuilderGenericStep(protocol: String) {
def generic(generic: String) = DubboProtocolBuilderUrlStep(protocol, generic)
}
case class DubboProtocolBuilderUrlStep(protocol: String, generic: String) {
def url(url: String) = DubboProtocolBuilderRegistryProtocolStep(protocol, generic, url)
}
case class DubboProtocolBuilderRegistryProtocolStep(protocol: String, generic: String, url: String) {
def registryProtocol(registryProtocol: String) = DubboProtocolBuilderRegistryAddressStep(protocol, generic, url, registryProtocol)
}
case class DubboProtocolBuilderRegistryAddressStep(protocol: String, generic: String, url: String, registryProtocol: String) {
def registryAddress(registryAddress: String) = DubboProtocolBuilder(protocol, generic, url, registryProtocol, registryAddress)
}
case class DubboProtocolBuilder(protocol: String, generic: String, url: String, registryProtocol: String, registryAddress: String) {
def build = DubboProtocol(
protocol = protocol,
generic = generic,
url = url,
registryProtocol = registryProtocol,
registryAddress = registryAddress
)
}</code></pre>
<h2>Action</h2>
<p>DubboAction 包含了 Duboo 请求逻辑、请求结果校验逻辑以及压力控制逻辑,需要扩展 ExitableAction 并实现 execute 方法。</p>
<p>DubboAction 类的域 argTypes、argValues 分别是泛化调用请求参数类型和请求参数值,需为 Expression[] 类型,这样当使用数据 Feeder 作为压测脚本参数输入时,可以使用类似 <code>${args_types}</code>、<code>${args_values}</code>这样的表达式从数据 Feeder 中解析对应字段的值。</p>
<p>execute 方法必须以异步方式执行 Dubbo 请求,这样前一个 Dubbo 请求执行后但还未等响应返回时虚拟用户就可以通过 AKKA Message 立即发起下一个请求,如此一个虚拟用户可以在很短的时间内构造大量请求。请求方式方面,相比于泛化调用,原生 API 调用需要客户端载入 Dubbo 服务相应的 API 包,但有时候却拿不到,此外,当被测 Dubbo 应用多了,客户端需要载入多个 API 包,所以出于使用上的便利性,Dubbo 压测插件使用泛化调用发起请求。</p>
<p>异步请求响应后会执行 onComplete 方法,校验请求结果,并根据校验结果记录请求成功或失败日志,压测报告就是使用这些日志统计计算的。 </p>
<p>为了控制压测时的 RPS,则需要实现 throttle 逻辑。实践中发现,高并发情况下,泛化调用性能远不如原生 API 调用性能,且响应时间成倍增长(如此不能表征 Dubbo 应用的真正性能),导致 Dubbo 压测插件压力控制不准,解决办法是优化泛化调用性能,使之与原生 API 调用的性能相近,请参考<strong><a href="https://link.segmentfault.com/?enc=HsQOArlhSnNxofUWrZUf9g%3D%3D.OjJST%2BVU6IrrcDw%2BA4Lzuijr3S1Js%2Ffuxngmmx1Yb0%2F0N%2BHGim8QSKlIZA%2Ba%2FIs1yOTmb1I1TU8JYm47u9xr3Q%3D%3D" rel="nofollow">dubbo 泛化调用性能优化</a></strong>。</p>
<pre><code class="scala">class DubboAction(
interface: String,
method: String,
argTypes: Expression[Array[String]],
argValues: Expression[Array[Object]],
genericService: GenericService,
checks: List[DubboCheck],
coreComponents: CoreComponents,
throttled: Boolean,
val objectMapper: ObjectMapper,
val next: Action
) extends ExitableAction with NameGen {
override def statsEngine: StatsEngine = coreComponents.statsEngine
override def name: String = genName("dubboRequest")
override def execute(session: Session): Unit = recover(session) {
argTypes(session) flatMap { argTypesArray =>
argValues(session) map { argValuesArray =>
val startTime = System.currentTimeMillis()
val f = Future {
try {
genericService.$invoke(method, argTypes(session).get, argValues(session).get)
} finally {
}
}
f.onComplete {
case Success(result) =>
val endTime = System.currentTimeMillis()
val resultMap = result.asInstanceOf[JMap[String, Any]]
val resultJson = objectMapper.writeValueAsString(resultMap)
val (newSession, error) = Check.check(resultJson, session, checks)
error match {
case None =>
statsEngine.logResponse(session, interface + "." + method, ResponseTimings(startTime, endTime), Status("OK"), None, None)
throttle(newSession(session))
case Some(Failure(errorMessage)) =>
statsEngine.logResponse(session, interface + "." + method, ResponseTimings(startTime, endTime), Status("KO"), None, Some(errorMessage))
throttle(newSession(session).markAsFailed)
}
case FuFailure(e) =>
val endTime = System.currentTimeMillis()
statsEngine.logResponse(session, interface + "." + method, ResponseTimings(startTime, endTime), Status("KO"), None, Some(e.getMessage))
throttle(session.markAsFailed)
}
}
}
}
private def throttle(s: Session): Unit = {
if (throttled) {
coreComponents.throttler.throttle(s.scenario, () => next ! s)
} else {
next ! s
}
}
}</code></pre>
<p>DubboActionBuilder 则是获取 Protocol 属性并初始化 Dubbo 客户端:</p>
<pre><code class="scala">case class DubboActionBuilder(interface: String, method: String, argTypes: Expression[Array[String]], argValues: Expression[Array[Object]], checks: List[DubboCheck]) extends ActionBuilder {
private def components(protocolComponentsRegistry: ProtocolComponentsRegistry): DubboComponents =
protocolComponentsRegistry.components(DubboProtocol.DubboProtocolKey)
override def build(ctx: ScenarioContext, next: Action): Action = {
import ctx._
val protocol = components(protocolComponentsRegistry).dubboProtocol
//Dubbo客户端配置
val reference = new ReferenceConfig[GenericService]
val application = new ApplicationConfig
application.setName("gatling-dubbo")
reference.setApplication(application)
reference.setProtocol(protocol.protocol)
reference.setGeneric(protocol.generic)
if (protocol.url == "") {
val registry = new RegistryConfig
registry.setProtocol(protocol.registryProtocol)
registry.setAddress(protocol.registryAddress)
reference.setRegistry(registry)
} else {
reference.setUrl(protocol.url)
}
reference.setInterface(interface)
val cache = ReferenceConfigCache.getCache
val genericService = cache.get(reference)
val objectMapper: ObjectMapper = new ObjectMapper()
new DubboAction(interface, method, argTypes, argValues, genericService, checks, coreComponents, throttled, objectMapper, next)
}
}</code></pre>
<p>LambdaProcessBuilder 则提供了设置 Dubbo 泛化调用入参的 DSL 以及接下来要介绍的 Check 部分的 DSL</p>
<pre><code class="scala">case class DubboProcessBuilder(interface: String, method: String, argTypes: Expression[Array[String]] = _ => Success(Array.empty[String]), argValues: Expression[Array[Object]] = _ => Success(Array.empty[Object]), checks: List[DubboCheck] = Nil) extends DubboCheckSupport {
def argTypes(argTypes: Expression[Array[String]]): DubboProcessBuilder = copy(argTypes = argTypes)
def argValues(argValues: Expression[Array[Object]]): DubboProcessBuilder = copy(argValues = argValues)
def check(dubboChecks: DubboCheck*): DubboProcessBuilder = copy(checks = checks ::: dubboChecks.toList)
def build(): ActionBuilder = DubboActionBuilder(interface, method, argTypes, argValues, checks)
}</code></pre>
<h2>Check</h2>
<p>全链路压测中,我们都使用<code>Json Path</code>校验 HTTP 请求结果,Dubbo 压测插件中,我们也实现了基于<code>Json Path</code>的校验。实现 Check,必须实现 Gatling check 中的 Extender 和 Preparer:</p>
<pre><code class="scala">package object dubbo {
type DubboCheck = Check[String]
val DubboStringExtender: Extender[DubboCheck, String] =
(check: DubboCheck) => check
val DubboStringPreparer: Preparer[String, String] =
(result: String) => Success(result)
}</code></pre>
<p>基于<code>Json Path</code>的校验逻辑:</p>
<pre><code class="scala">trait DubboJsonPathOfType {
self: DubboJsonPathCheckBuilder[String] =>
def ofType[X: JsonFilter](implicit extractorFactory: JsonPathExtractorFactory) = new DubboJsonPathCheckBuilder[X](path, jsonParsers)
}
object DubboJsonPathCheckBuilder {
val CharsParsingThreshold = 200 * 1000
def preparer(jsonParsers: JsonParsers): Preparer[String, Any] =
response => {
if (response.length() > CharsParsingThreshold || jsonParsers.preferJackson)
jsonParsers.safeParseJackson(response)
else
jsonParsers.safeParseBoon(response)
}
def jsonPath(path: Expression[String])(implicit extractorFactory: JsonPathExtractorFactory, jsonParsers: JsonParsers) =
new DubboJsonPathCheckBuilder[String](path, jsonParsers) with DubboJsonPathOfType
}
class DubboJsonPathCheckBuilder[X: JsonFilter](
private[check] val path: Expression[String],
private[check] val jsonParsers: JsonParsers
)(implicit extractorFactory: JsonPathExtractorFactory)
extends DefaultMultipleFindCheckBuilder[DubboCheck, String, Any, X](
DubboStringExtender,
DubboJsonPathCheckBuilder.preparer(jsonParsers)
) {
import extractorFactory._
def findExtractor(occurrence: Int) = path.map(newSingleExtractor[X](_, occurrence))
def findAllExtractor = path.map(newMultipleExtractor[X])
def countExtractor = path.map(newCountExtractor)
}</code></pre>
<p>DubboCheckSupport 则提供了设置 jsonPath 表达式的 DSL</p>
<pre><code class="scala">trait DubboCheckSupport {
def jsonPath(path: Expression[String])(implicit extractorFactory: JsonPathExtractorFactory, jsonParsers: JsonParsers) =
DubboJsonPathCheckBuilder.jsonPath(path)
}</code></pre>
<ul><li>Dubbo 压测脚本中可以设置一个或多个 check 校验请求结果,使用 DSL check 方法*</li></ul>
<h2>DSL</h2>
<p>trait <code>AwsDsl</code>提供顶层 DSL。我们还定义了 dubboProtocolBuilder2DubboProtocol、dubboProcessBuilder2ActionBuilder 两个 Scala 隐式方法,以自动构造 DubboProtocol 和 ActionBuilder。 <br>此外,泛化调用中使用的参数类型为 Java 类型,而我们的压测脚本使用 Scala 编写,所以这里需要做两种语言间的类型转换,所以我们定义了 transformJsonDubboData 方法</p>
<pre><code class="scala">trait DubboDsl extends DubboCheckSupport {
val Dubbo = DubboProtocolBuilderBase
def dubbo(interface: String, method: String) = DubboProcessBuilder(interface, method)
implicit def dubboProtocolBuilder2DubboProtocol(builder: DubboProtocolBuilder): DubboProtocol = builder.build
implicit def dubboProcessBuilder2ActionBuilder(builder: DubboProcessBuilder): ActionBuilder = builder.build()
def transformJsonDubboData(argTypeName: String, argValueName: String, session: Session): Session = {
session.set(argTypeName, toArray(session(argTypeName).as[JList[String]]))
.set(argValueName, toArray(session(argValueName).as[JList[Any]]))
}
private def toArray[T:ClassTag](value: JList[T]): Array[T] = {
value.asScala.toArray
}
}</code></pre>
<pre><code class="scala">object Predef extends DubboDsl</code></pre>
<h2>Dubbo 压测脚本和数据 Feeder 示例</h2>
<p><strong>压测脚本示例:</strong></p>
<pre><code class="scala">import io.gatling.core.Predef._
import io.gatling.dubbo.Predef._
import scala.concurrent.duration._
class DubboTest extends Simulation {
val dubboConfig = Dubbo
.protocol("dubbo")
.generic("true")
//直连某台Dubbo机器,只单独压测一台机器的水位
.url("dubbo://IP地址:端口")
//或设置注册中心,压测该Dubbo应用集群的水位,支持ETCD3注册中心
.registryProtocol("")
.registryAddress("")
val jsonFileFeeder = jsonFile("data.json").circular //数据Feeder
val dubboScenario = scenario("load test dubbo")
.forever("repeated") {
feed(jsonFileFeeder)
.exec(session => transformJsonDubboData("args_types1", "args_values1", session))
.exec(dubbo("com.xxx.xxxService", "methodName")
.argTypes("${args_types1}")
.argValues("${args_values1}")
.check(jsonPath("$.code").is("200"))
)
}
setUp(
dubboScenario.inject(atOnceUsers(10))
.throttle(
reachRps(10) in (1 seconds),
holdFor(30 seconds))
).protocols(dubboConfig)
}</code></pre>
<p><strong>data.json 示例:</strong></p>
<pre><code class="json">[
{
"args_types1": ["com.xxx.xxxDTO"],
"args_values1": [{
"field1": "111",
"field2": "222",
"field3": "333"
}]
}
]</code></pre>
<h2>Dubbo 压测报告示例</h2>
<p><img src="/img/bVblAbK?w=1814&h=1404" alt="图片描述" title="图片描述"><br><strong>我的系列博客</strong> <br><a href="https://link.segmentfault.com/?enc=w8W9b42uKOOvFAIJovekxA%3D%3D.BkITbNxm6xkSO0gnam2M3d0FV7vfBvLwkEfDlQWUOfkujkSsHKVYLvwDlPISfD4P" rel="nofollow">混沌工程 - 软件系统高可用、弹性化的必由之路</a> <br><a href="https://link.segmentfault.com/?enc=mGYAd6KxLWUq8dqnszGwnQ%3D%3D.cQFc5THUiV6aK6p5CNUDZ2Fnra6d8u1gQrpf5KVXxbCZoUktxo%2B8h43WpqSbRynD" rel="nofollow">异步系统的两种测试方法</a></p>
<p><strong>我的其他测试相关开源项目</strong> <br><a href="https://link.segmentfault.com/?enc=QrDsfe9xIRU%2BvVJc2Bnw9Q%3D%3D.OTT6DJUFef9zq%2FMsOc25ihsASjBfT6H6cJjWB8me%2Br0nCsDNv31OZWJJ2dHgcCe%2F" rel="nofollow">捉虫记:方便产品、开发、测试三方协同自测的管理工具</a></p>
<p><strong>招聘</strong> <br>有赞测试组在持续招人中,大量岗位空缺,只要你来,就能帮你点亮全栈开发技能树,有意向换工作的同学可以发简历到 sunjun【@】youzan.com</p>
有赞全链路压测引擎的设计与实现
https://segmentfault.com/a/1190000017497495
2018-12-24T17:15:45+08:00
2018-12-24T17:15:45+08:00
有赞技术
https://segmentfault.com/u/youzantech
7
<p>一年以前,有赞准备在双十一到来之前对系统进行一次性能摸底,以便提前发现并解决系统潜在性能问题,好让系统在双十一期间可以从容应对剧增的流量。工欲善其事,必先利其器,我们拿什么工具来压测呢?我们做了很多前期调研和论证,最终决定基于 Gatling 开发有赞自己的分布式全链路压测引擎 —— MAXIM。一年多来,我们使用 Maxim 对系统做了很多次的性能压测,在提升系统性能、稳定性的同时,也得益于历次压测的实践经验逐步改进 Maxim。</p>
<hr>
<h2>1、前期调研</h2>
<h3>1.1、技术选型的核心考量</h3>
<p>由于时间或成本关系,我们打算基于开源软件做二次开发,而以下就是我们技术选型时的核心考量:</p>
<ul><li><strong>将请求编排成业务场景</strong></li></ul>
<p>以用户下单这个场景为例,用户完成一笔订单,可能需要打开商品主页-加入购物车-选择收货地址-下单支付这些步骤,而串起这一系列的请求就是所谓的将请求编排成业务场景</p>
<ul><li><strong>流量控制</strong></li></ul>
<p>流量控制可以是纵向的,如上述下单场景中,各个步骤的请求量逐渐减少,整体呈现一个漏斗模型;也可以是横向的,比如用户正在浏览 A 商品的商品详情页,然后看到了 B 商品的推荐,转而浏览 B 商品的商品详情页</p>
<ul><li><strong>压力控制</strong></li></ul>
<p>指压测时并发用户数、吞吐量(RPS / TPS)的控制</p>
<ul><li><strong>数据跟请求参数的绑定</strong></li></ul>
<p>压测往往涉及大量的测试数据,而如何绑定数据和请求参数是我们需要考量的</p>
<ul><li><strong>对分布式测试的支持</strong></li></ul>
<p>因为是全链路压测,自然需要多台施压机共同协作施压,自然而然的需要分布式支持</p>
<ul><li><strong>测试报告</strong></li></ul>
<p>良好的测试报告是我们分析性能问题的必备条件</p>
<ul><li><strong>二次开发的成本</strong></li></ul>
<p>由于时间或人力关系,我们也需要考虑二次开发成本</p>
<h3>1.2、4 个主流开源性能测试框架对比</h3>
<p>我们调研了以下 4 个主流开源性能测试框架:<br><img src="/img/bVblz1d?w=1672&h=798" alt="图片描述" title="图片描述"></p>
<ul><li><strong>ApacheBench</strong></li></ul>
<p>Apache 服务器自带,简单易用,但不支持场景编排、不支持分布式,二次开发难度较大</p>
<ul><li><strong>JMeter</strong></li></ul>
<p>JMeter 支持上述很多特性,如分布式、良好的压测报告等,但其基于 GUI 的使用方式,使得当我们的压测场景非常复杂并包含很多请求时,使用上不够灵活;此外在流量控制方面的支持也一般</p>
<ul><li><strong>nGrinder</strong></li></ul>
<p>基于 Grinder 二次开发的开源项目,支持分布式,测试报告良好,但和 JMeter 一样,在场景编排和流量控制方面支持一般</p>
<ul><li><strong>Gatling</strong></li></ul>
<p>支持场景编排、流量控制、压力控制,测试报告良好,且提供了强大的 DSL(领域特定语言)方便编写压测脚本,但不支持分布式,且使用 Scala 开发,有一定开发成本</p>
<p>以上,我们最终选择基于 Gatling 做二次开发。</p>
<h2>2、Maxim 新增的特性</h2>
<p>Maxim 在 Gatling 基础上开发了很多新特性:</p>
<ul><li><strong>支持分布式</strong></li></ul>
<p>一个控制中心(Control Center,负责调度) + 多个压力注入器(指施压机)</p>
<ul><li><strong>提供 GUI,并对用户隐藏压测过程的复杂性</strong></li></ul>
<p>高效地创建、运行(手动/定期)测试任务</p>
<ul><li><strong>管理测试资源</strong></li></ul>
<p>测试资源包括压测脚本、数据集(为压测请求提供测试数据,由数据块构成的一个集合,数据块是大量测试数据的最小分割单元)、压力注入器</p>
<ul><li><strong>支持压测脚本参数化</strong></li></ul>
<p>Maxim 中并发用户数、RPS、持续时间等都可以通过 GUI 动态注入压测脚本</p>
<ul><li><strong>支持压力注入器系统状态监控</strong></li></ul>
<p>实时监控压力注入器的 CPU、内存、I/O 等指标</p>
<ul><li><strong>自动生成压测报告,保留历史压测报告</strong></li></ul>
<p>采集多个压力注入器的压测日志,自动汇总生成压测报告,并保留历史压测报告</p>
<h2>3、Maxim 的技术架构</h2>
<h3>3.1、Maxim 的总体架构</h3>
<p><img src="/img/bVblz1t?w=2604&h=1740" alt="图片描述" title="图片描述"></p>
<p>Maxim 架构的主要构成:</p>
<ul><li><strong>Maxim Console</strong></li></ul>
<p>Maxim Console 主要衔接 GUI 和 Maxim Control Center,负责创建、运行测试任务,接收压力控制参数等</p>
<ul><li><strong>Maxim Control Center</strong></li></ul>
<p>Maxim 的控制中心,这里主要负责压测任务的调度、读取数据集、上传脚本和数据以及读取日志并生成压测报告</p>
<ul><li><strong>Load Injector Cluster</strong></li></ul>
<p>压力注入器集群,主要分为 Agent 和 Gatling 两部分,Agent 负责接收 Maxim 控制中心的调度指令以及向控制中心反馈本压力注入器压测情况,而 Gatling 则是真正发起压测请求的地方,并将压测日志写入 InfluxDB</p>
<ul><li><strong>Data Factory</strong></li></ul>
<p>压测数据首先会在大数据平台通过 MapReduce 任务生成,而数据工厂负责为控制中心读取这些数据并返回数据集</p>
<ul><li><strong>Cloud Storage</strong></li></ul>
<p>云存储,Maxim 控制中心会将压测脚本和压测数据上传到云存储,当 Agent 收到控制中心的任务执行指令时,会从云存储下载压测脚本和对应的数据块。设计云存储的目的主要是为了模拟真实用户环境在公网发起压测请求,但有赞目前都是从内网发起压测请求,所以云存储的功能也可以以其他方式实现,比如 Agent 直接从大数据平台下载数据集</p>
<ul><li><strong>InfluxDB</strong></li></ul>
<p>所有压力注入器产生的日志都会统一写入 InfluxDB,方便生成压测报告</p>
<blockquote>
<strong>Maxim的调度算法</strong> <br>控制中心会根据当前测试任务使用的压力注入器数量,将数据集中的数据块平均分配给每个压力注入器,让每个压力注入器只下载对应的那些数据块。此外,并发用户数、RPS 也会被平均切分给每个压力注入器。这样,每个压力注入器的负载基本是一致的。</blockquote>
<h3>3.2、Maxim 的领域抽象</h3>
<p><img src="/img/bVblz2p?w=2456&h=1812" alt="图片描述" title="图片描述"></p>
<ul><li><strong>TestJob - JobExecution - JobSliceExecution</strong></li></ul>
<p>当压测任务开始执行,首先会在控制中心生成 JobExecution,监控本次压测任务的整体执行状态。控制中心又会根据上述调度算法为每个压力注入器生成任务分片 JobSliceExecution 并下发到各个压力注入器,其中包含了脚本、数据集等信息</p>
<ul><li><strong>TestScript</strong></li></ul>
<p>压测脚本</p>
<ul><li><strong>DataSet和DataChunk</strong></li></ul>
<p>数据集和组成数据集的数据块单元,目前单次压测任务已支持多数据集,为多个场景提供不同的压测数据,即混合场景压测</p>
<ul><li><strong>LoadProfile</strong></li></ul>
<p>从 GUI 接收动态参数,主要包括压力注入器数量、并发用户数、RPS、持续时间等</p>
<p><img src="/img/bVblz2x?w=1156&h=1140" alt="图片描述" title="图片描述"></p>
<ul><li><strong>ExecPlan</strong></li></ul>
<p>执行计划,包括按需执行和周期执行两种执行方式</p>
<ul><li><strong>ExecutionStatus</strong></li></ul>
<p>关于状态机下一节会详细介绍</p>
<h3>3.3、Maxim的状态机</h3>
<p><img src="/img/bVblz2D?w=3724&h=2748" alt="图片描述" title="图片描述"></p>
<p>Maxim 状态机是 Maxim 分布式的核心,控制中心和各个 Agent 的行为都受状态机变化的影响。</p>
<p>创建任务并开始执行以后,各个任务分片(JobSliceExecution)首先会进入 preparing 状态,各个 Agent 会从云存储下载压测脚本和各自对应的那些数据块,下载完成后再将这些数据块合并成一个 Json 数据文件作为压测脚本的数据输入。如果下载失败则会重试,即 Prepare。如果所有 Agent 都成功下载了脚本和数据,则各个 JobSliceExecution 会相继进入 prepared 状态,等所有 JobSliceExecution 进入 prepared 状态后,JobExecution 也会进入 prepared 状态,并向各个 Agent 发起执行指令,各个 JobSliceExecution 进入 running 状态,等所有 Agent 执行完成且各个 JobSliceExecution 变成 completed 状态之后,JobExecution 也会进入 completed 状态,此时压测任务执行完成并生成压测报告。如果各个任务分片在 preparing、prepared 或 running 过程中有任何一个出错,则出错的分片会进入 failed 状态并通知控制中心,控制中心则控制其他分片中止正在执行的任务并进入 Stopping 状态,等这些分片中止成功并都变成 stopped 状态后,JobExecution 会被置成 failed 状态。当然了,也可以手动停止压测任务,这时候 JobSliceExecution 和 JobExecution 都会被置成 stopping->stopped 状态。</p>
<h3>3.4、Maxim 控制中心的技术架构</h3>
<p><img src="/img/bVblz2X?w=2872&h=1626" alt="图片描述" title="图片描述"></p>
<p>Maxim 控制中心采用六边形架构(也叫端口与适配器模式),核心服务只处理核心业务逻辑(如调度算法),其他功能如与 Agent 通信、脚本存储、数据存储、压测报告等都是通过适配层调用特定实现的 API 实现。具体技术的话,与 Agent 通信使用 grpc 实现,其他功能则是通过 SPI 技术实现,我们把这一层叫做接缝层(Seam)。这样设计最大层度的解耦了核心业务逻辑和其他功能的特定实现,我们在保持接缝层 API 不变的情况下,可以自由选择技术方案实现相应的功能。比如数据服务这块强依赖了有赞的大数据平台,假设我们开源了 Maxim,外部团队就可以选择他们自己的技术方案实现数据服务,或者为了测试目的 Mock 掉。</p>
<h2>4、改造 Gatling</h2>
<p>原生 Gatling 是将压测日志写入本地日志文件的,而在分布式中,如果每个压力注入器都把日志写在本地,则为了基于所有日志分析生成压测报告,我们需要首先收集分散在各个压力注入器中的日志文件,这样显然是低效的。所以我们改造了 Gatling ,将所有日志都写到同一个 InfluxDB 数据库。需要生成压测报告时,控制中心从 InfluxDB 数据库读入本次压测任务的所有压测日志并保存为一个日志文件,再交由 Gatling 的日志处理模块来生成压测报告。</p>
<h2>5、扩展 Gatling</h2>
<p>原生 Gatling 不支持 Dubbo 压测,所以我们扩展 Gatling,实现并开源了 <a href="https://link.segmentfault.com/?enc=FrI2cY9o0FpUtvI%2BWfIMvQ%3D%3D.RkAxC8xCPp6HaoxlfW8nJWovfWvzo%2B%2F3MegEK4KtyknJxBOgESdY3SUNjdu%2BTQBb" rel="nofollow">gatling-dubbo</a>压测插件,具体实现方法详见 <a href="https://link.segmentfault.com/?enc=%2FkEJ4xQrNFZwkFdcnZIN8A%3D%3D.c%2BYarFiARhUMAi3EIiK0ZMoWrAeYQ%2BgAn8xYHucxf2lrJAOGTjgZwusRuctwzbBX" rel="nofollow">Dubbo压测插件的实现——基于Gatling</a></p>
<h2>6、Maxim 的未来展望</h2>
<p>Maxim 目前还是个单打独斗的产品,未来我们希望与大数据平台、运维平台等系统打通,让 Maxim 逐渐进化为一个一站式的压测平台,并引入更多新特性,如压测过程和压测报告的实时计算和展示等等。</p>
<p><strong>我的系列博客</strong> <br><a href="https://link.segmentfault.com/?enc=VDWvJCgGI2NOX5whaHpWVg%3D%3D.Ih3unYo2sCE8iP%2BtoK3yWsR1M4sPtN%2FwEDvYsAIkxYsCWP%2F4M5XeEa7cAKfYLPsn" rel="nofollow">混沌工程 - 软件系统高可用、弹性化的必由之路</a> <br><a href="https://link.segmentfault.com/?enc=pUxebEvMDOJuYDk%2Fb%2BFt9Q%3D%3D.YRoSbpEjIO3603h3v5rx5A1dkObVdIS1ITHKJY%2BEzXU1epM%2BAzBWVQ47GriT8GMo" rel="nofollow">异步系统的两种测试方法</a> <br><a href="https://link.segmentfault.com/?enc=xoJ9b56L%2FLE4Ux8L5FzPPw%3D%3D.%2B9U7tBRkEssQ%2BXmkszxGnepSga5zlH6WmdzTkBIBl1mv2hFn40L2c3AesdSR80U0" rel="nofollow">Dubbo压测插件的实现——基于Gatling</a></p>
<p><strong>我的其他测试相关开源项目</strong> <br><a href="https://link.segmentfault.com/?enc=fXs%2F8%2FMGX2Bv96CPDZZgJQ%3D%3D.vbLLp7vol4F2UGuy28rz%2BStyDL91THT70XnlFBckK63HHxqemIM5QMKwi6IGW%2F4h" rel="nofollow">捉虫记:方便产品、开发、测试三方协同自测的管理工具</a> <br><a href="https://link.segmentfault.com/?enc=om04zlPNNsRT5CXCfjTu8Q%3D%3D.ifn%2FJ62MwDvI%2BaLZHLDH0pNzhsUrD6ikQr%2FllngmUaKYBJmby%2B45CMAq79CGyxoO" rel="nofollow">gatling-dubbo:扩展自Gatling的Dubbo性能测试插件</a></p>
<p><strong>招聘</strong> <br>有赞测试组在持续招人中,大量岗位空缺,只要你来,就能帮你点亮全栈开发技能树,有意向换工作的同学可以发简历到 sunjun【@】youzan.com</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
有赞全链路压测实战
https://segmentfault.com/a/1190000017402376
2018-12-17T15:22:38+08:00
2018-12-17T15:22:38+08:00
有赞技术
https://segmentfault.com/u/youzantech
23
<h2>一、前言</h2>
<p>有赞致力于成为商家服务领域里最被信任的引领者,因为被信任,所有我们更需要为商家保驾护航,保障系统的稳定性。有赞从去年开始通过全链路压测,模拟大促真实流量,串联线上全部系统,让核心系统同时达到流量峰值:</p>
<ul>
<li>验证大促峰值流量下系统稳定性</li>
<li>容量规划</li>
<li>进行强弱依赖的划分</li>
<li>降级、报警、容灾、限流等演练</li>
<li>...</li>
</ul>
<p>通过全链路压测这一手段,对线上系统进行最真实的大促演练,获取系统在大压力时的表现情况,进而准确评估线上整个系统集群的性能和容量水平,不辜负百万商家的信任。</p>
<p>有赞对于性能测试主要有线下单系统单接口、线上单系统以及线上全链路压测等手段,通过不同维度和颗粒度对接口、系统、集群层面进行性能测试,最终保障系统的稳定性。这里主要讲述一下,有赞全链路压测的相关设计和具体的实施。</p>
<h2>二、整体设计</h2>
<p>说到全链路压测,业内的淘宝、京东都都已有很成熟的技术,主要就是压测流量的制造、压测数据的构造、压测流量的识别以及压测数据流向的处理;直接看下有赞压测的整体设计:</p>
<p><img src="/img/bVbla6o?w=999&h=775" alt="图片描述" title="图片描述"></p>
<ul>
<li>大流量下发器:其实就是模拟海量的用户去使用我们的系统,提供压测的流量,产生大促时的场景和流量;</li>
<li>数据工厂:构造压测链路中用户请求的数据,以及压测铺底的数据、数据清洗、脱敏等工作;</li>
</ul>
<p>压测平台负责管理压测脚本和压测请求数据,从数据工厂获取压测数据集,分发到每一个压测 agent 机器上,agent 根据压测脚本和压力目标对线上机器发起请求流量,模拟用户的查看商品-添加购物车-下单-支付等行为,线上服务集群识别出压测的流量,对于存储的读写走影子存储。这里就要说到,线上压测很重要的一点就是不能污染线上的数据,不能让买卖家有感知,比如压测后,卖家发现多了好多订单,买家发现钱少了。所以,线上服务需要能够隔离出压测的流量,存储也需要识别出压测的数据,下面看一下有赞的压测流量和压测数据存储的隔离方案。</p>
<h2>三、流量识别</h2>
<p>要想压测的流量和数据不影响线上真实的生产数据,就需要线上的集群能识别出压测的流量,只要能识别出压测请求的流量,那么流量触发的读写操作就很好统一去做隔离了,先看一下有赞压测流量的识别:</p>
<h3>3.1 同步请求</h3>
<p><img src="/img/bVbla6D?w=1544&h=634" alt="图片描述" title="图片描述"></p>
<p>全链路压测发起的都是Http的请求,只需要要请求头上添加统一的压测请求头,具体表现形式为:<br>Header Name:X-Service-Chain;<br>Header Value:{"zan_test": true}</p>
<p>Dubbo协议的服务调用,通过隐式参数在服务消费方和提供方进行参数的隐式传递,表现形式为:<br>Attachments:X-Service-Chain;<br>Attachments Value:{"zan_test": true}</p>
<p>通过在请求协议中添加压测请求的标识,在不同服务的相互调用时,一路透传下去,这样每一个服务都能识别出压测的请求流量,这样做的好处是与业务完全的解耦,只需要应用框架进行感知,对业务方代码无侵入</p>
<h3>3.2 中间件</h3>
<ul>
<li>NSQ:通过 NSQMessage 中添加 jsonExtHeader 的 KV 拓展信息,让消息可以从 Producer 透传到 Consumer 端,具体表现形式为:Key:zan_test;Value:true</li>
<li>Wagon:对来自影子库的 binlog 通过拓展消息命令(PUB_EXT)带上压测标记{"zan_test": true}</li>
</ul>
<h3>3.3 异步线程</h3>
<p>异步调用标识透传问题,可以自行定制 Runnable 或者定制线程池再或者业务方自行定制实现。</p>
<h2>四、数据隔离</h2>
<p>通过流量识别的改造,各个服务都已经能够识别出压测的请求流量了,那么如何做到压测数据不污染线上数据,对于不同的存储做到压测数据和真实的隔离呢,有赞主要有客户端 Client 和 Proxy 访问代理的方式实现,下面就看一下有赞的数据隔离方案:</p>
<h3>4.1 Proxy 访问代理隔离</h3>
<p>针对业务方和数据存储服务间已有Proxy代理的情况,可以直接升级 Proxy 层,存储使用方完全无感知,无侵入,下面以 MySQL 为例,说明 Proxy 访问代理对于压测数据隔离的方案;</p>
<p><img src="/img/bVbla6Z?w=1686&h=912" alt="图片描述" title="图片描述"></p>
<p>业务方应用读写DB时,统一与 RDS-Proxy (介于 MySQL 服务器与 MySQLClient 之间的中间件)交互,调用 RDS-Proxy 时会透传压测的标记,RDS 识别出压测请求后,读写 DB 表时,自动替换成对应的影子表,达到压测数据和真实的生产数据隔离的目的</p>
<p>ElasticSearch、KV 对于压测的支持也是通过 Proxy 访问代理的方式实现的</p>
<h3>4.2 客户端SDK隔离</h3>
<p><img src="/img/bVblbah?w=994&h=536" alt="图片描述" title="图片描述"></p>
<p>业务应用通过Client调用存储服务时,Client 会识别出压测的流量,将需要读写的 Table 自动替换为影子表,这样就可以达到影子流量,读写到影子存储的目的;</p>
<p>Hbase、Redis 等采用此方案实现</p>
<h3>4.3 数据偏移隔离</h3>
<p>推动框架、中间件升级、业务方改造,难免会有遗漏之处,所以有赞对于压测的数据统一做了偏移,确保买卖家 ID 与线上已有数据隔离开,这样即使压测数据由于某种原因写入了真实的生产库,也不会影响到线上买卖家相关的数据,让用户无感知;</p>
<p>这里要说一下特殊的周期扫表应用的改造,周期性扫表应用由于没有外部触发,所有无法扫描影子表的数据,如何让这样的 job 集群整体来看既扫描生产库,也扫描影子库呢?<br>有赞的解决思路是,部署一些新的 job 实例,它们只扫描影子库,消息处理逻辑不变;老的 job 实例保持不变(只扫生产库)</p>
<h2>五、压测平台</h2>
<p>有赞的全链路压测平台目前主要负责压测脚本管理、压测数据集管理、压测 job 管理和调度等,后续会有重点介绍,这里不做深入</p>
<p>压测的“硬件”设施基本已经齐全,下面介绍一下有赞全链路压测的具体实施流程吧</p>
<h2>六、压测实施流程</h2>
<p>废话不多说,直接上图:</p>
<p><img src="/img/bVblbam?w=1011&h=827" alt="图片描述" title="图片描述"></p>
<p>有赞全链路压测的执行流程如上图所示,下面具体看一下几个核心步骤在有赞是怎么做的。</p>
<h3>6.1 压测计划制定</h3>
<p>要想模拟大促时线上真实的流量情况,首先需要确认的就是压测场景、链路,压测的目录,以及压测的流量漏斗模型:</p>
<p><img src="/img/bVblbar?w=936&h=792" alt="图片描述" title="图片描述"></p>
<ul>
<li>压测的链路:根据公司具体业务具体分析,有赞属于电商类型公司,大促时候的峰值流量基本都是由于买家的购买行为导致的,从宏观上看,这样的购买行为主要是店铺首页-商品详情页-下单-支付-支付成功,我们把这一条骨干的链路称为核心链路,其实大促时主要就是保证核心链路的稳定性</li>
<li>压测链路的漏斗模型:线上真实的场景案例是,100个人进入了商家的店铺首页,可能有50个人直接退出了,有50个人最终点击进入了商品详情页面,最终只有10个人下了单,5个人真正付款了,这就是压测链路的漏斗模型,也就是不同的接口的真实调用比例;实际的模型制定会根据近7天线上真实用户的行为数据分型分析建模,以及往期同类型活动线上的流量分布情况,构建压测链路的漏斗模型</li>
</ul>
<p><img src="/img/bVblbat?w=1262&h=857" alt="图片描述" title="图片描述"></p>
<ul><li>压测的场景:根据运营报备的商家大促活动的计划,制定大促的压测场景(比如秒杀、抽奖等),再结合近七天线上流量的场景情况,综合确定压测的场景;</li></ul>
<p><img src="/img/bVblbiy?w=2358&h=1022" alt="图片描述" title="图片描述"></p>
<ul><li>压测目标:根据运营报备的商家大促预估的PV和转换率情况,结合去年同期线上流量情况和公司业务的增长速率,取大值作为压测的目标</li></ul>
<h3>6.2 数据工厂</h3>
<p>前面我们已经介绍了如何确定压测的目标、场景、链路,那么压测的数据怎么来尼,这就是数据工厂登场的时候了,下面就介绍一下有赞压测的数据工厂</p>
<p>有赞的压测数据工厂主要负责,压测铺底数据的准备、压测请求数据块的生成;</p>
<h3>6.3 铺底数据准备</h3>
<p>压测准备铺底的数据,这个众所周知的,但是由于有赞压测的方案采用的是影子库的设计,所以对于铺底数据准备不得不去处理影子库的数据。直接看下铺底数据准备的流程图:</p>
<p><img src="/img/bVblber?w=926&h=604" alt="图片描述" title="图片描述"></p>
<ul><li>数据导入</li></ul>
<p>从生产数据库按需过滤,导入压测铺底需要的数据到大数据集群的hive表中。</p>
<ul><li>数据处理</li></ul>
<p>在 hive 表中,对导入的数据进行脱敏和清洗,清洗的目的是保证压测的数据可用性,比如保证铺底商品库存最大、营销活动时间无限、店铺正常营业等。</p>
<ul><li>数据导出</li></ul>
<p>对 hive 标中已经处理完成的数据,导出到已经创建好的影子库中,需要注意的是同一实例写入数据的控制(因为影子库和生产库同实例),写入数据的 binlog 过滤。</p>
<p>有赞的压测数据准备目前全部在 DP 大数据平台上操作,基本完成了数据准备操作的自动化,维护了近千的数据准备 job</p>
<p><img src="/img/bVblbew?w=2412&h=1216" alt="图片描述" title="图片描述"></p>
<h6>压测请求数据数据集</h6>
<p>gatling 原生支持 json、csv、DB 等方式的数据源载入,我们采用的压测数据源是 json 格式的,那么如此海量的压测源数据,是通过什么方式生成和存储的尼,我们的实现还是依托于 DP 大数据平台,通过 MapReduce 任务的方式,按需生成我们压测请求需要的数据集:</p>
<p><img src="/img/bVblbeH?w=1788&h=866" alt="图片描述" title="图片描述"></p>
<ul>
<li>从各个业务线的表中获取压测场景整个链路所以接口请求需要的参数字段,存到一张创建好的压测数据源宽表中</li>
<li>编写 MapReduce 任务代码,读取压测数据源宽表数据,按压测的接口请求参数情况,生成目标 json 格式的压测请求数据块文件到 HDFS</li>
<li>压测时,压测引擎自动从 HDFS 上拉取压测的请求数据块</li>
</ul>
<p>MapReduce 生成的数据集 json 示例:<br><img src="/img/bVblbeO?w=1068&h=804" alt="图片描述" title="图片描述"></p>
<h3>6.4 压测脚本准备</h3>
<h4>6.4.1 梳理压测请求和参数</h4>
<p><img src="/img/bVblbeZ?w=956&h=892" alt="图片描述" title="图片描述"></p>
<p>压测就要知道压测的具体接口和接口参数,所以我们采用统一的 RESTful 风格规范,让各个业务方的人员提交压测接口的 API 文档,这样压测脚本编写人员就能根据这份 API 快速写出压测的脚本,以及接口的预期结果等</p>
<h4>6.4.2 控制漏斗转化率</h4>
<p>有赞的压测引擎用的是公司二次封装的 gatling,原生就支持漏斗比例的控制,直接看例子</p>
<p><img src="/img/bVblbfD?w=1082&h=494" alt="图片描述" title="图片描述"></p>
<h4>6.4.3 不同场景的配比</h4>
<pre><code>setUp(
scn0.inject(constantUsersPerSec(10) during (1 minute)).throttle(
reachRps(300) in (30 seconds),
holdFor(2 minute)).protocols(CustomHttpProtocol.httpProtocol),
scn1.inject(constantUsersPerSec(10) during (1 minute)).throttle(
reachRps(500) in (10 seconds),
holdFor(3 minute)).protocols(CustomHttpProtocol.httpProtocol),
scn2.inject(constantUsersPerSec(10) during (1 minute)).throttle(
reachRps(200) in (20 seconds),
holdFor(1 minute)).protocols(CustomHttpProtocol.httpProtocol)
)</code></pre>
<p>对不同的压测场景链路按模块编写压测脚本,这样的好处就是需要不同场景混合压测时,只需要在 setUp 时,按需把不同的场景组合到一起即可;需要单独压测某一个场景时,setUp 中只留一个场景就好,做到一次编写,处处可压。</p>
<h3>6.5 压测执行</h3>
<p>压测的铺底数据、压测脚本、压测请求的数据集都已经介绍完了,可谓是万事俱备只欠东风,那这个东风就是我们自建的压测平台 maxim,这里不对压测平台的设计展开介绍,展示一下 maxim 在压测执行过程中所承担的工作。<br><img src="/img/bVblbfR?w=1582&h=240" alt="图片描述" title="图片描述"></p>
<p>maxim平台主要的功能模块有:</p>
<ul>
<li>测试脚本:负责测试脚本的管理</li>
<li>数据集:负责压测请求数据集的管理,目前主要有两种数据集上传模式:直接上传数据包和 hadoop 集群数据源路径上传。第二种上传模式,只需要填写数据源所在的 hadoop 集群的路径,maxim 平台会自动去所在路径获取压测数据集文件</li>
<li>测试 job:负责测试任务的管理,指定压测 job 的脚本和脚本入口,以及压测数据集等</li>
<li>压测注入器:负责展示压测注入机器的相关信息</li>
<li>压测报告生成:压测报告的生成,直接用的 gatling 原生的报告生成功能</li>
</ul>
<p>maxim 平台压测结果报告</p>
<p><img src="/img/bVblbfZ?w=2448&h=1144" alt="图片描述" title="图片描述"></p>
<p>下面看一下压测执行的页面功能:<br><img src="/img/bVblbjn?w=2092&h=1328" alt="图片描述" title="图片描述"></p>
<ul>
<li>压力注入器数量:指定本次压测执行,需要多少台压测机去执行</li>
<li>重复场景测试:一个虚拟用户重复几次压测场景</li>
<li>并发用户数:可执行压测时,按需填写需要的每秒加载的并发用户数和持续时间,无需每次变更压测脚本</li>
<li>目标 RPS:可执行压测时,按需填写压测的目标 RPS,爬坡时间,目标持续时间,达到限流的作用,可同时指定多个目标 RPS,达到分梯度压测的目的;</li>
</ul>
<h2>七、最后</h2>
<p>到这里有赞全链路压测方案已经介绍完了,因为篇幅的原因还有很多实施细节部分并没有完整表述,同时有赞的全链路压测也才初具雏形,欢迎有兴趣的同学联系我们一起探讨,有表述错误的地方也欢迎大家联系我们纠正。</p>
有赞订单导出的配置化实践
https://segmentfault.com/a/1190000017350583
2018-12-12T15:55:40+08:00
2018-12-12T15:55:40+08:00
有赞技术
https://segmentfault.com/u/youzantech
9
<h2>一、引子</h2>
<h3>1.1 背景</h3>
<p>有赞订单导出业务隶属于有赞交易订单管理组,主要职能是将有赞商家的订单数据通过报表的形式导出并提供下载给商家使用。目前承接了有赞所有的订单导出业务,报表的字段覆盖交易、支付、会员、优惠、发货、退款、特定业务等,合计超过 100 个。</p>
<h3>1.2 挑战</h3>
<p>随着有赞的迅速发展,有赞的行业、业务与产品覆盖面越来越广。从行业角度来看,覆盖了微商城、新零售、餐饮、美业、教育等,从模块角度来看,覆盖了交易、资产、客户、营销、店铺等,从产品角度来看,覆盖了分销、精选等。每个行业、模块、产品都会在订单导出报表中有所诉求。如下图所示,展示了有赞订单导出的域模型:</p>
<p><img src="/img/bVbkXOQ?w=1266&h=1102" alt="图片描述" title="图片描述"></p>
<p>订单导出需要跨越来自不同行业、不同产品、不同模块,对各个业务域的存储和设计有整体理解;同时,需要通过技术手段(数据域、存储域、报表域、文件域)聚合来自各个域的数据集合,生成可读的报表下载给商家。</p>
<p>由此可见,其主要的挑战是:如何快速支持各个域灵活多变的导出字段需求。如何应对这一挑战呢?</p>
<h2>二、 架构重构</h2>
<p>订单导出的最初实现是从交易的多个 DB 及多个业务 API,分别获取交易、支付、会员、发货、退款、核销、分销等多个数据,组装到一起生成报表。采用 PHP 任务脚本来实现。这种做法有两个痛点:</p>
<ul>
<li>在小量订单导出的情形尚能应付,一旦同时有多个数万订单导出任务时,资源占用非常大,CPU 基本被打满,PHP 导出进程被阻塞,从而阻塞了所有的订单导出,导出就无法提供服务了。</li>
<li>直接访问业务数据库存在一种潜在风险:如果访问业务数据库的数据量很大,SQL 编写不当导致慢查,往往会给业务数据库带来访问压力,严重影响正常核心业务流程。</li>
</ul>
<p>基于这两个痛点,有赞订单管理组进行了架构升级,详见有赞技术博文<a href="https://link.segmentfault.com/?enc=OptyotNRPgmWNFkk%2F5NWBw%3D%3D.3zYp4h3oJsTlOHfR0Z5FmfhnDKAH2p4QYp8Q9Y0bu09zkWteQv6AMNspMXbiOysT" rel="nofollow">《有赞订单管理的三生三世与“十面埋伏》</a>。 得益于此,订单导出也迁移到基于 ES + Hbase 的技术栈。其中订单搜索采用 ES 服务实现,订单详情则存储在 Hbase 中,通过 API 来获取。整体流程如下所示:</p>
<p><img src="/img/bVbkXOW?w=1566&h=1242" alt="图片描述" title="图片描述"></p>
<p>重构之后,订单导出的性能和稳定性有了很大的提升:</p>
<ul>
<li>支持百万级订单的导出,且导出的速度比之前大幅提升。以前导出几万订单慢且易阻塞,现在平均能导出 1w/1min,大多数导出可在十几秒内完成。</li>
<li>ES 和 Hbase 具有天然的弹性和容量扩展性,即使总订单量有数量级的增长,导出的速度和稳定性也不受影响。</li>
<li>摆脱了容易被阻塞的困境,不再直接访问业务数据库,关闭了导出对核心业务流程的潜在威胁。</li>
</ul>
<p>接下来,开始了配置化之旅。</p>
<h2>三、配置之旅</h2>
<h3>3.1 初尝配置:设下伏笔</h3>
<p>订单导出常常要面临添加新的报表字段的需求。最初实现不太灵活,是来一个字段,在代码流程里添加一个字段。每次增加新的字段,都需要修改多处。因此,第一个优化是采用函数接口编程,将字段定义做成枚举可配置化的,然后遍历指定的报表字段列表,拿到对应的字段定义,计算字段的值,写入报表文件。 </p>
<p>根据报表字段列表生成报表行的伪代码如下:</p>
<pre><code class="java">public List<String> generateReportLineData(List<String> fields) {
return StreamUtil.map(fields, field -> {
try {
FieldDefinition fieldDef = getFieldDefinition(field);
FieldMethod method = getMethod(fieldDef);
String value = method.invoke(this.reportItem);
return postproc(value);
}catch (Exception e){
logger.warn("failed to get value for field: {} orderNo: {}", field, reportItem.getOrderNo());
return "";
}
});
}</code></pre>
<p>这个小小的优化,为进一步的配置化设下伏笔。当需要新增报表字段时,只要增加新的字段定义,而不需要在流程里增加代码。增强软件可扩展性的一个重要方法是,将流程变得通用,只要增删流程里的环节及定义即可。</p>
<blockquote>凡基础必要总是正确的方向。</blockquote>
<h3>3.2 报表配置:破局之时</h3>
<p>有赞新零售、餐饮的迅速兴起和发展,需要低成本快速地搭建起零售和餐饮的订单导出。这要求订单导出具有更大的灵活性,能够根据不同行业的要求配置不同的字段列表及导出格式,同时又能互不影响。此外,不同商家有个性化的导出需求。然而,原来的订单导出,是专门为微商城开发的商品级别的报表。要加一个字段,往往会影响所有的有赞商家,使用体验不佳,订单报表本身也变得臃肿不堪。</p>
<p>如何突破原来的局限,支持更灵活的订单导出呢?这是订单导出面临的一个破局点。通过订单导出模板解决了这个问题。针对行业、产品配置的导出模板存储在 DB 表 export_biz_conf 里;针对有赞商家的导出模板存储在 DB 表 export_customized_conf 里。每个导出模板包括了如下信息:报表字段列表、导出维度(订单及商品)、报表文件格式、可选项等,做到足够灵活。</p>
<p>若要导出不同报表字段,只要新增相应字段,指定报表字段列表即可;若要生成不同维度的报表,可使用策略模式。比如,</p>
<ul>
<li>导出大订单量,采用批量并发策略更高效;导出小订单量,采用串行策略更易理解;</li>
<li>可以把字段定义写在本地代码里直接引用,或者配置在 Groovy 脚本里更加灵活;</li>
<li>可以根据指定的订单级别或商品级别进行维度聚合,然后计算报表字段的值;</li>
<li>可以根据指定的 csv 或 excel 生成相应的文件。</li>
</ul>
<p>如图所示: 针对导出流程的各环节,可采用策略模式来选择不同实现,然后将策略组合起来。</p>
<p><img src="/img/bVbkXO5?w=1708&h=626" alt="图片描述" title="图片描述"></p>
<p>通过实现报表配置功能,突破了之前的局限,可以支持不同行业、产品的标准化和定制化导出需求,并且做到相互隔离不干扰。</p>
<h3>3.3 配置深化:更快更稳</h3>
<p>随着有赞进入更多的行业,面临着更加多变和个性化的导出需求。比如,有赞教育需要导出知识订单的学员信息和课程信息,有赞零售需要导出导购员和发货仓库门店名称。 显然,如果要完成某个导出需求,还需要修改代码、发布系统,这种操作会非常频繁,导致开发和维护成本提升,影响系统稳定性。</p>
<p>如果能够在应用运行中动态地新增报表字段并加载和使用,无需修改导出工程代码,无需重新发布系统,就能更加快速地支持导出需求,将会大幅降低导出需求支持的开发和维护成本,保持系统稳定性。</p>
<p>为了解决这个问题,引入了动态脚本语言 Groovy. Groovy 是能够与 Java 无缝对接的好伙伴,可以直接使用 Java 类的功能。编写 Groovy 脚本实现报表字段逻辑,存储在字段配置表 export_field_conf 里, 在报表配置表 export_biz_conf 或 export_customized_conf 里引用,然后在应用启动时缓存到内存里并使用。比如粉丝姓名的 Groovy 脚本如下:</p>
<pre><code>import com.youzan.trade.orderexport.util.PublicUtil
def fansInfo = reportItem.orderInfo.extra["FANS"]
PublicUtil.fetch(fansInfo, "nickname")
</code></pre>
<p>PublicUtil 是导出工程里封装的一个工具类,可以让编写字段配置脚本更加简单。值得提及的是,为了避免使用 Groovy 脚本可能导致的内存泄露,需要对编译后的 Groovy 脚本进行缓存和执行。</p>
<p>为了实现无需改动代码和发布系统,还需要在整体流程上打通。整体流程如下:</p>
<p>Step1: 当用户下单后,源数据落到业务数据库的扩展信息里; <br>Step2: 通过数据同步,自动同步到 Hbase 表; <br>Step3: 通过 Apollo 配置和可扩展的数据聚合机制,将数据自动输送到用来计算报表字段值的报表对象里; <br>Step4: 新增报表字段的配置; <br>Step5: 在报表配置中引用该字段的标识。</p>
<p>下图展示了通过配置自定义字段快速支持导出需求的整体流程。</p>
<p><img src="/img/bVbkXPa?w=1868&h=830" alt="图片描述" title="图片描述"></p>
<p>整体流程打通后,当需要新增个性化字段时,通常只要做两步:</p>
<ol>
<li>增加个性化字段的配置,包括 Groovy 脚本;</li>
<li>测试通过后,刷新应用的配置即可。</li>
</ol>
<p>个性化字段配置能力已经在线上稳定运行,比如拼团订单成团时间、零售导购员、有赞教育的课程字段等。</p>
<h3>3.4 通用导出:锦上添花</h3>
<p>紧接着,订单导出又面临分销采购单的导出需求。分销采购单导出流程跟订单导出有所不同,需要分别导出分销买家订单和供货订单的详情信息再导出。这个流程跟通用的订单导出流程是有所区别的。如果通过修改订单导出的通用流程来支持,显然会影响所有的订单导出,使订单导出流程不清晰。</p>
<p>最终采用的解决方案是:对分销采购单的导出需求和所需技术进行抽象,实现一个更加通用的导出能力模型,支持交易领域的各种潜在导出需求,而不仅仅局限于分销采购单导出。通过分析订单导出流程可以发现,绝大多数导出都遵循如下核心流程:</p>
<p><img src="/img/bVbkXPd?w=1078&h=1176" alt="图片描述" title="图片描述"></p>
<p>可以将核心流程做成插件式的。首先,定义一个插件接口,包含其配置和功能等;其次,实现常用的插件列表,支持从 ES, HBase, API 查询或获取数据,以及常用的过滤、排序、格式化、生成报表等功能;最后,将这些插件列表串联成一个具体的导出实例。整体流程则采用模板方法模式复用了订单导出流程。</p>
<p>比如微商城分销采购单导出通过依次执行ES查询插件、订单详情插件、数据排序插件、报表字段格式化插件、报表生成插件来实现,其中订单详情插件针对分销买家单和供货订单分别调用了一次。</p>
<h2>四、质量保障</h2>
<p>前面提到,订单导出的报表字段非常多,导出数据量大,如何保证代码改动或重构后订单导出的服务质量和数据准确性?主要手段如下: </p>
<p><img src="/img/bVbkXPt?w=1500&h=332" alt="图片描述" title="图片描述"></p>
<ul>
<li>质量流程保障是第一位的。最主要的三项是:单测严格全部通过; CodeReview 由应用责任人及经验丰富的高级工程师同时通过;预发线上导出对比工具通过。</li>
<li>整体架构设计保证了订单导出的性能、稳定性和可扩展性。</li>
<li>持续小幅重构使得系统能够持续优化,避免一次性大改造伤筋动骨且容易导致线上故障。</li>
<li>设计先行,对代码质量非常重视。</li>
<li>运行预发线上订单导出自动化对比工具,很大程度上增强了成功发布的信心,是发布前保障质量的一道重要防线。</li>
</ul>
<p>此外,采用函数编程及设计模式,使代码实现层面更具复用性和柔软性。18K 行代码,代码重复率约为 1.8%。</p>
<h2>五、小结与致谢</h2>
<p>本文简要讲述了有赞订单导出的配置化实践。通过配置化之后,订单导出的能力和稳定性有了大幅提升。当然,还有一些需要提升的地方。比如,可以增加扩展点机制,允许业务方定制化导出;局部细节可以打磨得更细腻。欢迎对海量订单业务感兴趣有经验的小伙伴与我们一起共建订单管理大局!简历可直邮 shuqin@youzan.com.</p>
<p>在这个过程中,有许多小伙伴给予了有力的支持,比如产品同学对订单报表的细致的规划设计、客满运营同学提出的及时反馈、有赞技术团队的支持以及自己的付出。</p>
有赞零售小票打印跨平台解决方案
https://segmentfault.com/a/1190000017321551
2018-12-10T14:41:52+08:00
2018-12-10T14:41:52+08:00
有赞技术
https://segmentfault.com/u/youzantech
22
<blockquote>作者:王前、林昊(鱼干)</blockquote>
<h3>一、背景</h3>
<p>零售商家的日常经营中,小票打印的场景无处不在,顾客的每笔消费都会收到商家打印出的消费小票,这个是顾客的消费凭证,所以小票的内容对顾客和商家都尤为重要。对于有赞零售应用软件来说,小票打印功能也是必不可少的,诸多业务场景都需要提供相应的小票打印能力。</p>
<ul><li>打印需求端</li></ul>
<p><img src="/img/bVbkQf4?w=450&h=260" alt="图片描述" title="图片描述"></p>
<ul><li>小票业务场景</li></ul>
<p><img src="/img/bVbkQgc?w=1333&h=365" alt="图片描述" title="图片描述"></p>
<ul><li>小票打印机设备类型</li></ul>
<p><img src="/img/bVbkQge?w=1680&h=579" alt="图片描述" title="图片描述"></p>
<p>过去我们存在的痛点:</p>
<ol>
<li>每个端各自实现一套打印流程,方案不统一。导致每次修改都会三端修改,而且 iOS 和 Android 必须依赖发版才可上线,不具有动态性,而且研发效率比较低。</li>
<li>打印小票的业务场景比较多,每个业务都自己实现模板封装及打印逻辑,模板及逻辑不统一,维护成本大。</li>
<li>多种小票设备的适配,对于每个端来说都要适配一遍。</li>
</ol>
<p><font color=red>其中最主要的痛点还是在于第一点,多端的不统一问题。由于不统一,导致开发和维护的成本成倍级增长。</font></p>
<p>针对以上痛点,小票打印技术方案需要解决的三个主要问题:</p>
<ol>
<li>iOS 、安卓和网页端的零售软件都需要提供小票样式设置和打印的能力,如何降低小票打印代码的维护和更新成本。</li>
<li>如何定制显示不同业务场景的小票内容:不同业务场景下的小票信息都不尽相同,比如购物小票和退款小票,商品信息的样式是一样的,但是支付信息是不一样的,购物小票应当显示顾客的支付信息,退款小票显示商家退款信息。</li>
<li>如何更灵活的适配多种多样的小票打印机,从连接方式上分为蓝牙连接和 WIFI 连接,从纸张样式分为 80mm 和 58mm 两种宽度。</li>
</ol>
<h3>二、整体解决方案</h3>
<p>针对以上三个问题,我们提出了一个涉及前端、移动端和服务端的跨平台解决方案,</p>
<ul><li>架构图</li></ul>
<p><img src="/img/bVbkQgo?w=1219&h=819" alt="图片描述" title="图片描述"></p>
<p>架构设计的核心在于通过 JS 实现支持跨平台的小票解析脚本,并具有动态更新的优势;通过服务端下发可编辑的样式模板实现小票内容的灵活定制;客户端启动 JS 执行器执行 JS 小票脚本引擎(以下简称:JS 引擎)并负责打印机设备的连接管理。</p>
<h4>1 、JS 引擎设计</h4>
<p>JS 引擎主要能力就是处理小票模版和业务数据,将业务数据整合到模版中(处理不了的交给移动端处理,比如图片),然后将整合模版数据转换成打印指令返给移动端。</p>
<ul><li>整体处理流程图</li></ul>
<p><img src="/img/bVbkQgr?w=370&h=637" alt="图片描述" title="图片描述"></p>
<ul><li>结构设计</li></ul>
<p><img src="/img/bVbkQgz?w=923&h=941" alt="图片描述" title="图片描述"></p>
<pre><code>* 小票格式中,打印机是一行一行的输出。那么基本输出布局单位,我们定义为 layout
* 默认一行有一个内容块,即一个 layout 里面有一个 content object
* 当一行有多列内容的时候,即一个 layout 里面包含 N 个 content object 。 各自内容块有 pagerWeight 代表每个内容的宽度占比
* 每一行的后面的是一个占位符,用数据模型的 key 做占位
</code></pre>
<p>小票 layout 样式描述:</p>
<p><img src="/img/bVbkQgN?w=770&h=159" alt="图片描述" title="图片描述"></p>
<p>content block 内容块:</p>
<p><img src="/img/bVbkQgR?w=874&h=1066" alt="图片描述" title="图片描述"></p>
<p>不同类型内容所支持的能力:</p>
<p><img src="/img/bVbkQgV?w=864&h=311" alt="图片描述" title="图片描述"></p>
<ul><li>模版编译</li></ul>
<p>这里使用了 <a href="https://link.segmentfault.com/?enc=mTESV13BqoMZM3tlu9Ofiw%3D%3D.xNJK7zkwTnGHW9ZSo57OCH4ihJTfp90C57h3dcJ8ZtA%3D" rel="nofollow">HandleBars.js</a> 作为模板编译的库。此外,目前还额外提供了部分能力支持。</p>
<p>自定义能力:</p>
<p><img src="/img/bVbkQgZ?w=871&h=443" alt="图片描述" title="图片描述"></p>
<ul><li>打印机设备适配</li></ul>
<p>主要进行适配指令集解析适配,根据连接不同设备进行不同指令解析。目前已适配设备:365wifi 、 sunmi 、 sprt80 、 sprt58 、 wangpos 、 aclas 、 xprinter 。如果连接未适配的设备抛出找不到相应打印机解析器 error。</p>
<ul><li>调用对应打印机的 parser 指令解析流程</li></ul>
<p><img src="/img/bVbkQg3?w=468&h=681" alt="图片描述" title="图片描述"></p>
<ul>
<li>
<p>兼容性问题</p>
<ul>
<li>切纸:支持外部传入是否需要切纸,防止外部发送打印指令时加入切纸指令后重复切纸问题,默认加切纸指令。</li>
<li>一机多尺寸打印:存在一台打印机支持两种纸张打印( 80mm 、 58mm ),这时需要从外部传入打印尺寸,默认 80mm 。比如,sunmiT1 支持 80mm 和 58mm 打印,默认是 80mm 。</li>
</ul>
</li>
<li>
<p>容错处理</p>
<ul>
<li>由于模版解析有一定格式要求,所以一些特殊字符及转移字符存在数据中会存在解析错误。所以 JS 在传入数据时,做了一层过滤,将 "\\" 、 "n" 、 "b" ... 等字符去掉或替换,保证打印。</li>
<li>如果在解析过程中存在错误,将抛出异常给移动端捕获。</li>
</ul>
</li>
</ul>
<h4>2 、模板管理服务</h4>
<p>小票模板的动态编辑和下发,模版动态配置信息存储和各业务全量模版存储,提供移动端动态配置信息接口,拉取业务小票模版接口,各业务方业务数据接口。</p>
<ul><li>整体处理流程图</li></ul>
<p><img src="/img/bVbkQg7?w=462&h=391" alt="图片描述" title="图片描述"></p>
<ul><li>小票基础模版库存储示例</li></ul>
<p><img src="/img/bVbkQhd?w=875&h=113" alt="图片描述" title="图片描述"></p>
<p>shopId:店铺 ID</p>
<p>business:业务方</p>
<p>type:打印内容类型</p>
<p>content:layout 中 content 内容</p>
<p>sortWeight:排序比重,用于输出模板 layout 顺序</p>
<ul><li>动态设置数据存储示例</li></ul>
<p><img src="/img/bVbkQhg?w=487&h=86" alt="图片描述" title="图片描述"></p>
<p>shopId:店铺 ID</p>
<p>business:业务方</p>
<p>type:打印内容类型</p>
<p>params:需要替换填充的内容</p>
<ul><li>接口返回整合后的小票模版 json</li></ul>
<pre><code class="json">{
"business": "shopping",
"shopId": 111111,
"id": 321,
"version": 0,
"layouts": [{
"name": "LOGO",
"content": "[{\"content\":\"http://www.test.com/test.jpg\",\"contentType\":\"image\",\"textAlign\":\"center\",\"width\":45}]"
},{
"name": "电话",
"content": "[{\"content\":\"电话:{{mobile}}\",\"contentType\":\"text\",\"textAlign\":\"left\",\"fontSize\":\"default\",\"pagerWeight\":1}]"
},...]
}</code></pre>
<p>其中相关动态数据后端已经做过整合替换,需要替换的业务数据保留在模板 json 中,等获取业务数据后由 JS 引擎进行替换。<br>上面 json 中 <code>http://www.test.com/test.jpg</code> 就是动态整合替换数据,<code>{{mobile}}</code> 是一个需要替换的业务数据。</p>
<h4>3 、移动端</h4>
<p>移动端除了动态模版配置之外,主要的就是打印流程。移动端只需要关心需要打印什么业务小票,然后去后端拉取业务小票模版和业务数据,将拉取到的数据传给 JS 引擎进行预处理,返回模版中处理不了的图片 url 信息,然后移动端进行下载图片,进行二值转换,输出像素的 16 进制字符串,替换原来模版中的 url ,最后将连接的打印机类型和处理后的模版传给 JS 引擎进行打印指令转换返回给打印机打印。</p>
<ul><li>动态模版配置</li></ul>
<p><img src="/img/bVbkQhi?w=2048&h=1536" alt="图片描述" title="图片描述"></p>
<p>动态配置小票内容,支持 LOGO 、店铺数据、营销活动配置等。左侧为在 80mm 和 58mm 上预览样式。通过动态配置模版,实现后端接口模版更新,然后可以实时同步修改打印内容。网页零售软件上动态配置内容和移动端一样。</p>
<ul><li>打印业务流程</li></ul>
<p><img src="/img/bVbkQhk?w=456&h=867" alt="图片描述" title="图片描述"></p>
<p>该业务流程,移动端完全脱离数据,只需要做一些额外能力以及传输功能,有效解决了业务数据修改依赖移动端发版的问题。 Android 和 iOS 流程统一。</p>
<h3>三、移动端功能设计</h3>
<h4>1 、动态化</h4>
<p>动态化在本解决方案里是必不可少的一环,实时更新业务数据模板依赖于后端,但是 JS 解析引擎的下发要依靠移动端来实现,为了及时修复发现的 JS 问题或者快速适配新设备等功能。更新流程图如下:</p>
<p><img src="/img/bVbkQhs?w=426&h=492" alt="图片描述" title="图片描述"></p>
<p>这里说明一下,因为可能会出现执行 JS 的过程中,正在执行本地 JS 文件更新,导致执行 JS 出错。所以在完成本地更新后会发送一个通知,告知业务方 JS 已更新完成,这时业务方可根据自身需求做逻辑处理,比如重新加载 JS 进行处理业务。</p>
<h4>2 、JS 执行器</h4>
<p>iOS 使用 JavaScriptCore 框架,Android 使用 J2V8 框架,具体框架的介绍这里就不说明了。JS 执行器设计包含加载指定 JS 文件,调用 JS 方法,获取 JS 属性,JS 异常捕获。</p>
<pre><code class="Objective-C"> /**
初始化 JSExecutor
@param fileName JS 文件名
@return JSExecutor
*/
- (instancetype)initWithScriptFile:(NSString *)fileName;
/**
加载 JS 文件
@param fileName JS 文件名
*/
- (void)loadSriptFile:(NSString *)fileName;
/**
执行 JS 方法
@param functionName 方法名
@param args 入参
@return 方法返回值
*/
- (JSValue *)runJSFunction:(NSString *)functionName args:(NSArray *)args;
/**
获取 JS 属性
@param propertyName 属性名
@return 属性值
*/
- (JSValue *)getJSProperty:(NSString *)propertyName;
/**
JS 异常捕获
@param handler 异常捕获回调
*/
- (void)catchExceptionWithHandler:(JSExceptionHandler)handler;</code></pre>
<p>加载 JS 文件方法,可以加载动态下发的 JS 。逻辑是先判断本地下发的文件是否存在,如果存在就加载下发 JS ,否则加载 app 中 bundle 里面的 JS 文件。</p>
<pre><code class="Objective-C"> - (void)loadSriptFile:(NSString *)fileName{
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
if (paths.count > 0) {
NSString *docDir = [paths objectAtIndex:0];
NSString *docSourcePath = [docDir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.js", fileName]];
NSFileManager *fm = [NSFileManager defaultManager];
if ([fm fileExistsAtPath:docSourcePath]) {
NSString *jsString = [NSString stringWithContentsOfFile:docSourcePath encoding:NSUTF8StringEncoding error:nil];
[self.content evaluateScript:jsString];
return;
}
}
NSString *sourcePath = [[YZCommonBundle bundle] pathForResource:fileName ofType:@"js"];
NSAssert(sourcePath, @"can't find jscript file");
NSString *jsString = [NSString stringWithContentsOfFile:sourcePath encoding:NSUTF8StringEncoding error:nil];
[self.content evaluateScript:jsString];
}</code></pre>
<p>这时候可能会有人疑问,为什么这里是直接强制加载本地下发 JS ,而不是对比版本优先加载。这里主要有两点原因:</p>
<ul>
<li>动态下发 JS 文件,就是为了补丁或者优化更新,所以一般新版本下发配置不会存在</li>
<li>为了支持 JS 版本回滚</li>
</ul>
<p>JS 异常捕获功能,将异常抛出给业务方,可以让调用者各自实现逻辑处理。</p>
<h4>3 、缓存优化</h4>
<p>由于模板和数据都在后端,需要拉取两次接口进行打印,所以需要提供一套缓存机制来提高打印体验。由于业务数据需要实时拉取,所以必须走接口,模板相对于业务数据来说,可以允许一定的延迟。所以,模板采用本地文件缓存,业务数据采用和业务打印页面挂钩的内存缓存,业务数据只需要第一次打印是请求接口,重新打印直接使用。</p>
<p>流程图:<br><img src="/img/bVbkQhv?w=817&h=599" alt="图片描述" title="图片描述"></p>
<p>本缓方案存会存在偶现的模板不同步问题,在即将打印时,如果网页后台修改了模板,就会出现本次打印模板不是最新的,但是在下一次打印时就会是最新的了。由于出现的几率比较低,模板也允许有一点延迟,所以不会影响整体流程。</p>
<p>对于离线场景,我们在 app 中存放一个最小可用模板,专门用于离线下小票打印使用。为什么是最小可用模板,因为离线下,业务数据及一些其他数据有可能不全,所以最小可用模板可以保证打印出来的数据准确性。</p>
<h4>4 、图片处理</h4>
<p>由于 JS 引擎是不能解析图片文件的,所以在最初模板中存在图片链接时,全部由移动端进行处理,然后进行替换。图片处理主要就是下载图片,图片压缩,二值图处理,图片像素点压缩(打印指令要求),每个字节转换成 16 进制,拼接 16 进制字符串。</p>
<ul><li>下载图片</li></ul>
<p>采用 SDWebImage 进行下载缓存,创建并行队列进行多图片下载,每下载成功一张后回到主线程进行后续的相关处理。所有图片都处理完成或,回调给 JS 引擎进行指令解析。</p>
<ul><li>图片压缩</li></ul>
<p>根据 JS 引擎模板要求的 width(必须是 8 的倍数,后续说明),进行等比例压缩,转换成 jpg 格式,过滤掉 alpha 通道。</p>
<ul><li>二值图处理</li></ul>
<p>遍历每一个像素点,进行 RGB 取值,然后算出 RGB 均值与 255 的比值,根据比值进行取值 0 或 255 。这里没有使用直方图寻找阈值 T 的方式进行处理,是出于性能和时间考虑。</p>
<ul><li>像素点压缩</li></ul>
<p>由于打印机指令要求,需要对转换成二值后的每个点进行 width 上压缩,需要将 8 个字节压缩到 1 个字节,这里也是为什么图片压缩时 width 必须是 8 的倍数的原因,否则打印出来的图片会错位。</p>
<p><img src="/img/bVbkQhC?w=776&h=225" alt="图片描述" title="图片描述"></p>
<ul><li>16 进制字符串</li></ul>
<p>因为打印机打印图片接收的是 16 进制字符串,所以需要将处理后的每个字节转换成 16 进制字符,然后拼成一个字符串。</p>
<h4>5 、实现多次打印</h4>
<p>由于业务场景需要,需要自动打印多张小票,所以设计了多次打印逻辑。由于每次打印都是异步线程中,所以不可以直接循环打印,这里使用信号量 <code>dispatch_semaphore_t</code> ,在异步线程中创建和 wait 信号量,每次打印完成回调线程中 signal 信号量,实现多次打印,保证每次打印依次进行。如果中途打印出错,则终止后续打印。</p>
<pre><code class="Objective-C"> dispatch_async(dispatch_get_global_queue(0, 0), ^{
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
for (int i = 1; i <= printCount; i++) {
if (stop) {
break;
}
[self print:template andCompletionBlock:^(State state, NSString *errorStr) {
dispatch_async(dispatch_get_main_queue(), ^{
if (errorStr.length > 0 || i == printCount) {
if (completion) {
completion(state, errorStr);
}
stop = YES;
}
dispatch_semaphore_signal(semaphore);
});
}];
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC));
}
});</code></pre>
<h3>四、总结与展望</h3>
<p>本方案已经实施,在零售 app 中使用来看,已经满足目前大部分业务场景及需求,后续的开发及维护成本也会大幅度降低,提高了研发效率,接入新业务小票也比较方便。客户使用上来说,使用体验和以前没有较大差别,同时在处理客户反映的问题来说,也可以做到快速修改,实时下发等。不过目前还存在一些不足点,比如说图片打印的功能,还不能完全满足所有图片都做到完美打印,毕竟图片处理考虑到性能体验方面;还有模板后续可以增加版本号,这样在模板存在异常时也可以回滚或兼容处理等;再者就是缓存优化可以后续进一步优化体验,比如加入模板推送,本地缓存优化等。</p>
<h2>参考链接</h2>
<ul>
<li>
<a href="https://link.segmentfault.com/?enc=x5oe9g9vF7V4Sv7I2Vh5TQ%3D%3D.8XWyvEimfTJk38PqbbuiT2e8SKTq4TwQ%2BMq1qZB%2FtRA%3D" rel="nofollow">HandleBars</a>, by wycats</li>
<li>
<a href="https://link.segmentfault.com/?enc=rVS3L2jJBVXCwJ8n0%2F5YpQ%3D%3D.PevteXNum62g5PaFTQNrXvGdcMTLw5qZ5tsXPF0rh86l39Vl%2BxSZ1vfc6ppfDisc" rel="nofollow">date-fns</a>, by date-fns</li>
<li>
<a href="https://link.segmentfault.com/?enc=uzyDLcXvJKt%2FSvf4UaXEzg%3D%3D.RjzseDscM9HSwDF3a3XVV365dozCE40HwJ9orvfCAHM%3D" rel="nofollow">lodash</a>, by Lodash</li>
<li>
<a href="https://link.segmentfault.com/?enc=OK9FwOJNG%2Fcv9mkDfWE7rw%3D%3D.Jr783%2BQKhJmHuWsBjT9BtFZzOW1bgnP2lSHJOdjFpSQtoflFm%2BuugrhoLBOjpYafzkCMVZ%2BKsziVvSTRKAxprVceN3z18o5fTJpEOHKW%2FWM%3D" rel="nofollow">JavaScriptCore</a>, by Apple</li>
<li>
<a href="https://link.segmentfault.com/?enc=Utrel9rQegRVMOv9CPkLTQ%3D%3D.sQVrkhzArT01ReXKkHLX%2BQTmXAqXQ%2BAiMlg2HbYlj%2F%2Bjjd41qHnnpdskbmnF3oY3" rel="nofollow">J2V8</a>, by eclipsesource</li>
</ul>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
有赞透明多级缓存解决方案(TMC)
https://segmentfault.com/a/1190000017142556
2018-11-26T11:40:27+08:00
2018-11-26T11:40:27+08:00
有赞技术
https://segmentfault.com/u/youzantech
43
<h2>一、引子</h2>
<h3>1-1. TMC 是什么</h3>
<p>TMC ,即“透明多级缓存( Transparent Multilevel Cache )”,是有赞 PaaS 团队给公司内应用提供的整体缓存解决方案。</p>
<p>TMC 在通用“分布式缓存解决方案(如 CodisProxy + Redis ,如有赞自研分布式缓存系统 zanKV )”基础上,增加了以下功能:</p>
<ul>
<li>应用层热点探测</li>
<li>应用层本地缓存</li>
<li>应用层缓存命中统计</li>
</ul>
<p>以帮助应用层解决缓存使用过程中出现的热点访问问题。</p>
<h3>1-2. 为什么要做 TMC</h3>
<p>使用有赞服务的电商商家数量和类型很多,商家会不定期做一些“商品秒杀”、“商品推广”活动,导致“营销活动”、“商品详情”、“交易下单”等链路应用出现 <strong>缓存热点访问</strong> 的情况:</p>
<ul>
<li>活动时间、活动类型、活动商品之类的信息不可预期,导致 <em>缓存热点访问</em> 情况不可提前预知;</li>
<li>
<em>缓存热点访问</em> 出现期间,应用层少数 <strong>热点访问 key </strong> 产生大量缓存访问请求:冲击分布式缓存系统,大量占据内网带宽,最终影响应用层系统稳定性;</li>
</ul>
<p>为了应对以上问题,需要一个能够 <em>自动发现热点</em> 并 <em>将热点缓存访问请求前置在应用层本地缓存</em> 的解决方案,这就是 TMC 产生的原因。</p>
<h3>1-3. 多级缓存解决方案的痛点</h3>
<p>基于上述描述,我们总结了下列 <strong>多级缓存解决方案</strong> 需要解决的需求痛点:</p>
<ul>
<li>热点探测:如何快速且准确的发现 <strong>热点访问 key </strong> ?</li>
<li>数据一致性:前置在应用层的本地缓存,如何保障与分布式缓存系统的数据一致性?</li>
<li>效果验证:如何让应用层查看本地缓存命中率、热点 key 等数据,验证多级缓存效果?</li>
<li>透明接入:整体解决方案如何减少对应用系统的入侵,做到快速平滑接入?</li>
</ul>
<p>TMC 聚焦上述痛点,设计并实现了整体解决方案。以支持“热点探测”和“本地缓存”,减少热点访问时对下游分布式缓存服务的冲击,避免影响应用服务的性能及稳定性。</p>
<h2>二、 TMC 整体架构</h2>
<p><img src="/img/bVbj5Hj?w=1053&h=759" alt="图片描述" title="图片描述"></p>
<p>TMC 整体架构如上图,共分为三层:</p>
<ul>
<li>存储层:提供基础的kv数据存储能力,针对不同的业务场景选用不同的存储服务( codis / zankv / aerospike );</li>
<li>代理层:为应用层提供统一的缓存使用入口及通信协议,承担分布式数据水平切分后的路由功能转发工作;</li>
<li>应用层:提供统一客户端给应用服务使用,内置“热点探测”、“本地缓存”等功能,对业务透明;</li>
</ul>
<p>本篇聚焦在应用层客户端的“热点探测”、“本地缓存”功能。</p>
<h2>三、 TMC 本地缓存</h2>
<h3>3-1. 如何透明</h3>
<p>TMC 是如何减少对业务应用系统的入侵,做到透明接入的?</p>
<p>对于公司 Java 应用服务,在缓存客户端使用方式上分为两类:</p>
<ul>
<li>基于<code>spring.data.redis</code>包,使用<code>RedisTemplate</code>编写业务代码;</li>
<li>基于<code>youzan.framework.redis</code>包,使用<code>RedisClient</code>编写业务代码;</li>
</ul>
<p>不论使用以上那种方式,最终通过<code>JedisPool</code>创建的<code>Jedis</code>对象与缓存服务端代理层做请求交互。</p>
<p><img src="/img/bVbj5Hu?w=1486&h=321" alt="图片描述" title="图片描述"></p>
<p>TMC 对原生jedis包的<code>JedisPool</code>和<code>Jedis</code>类做了改造,在JedisPool初始化过程中集成TMC“热点发现”+“本地缓存”功能<code>Hermes-SDK</code>包的初始化逻辑,使<code>Jedis</code>客户端与缓存服务端代理层交互时先与<code>Hermes-SDK</code>交互,从而完成 “热点探测”+“本地缓存”功能的透明接入。</p>
<p>对于 Java 应用服务,只需使用特定版本的 jedis-jar 包,无需修改代码,即可接入 TMC 使用“热点发现”+“本地缓存”功能,做到了对应用系统的最小入侵。</p>
<h3>3-2. 整体结构</h3>
<p><img src="/img/bVbj5Hx?w=1077&h=678" alt="图片描述" title="图片描述"></p>
<h4>3-2-1. 模块划分</h4>
<p>TMC 本地缓存整体结构分为如下模块:</p>
<ul>
<li>
<strong>Jedis-Client</strong>: Java 应用与缓存服务端交互的直接入口,接口定义与原生 Jedis-Client 无异;</li>
<li>
<strong>Hermes-SDK</strong>:自研“热点发现+本地缓存”功能的SDK封装, Jedis-Client 通过与它交互来集成相应能力;</li>
<li>
<strong>Hermes服务端集群</strong>:接收 Hermes-SDK 上报的缓存访问数据,进行热点探测,将热点 key 推送给 Hermes-SDK 做本地缓存;</li>
<li>
<strong>缓存集群</strong>:由代理层和存储层组成,为应用客户端提供统一的分布式缓存服务入口;</li>
<li>
<strong>基础组件</strong>: etcd 集群、 Apollo 配置中心,为 TMC 提供“集群推送”和“统一配置”能力;</li>
</ul>
<h4>3-2-2. 基本流程</h4>
<p>1) key 值获取</p>
<ul>
<li>Java 应用调用 <strong>Jedis-Client</strong> 接口获取key的缓存值时,<strong>Jedis-Client</strong> 会询问 <strong>Hermes-SDK</strong> 该 key 当前是否是 <strong>热点key</strong>;</li>
<li>对于 <strong>热点key</strong> ,直接从 <strong>Hermes-SDK</strong> 的 <em>热点模块</em> 获取热点 key 在本地缓存的 value 值,不去访问 <strong>缓存集群</strong> ,从而将访问请求前置在应用层;</li>
<li>对于非 <strong>热点key</strong> ,<strong>Hermes-SDK</strong> 会通过<code>Callable</code>回调 <strong>Jedis-Client</strong> 的原生接口,从 <strong>缓存集群</strong> 拿到 value 值;</li>
<li>对于 <strong>Jedis-Client</strong> 的每次 key 值访问请求,<strong>Hermes-SDK</strong> 都会通过其 <em>通信模块</em> 将 <strong><em>key访问事件</em></strong> 异步上报给 <strong>Hermes服务端集群</strong> ,以便其根据上报数据进行“热点探测”;</li>
</ul>
<p>2)key值过期</p>
<ul>
<li>Java 应用调用 <strong>Jedis-Client</strong> 的<code>set()</code> <code>del()</code> <code>expire()</code>接口时会导致对应 key 值失效,<strong>Jedis-Client</strong> 会同步调用 <strong>Hermes-SDK</strong> 的<code>invalid()</code>方法告知其“ key 值失效”事件;</li>
<li>对于 <strong>热点key</strong> ,<strong>Hermes-SDK</strong> 的 <em>热点模块</em> 会先将 key 在本地缓存的 value 值失效,以达到本地数据<strong>强一致</strong>。同时 <em>通信模块</em> 会异步将“ key 值失效”事件通过 <strong>etcd集群</strong> 推送给 Java 应用集群中其他 <strong>Hermes-SDK</strong> 节点;</li>
<li>其他<strong>Hermes-SDK</strong>节点的 <em>通信模块</em> 收到 “ key 值失效”事件后,会调用 <em>热点模块</em> 将 key 在本地缓存的 value 值失效,以达到集群数据<strong>最终一致</strong>;</li>
</ul>
<p>3)热点发现</p>
<ul>
<li>
<strong>Hermes服务端集群</strong> 不断收集 <strong>Hermes-SDK</strong>上报的 <strong><em>key访问事件</em></strong>,对不同业务应用集群的缓存访问数据进行周期性(3s一次)分析计算,以探测业务应用集群中的<strong>热点key</strong>列表;</li>
<li>对于探测到的<strong>热点key</strong>列表,<strong>Hermes服务端集群</strong> 将其通过 <strong>etcd集群</strong> 推送给不同业务应用集群的 <strong>Hermes-SDK</strong> <em>通信模块</em>,通知其对<strong>热点key</strong>列表进行本地缓存;</li>
</ul>
<p>4)配置读取</p>
<ul>
<li>
<strong>Hermes-SDK</strong> 在启动及运行过程中,会从 <strong>Apollo配置中心</strong> 读取其关心的配置信息(如:启动关闭配置、黑白名单配置、etcd地址...);</li>
<li>
<strong>Hermes服务端集群</strong> 在启动及运行过程中,会从 <strong>Apollo配置中心</strong> 读取其关心的配置信息(如:业务应用列表、热点阈值配置、 etcd 地址...);</li>
</ul>
<h4>3-2-3. 稳定性</h4>
<p>TMC本地缓存稳定性表现在以下方面:</p>
<ul>
<li>数据上报异步化:<strong>Hermes-SDK</strong> 使用<code>rsyslog技术</code>对“ key 访问事件”进行异步化上报,不会阻塞业务;</li>
<li>通信模块线程隔离:<strong>Hermes-SDK</strong> 的 <em>通信模块</em> 使用独立线程池+有界队列,保证事件上报&监听的I/O操作与业务执行线程隔离,即使出现非预期性异常也不会影响基本业务功能;</li>
<li>缓存管控:<strong>Hermes-SDK</strong> 的 <em>热点模块</em> 对本地缓存大小上限进行了管控,使其占用内存不超过 64MB(LRU),杜绝 JVM 堆内存溢出的可能;</li>
</ul>
<h4>3-2-4. 一致性</h4>
<p>TMC 本地缓存一致性表现在以下方面:</p>
<ul>
<li>
<strong>Hermes-SDK</strong> 的 <em>热点模块</em> 仅缓存 <strong>热点key</strong> 数据,绝大多数非热点 key 数据由 <strong>缓存集群</strong> 存储;</li>
<li>
<strong>热点key</strong> 变更导致 value 失效时,<strong>Hermes-SDK</strong> 同步失效本地缓存,保证 <strong>本地强一致</strong>;</li>
<li>
<strong>热点key</strong> 变更导致 value 失效时,<strong>Hermes-SDK</strong> 通过 <strong>etcd集群</strong> 广播事件,异步失效业务应用集群中其他节点的本地缓存,保证 <strong>集群最终一致</strong>;</li>
</ul>
<h2>四、TMC热点发现</h2>
<h3>4-1. 整体流程</h3>
<p><img src="/img/bVbj5HV?w=1242&h=169" alt="图片描述" title="图片描述"></p>
<p>TMC 热点发现流程分为四步:</p>
<ul>
<li>
<strong>数据收集</strong>:收集 <strong>Hermes-SDK</strong> 上报的 <em>key访问事件</em>;</li>
<li>
<strong>热度滑窗</strong>:对 App 的每个 Key ,维护一个时间轮,记录基于当前时刻滑窗的访问热度;</li>
<li>
<strong>热度汇聚</strong>:对 App 的所有 Key ,以<code><key,热度></code>的形式进行 <em>热度排序汇总</em>;</li>
<li>
<strong>热点探测</strong>:对 App ,从 <em>热Key排序汇总</em> 结果中选出 <em>TopN的热点Key</em> ,推送给 <strong>Hermes-SDK</strong>;</li>
</ul>
<h3>4-2. 数据收集</h3>
<p><strong>Hermes-SDK</strong> 通过本地<code>rsyslog</code>将 <strong><em>key访问事件</em></strong> 以协议格式放入 <strong>kafka</strong> ,<strong>Hermes服务端集群</strong> 的每个节点消费 kafka 消息,实时获取 <strong><em>key访问事件</em></strong>。</p>
<p>访问事件协议格式如下:</p>
<ul>
<li>appName:集群节点所属业务应用</li>
<li>uniqueKey:业务应用 <em>key访问事件</em> 的 key</li>
<li>sendTime:业务应用 <em>key访问事件</em> 的发生时间</li>
<li>weight:业务应用 <em>key访问事件</em> 的访问权值</li>
</ul>
<p><strong>Hermes服务端集群</strong> 节点将收集到的 <strong><em>key访问事件</em></strong> 存储在本地内存中,内存数据结构为<code>Map<String, Map<String, LongAdder>></code>,对应业务含义映射为<code>Map< appName , Map< uniqueKey , 热度 >></code>。</p>
<h3>4-3. 热度滑窗</h3>
<p><img src="/img/bVbj5HB?w=948&h=566" alt="图片描述" title="图片描述"></p>
<h4>4-3-1. 时间滑窗</h4>
<p><strong>Hermes服务端集群</strong> 节点,对每个App的每个 key ,维护了一个 <strong><em>时间轮</em></strong>:</p>
<ul>
<li>时间轮中共10个 <strong><em>时间片</em></strong>,每个时间片记录当前 key 对应 3 秒时间周期的总访问次数;</li>
<li>时间轮10个时间片的记录累加即表示当前 key 从当前时间向前 30 秒时间窗口内的总访问次数;</li>
</ul>
<h4>4-3-2. 映射任务</h4>
<p><strong>Hermes服务端集群</strong> 节点,对每个 App <em>每3秒</em> 生成一个 <strong>映射任务</strong> ,交由节点内 <em>“缓存映射线程池”</em> 执行。<strong>映射任务</strong> 内容如下:</p>
<ul>
<li>对当前 App ,从<code>Map< appName , Map< uniqueKey , 热度 >></code>中取出 <em>appName</em> 对应的Map <code>Map< uniqueKey , 热度 >></code>;</li>
<li>遍历<code>Map< uniqueKey , 热度 >></code>中的 key ,对每个 key 取出其热度存入其 <strong><em>时间轮</em></strong> 对应的时间片中;</li>
</ul>
<h3>4-4. 热度汇聚</h3>
<p><img src="/img/bVbj5H9?w=2036&h=872" alt="图片描述" title="图片描述"></p>
<p>完成第二步“热度滑窗”后,<strong>映射任务</strong> 继续对当前 App 进行“热度汇聚”工作:</p>
<ul>
<li>遍历 App 的 key ,将每个 key 的 <strong>时间轮</strong> 热度进行汇总(即30秒时间窗口内总热度)得到探测时刻 <strong>滑窗总热度</strong>;</li>
<li>将 <code>< key , 滑窗总热度 ></code> 以排序集合的方式存入 <em>Redis存储服务</em> 中,即 <strong>热度汇聚结果</strong>;</li>
</ul>
<h3>4-5. 热点探测</h3>
<ul>
<li>在前几步,<strong><em>每3秒</em></strong> 一次的 <strong>映射任务</strong> 执行,对每个 App 都会产生一份当前时刻的 <strong>热度汇聚结果</strong> ;</li>
<li>
<strong>Hermes服务端集群</strong> 中的“热点探测”节点,对每个 App ,只需周期性从其最近一份 <strong>热度汇聚结果</strong> 中取出达到热度阈值的 TopN 的 key 列表,即可得到本次探测的 <strong>热点key列表</strong>;</li>
</ul>
<p>TMC 热点发现整体流程如下图:<br><img src="/img/bVbj5Ip?w=1498&h=706" alt="图片描述" title="图片描述"></p>
<h3>4-6. 特性总结</h3>
<h4>4-6-1. 实时性</h4>
<p><strong>Hermes-SDK</strong>基于rsyslog + kafka 实时上报 <strong><em>key访问事件</em></strong>。<br><strong>映射任务</strong> 3秒一个周期完成“热度滑窗” + “热度汇聚”工作,当有 <strong><em>热点访问场景</em></strong> 出现时最长3秒即可探测出对应 <strong>热点key</strong>。</p>
<h4>4-6-2. 准确性</h4>
<p>key 的<strong>热度汇聚结果</strong>由“基于时间轮实现的滑动窗口”汇聚得到,相对准确地反应当前及最近正在发生访问分布。</p>
<h4>4-6-3.扩展性</h4>
<p><strong>Hermes服务端集群</strong> 节点无状态,节点数可基于 kafka 的 partition 数量横向扩展。</p>
<p>“热度滑窗” + “热度汇聚” 过程基于 App 数量,在单节点内多线程扩展。</p>
<h2>五、TMC实战效果</h2>
<h3>5-1. 快手商家某次商品营销活动</h3>
<p>有赞商家通过快手直播平台为某商品搞活动,造成该商品短时间内被集中访问产生访问热点,活动期间 TMC 记录的实际热点访问效果数据如下:</p>
<h4>5-1-1. 某核心应用的缓存请求&命中率曲线图</h4>
<p><img src="/img/bVbj5Iy?w=1210&h=356" alt="图片描述" title="图片描述"></p>
<ul>
<li>上图蓝线为应用集群调用<code>get()</code>方法访问缓存次数</li>
<li>上图绿线为获取缓存操作命中 TMC 本地缓存的次数</li>
</ul>
<p><img src="/img/bVbj5IC?w=1175&h=345" alt="图片描述" title="图片描述"></p>
<ul><li>上图为本地缓存命中率曲线图</li></ul>
<p>可以看出活动期间缓存请求量及本地缓存命中量均有明显增长,本地缓存命中率达到近 80% (即应用集群中 80% 的缓存查询请求被 TMC 本地缓存拦截)。</p>
<h4>5-1-2. 热点缓存对应用访问的加速效果</h4>
<p><img src="/img/bVbj5IF?w=720&h=397" alt="图片描述" title="图片描述"></p>
<ul><li>上图为应用接口QPS曲线</li></ul>
<p><img src="/img/bVbj5II?w=823&h=519" alt="图片描述" title="图片描述"></p>
<ul><li>上图为应用接口RT曲线</li></ul>
<p>可以看出活动期间应用接口的请求量有明显增长,由于 TMC 本地缓存的效果应用接口的 RT 反而出现下降。</p>
<h3>5-2. 双十一期间部分应用 TMC 效果展示</h3>
<h4>5-2-1. 商品域核心应用效果</h4>
<p><img src="/img/bVbj5IK?w=1449&h=430" alt="图片描述" title="图片描述"></p>
<h4>5-2-2. 活动域核心应用效果</h4>
<p><img src="/img/bVbj5IO?w=1546&h=460" alt="图片描述" title="图片描述"><br><img src="/img/bVbj5IU?w=1518&h=326" alt="图片描述" title="图片描述"></p>
<h2>六、TMC功能展望</h2>
<p>在有赞, TMC 目前已为商品中心、物流中心、库存中心、营销活动、用户中心、网关&消息等多个核心应用模块提供服务,后续应用也在陆续接入中。</p>
<p>TMC 在提供“热点探测” + “本地缓存”的核心能力同时,也为应用服务提供了灵活的配置选择,应用服务可以结合实际业务情况在“热点阈值”、“热点key探测数量”、“热点黑白名单”维度进行自由配置以达到更好的使用效果。</p>
<p>最后, TMC 的迭代还在持续进行中...</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
性能优化之分页查询
https://segmentfault.com/a/1190000017059239
2018-11-19T11:05:16+08:00
2018-11-19T11:05:16+08:00
有赞技术
https://segmentfault.com/u/youzantech
70
<h3>背景</h3>
<p>大部分开发和DBA同行都对分页查询非常非常了解,看帖子翻页需要分页查询,搜索商品也需要分页查询。那么问题来了,遇到上千万或者上亿的数据量怎么快速的拉取全量,比如大商家拉取每月千万级别的订单数量到自己独立的ISV做财务统计;或者拥有百万千万粉丝的公众大号,给全部粉丝推送消息的场景。本文讲讲个人的优化分页查询的经验,抛砖引玉。</p>
<h3>二 分析</h3>
<p>在讲如何优化之前我们先来看看一个比较常见错误的写法</p>
<blockquote>SELECT * FROM table<br>where kid=1342 and type=1 order id asc limit 149420,20;</blockquote>
<p>该SQL是一个非常典型的排序+分页查询:</p>
<blockquote>order by col limit N,OFFSET M</blockquote>
<p>MySQL 执行此类SQL时需要先扫描到N行,然后再去取 M行。对于此类操作,取前面少数几行数据会很快,但是扫描的记录数越多,SQL的性能就会越差,因为N越大,MySQL需要扫描越多的数据来定位到具体的N行,这样耗费大量的IO 成本和时间成本。一图胜千言,我们使用简单的图来解释为什么 上面的sql 的写法扫描数据会慢。<br>t 表是一个索引组织表,key idx_kid_type(kid,type) 。<br><img src="/img/bVbjJ3u?w=350&h=700" alt="图片描述" title="图片描述"></p>
<p>符合kid=3 and type=1 的记录有很多行,我们取第 9,10行。</p>
<blockquote>select * from t where kid =3 and type=1 order by id desc 8,2;</blockquote>
<p>MySQL 是如何执行上面的sql 的?对于Innodb表,系统是根据 idx_kid_type 二级索引里面包含的主键去查找对应的行。对于百万千万级别的记录而言,索引大小可能和数据大小相差无几,cache在内存中的索引数量有限,而且二级索引和数据叶子节点不在同一个物理块儿上存储,二级索引与主键的相对无序映射关系,也会带来大量的随机IO请求,N值越大越需要遍历大量索引页和数据叶,需要耗费的时间就越久。</p>
<p><img src="/img/bVbjJ3B?w=350&h=700" alt="图片描述" title="图片描述"></p>
<p>鉴于上面的大分页查询耗费时间长的原因,我们思考一个问题,是否需要完全遍历“无效的数据”?如果我们需要limit 8,2;我们跳过前面8行无关的数据页遍历,可以直接通过索引定位到第9,第10行,这样操作是不是更快了?依然是一图胜千言,通过这其实也是 延迟关联的 核心思思:通过使用覆盖索引查询返回需要的主键,再根据主键关联原表获得需要的数据,而不是通过二级索引获取主键再通过主键去遍历数据页。</p>
<p><img src="/img/bVbjJ3M?w=350&h=700" alt="图片描述" title="图片描述"></p>
<p>通过上面的原理分析,我们知道通过常规方式进行大分页查询慢的原因,也知道了提高大分页查询的具体方法 ,下面我们讨论一下在线上业务系统中常用的解决方法。</p>
<h3>三 实践出真知</h3>
<p>针对limit 优化有很多种方式:</p>
<ol>
<li>前端加缓存、搜索,减少落到库的查询操作。比如海量商品可以放到搜索里面,使用瀑布流的方式展现数据,很多电商网站采用了这种方式。</li>
<li>优化SQL 访问数据的方式,直接快速定位到要访问的数据行。</li>
<li>使用书签方式 ,记录上次查询最新/大的id值,向后追溯 M行记录。<br>对于第二种方式 我们推荐使用"延迟关联"的方法来优化排序操作,何谓"延迟关联" :通过使用覆盖索引查询返回需要的主键,再根据主键关联原表获得需要的数据。</li>
</ol>
<p>3.1 延迟关联<br>优化前</p>
<pre><code>root@xxx 12:33:48>explain SELECT id, cu_id, name, info, biz_type, gmt_create, gmt_modified,start_time, end_time, market_type, back_leaf_category,item_status,picuture_url FROM relation where biz_type ='0' AND end_time >='2014-05-29' ORDER BY id asc LIMIT 149420 ,20;
+----+-------------+-------------+-------+---------------+-------------+---------+------+--------+-----+
| id | select_type | table | type | possible_keys | key | key\_len | ref | rows | Extra |
+----+-------------+-------------+-------+---------------+-------------+---------+------+--------+-----+
| 1 | SIMPLE | relation | range | ind\_endtime | ind\_endtime | 9 | NULL | 349622 | Using where; Using filesort |
+----+-------------+-------------+-------+---------------+-------------+---------+------+--------+-----+
1 row in set (0.00 sec)</code></pre>
<p>其执行时间:</p>
<p><img src="/img/bVbjJ35?w=363&h=60" alt="图片描述" title="图片描述"></p>
<p>优化后:</p>
<pre><code>root@xxx 12:33:43>explain SELECT a.* FROM relation a, (select id from relation where biz_type ='0' AND end\_time >='2014-05-29' ORDER BY id asc LIMIT 149420 ,20 ) b where a.id=b.id;
+----+-------------+-------------+--------+---------------+---------+---------+------+--------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------------+--------+---------------+---------+---------+------+--------+-------+
| 1 | PRIMARY | <derived2> | ALL | NULL | NULL | NULL | NULL | 20 | |
| 1 | PRIMARY | a | eq_ref | PRIMARY | PRIMARY | 8 | b.id | 1 | |
| 2 | DERIVED | relation | index | ind_endtime | PRIMARY | 8 | NULL | 733552 | |
+----+-------------+-------------+--------+---------------+---------+---------+------+--------+-------+
3 rows in set (0.36 sec)</code></pre>
<p>执行时间:</p>
<p><img src="/img/bVbjJ4a?w=240&h=41" alt="图片描述" title="图片描述"></p>
<p>优化后 执行时间 为原来的1/3 。</p>
<h4>3.2 使用书签的方式</h4>
<p>首先要获取复合条件的记录的最大 id和最小id(默认id是主键)</p>
<blockquote>select max(id) as maxid ,min(id) as minid from t where kid=2333 and type=1;</blockquote>
<p>其次 根据id 大于最小值或者小于最大值 进行遍历。</p>
<blockquote>select xx,xx from t where kid=2333 and type=1 and id >=min_id order by id asc limit 100;<br>select xx,xx from t where kid=2333 and type=1 and id <=max_id order by id desc limit 100;</blockquote>
<p>案例</p>
<p>当遇到延迟关联也不能满足查询速度的要求时</p>
<blockquote>SELECT a.id as id, client_id, admin_id, kdt_id, type, token, created_time, update_time, is_valid, version FROM t1 a, (SELECT id FROM t1 WHERE 1 and client_id = 'xxx' and is_valid = '1' order by kdt_id asc limit 267100,100 ) b WHERE a.id = b.id;</blockquote>
<p><img src="/img/bVbjJ4g?w=1280&h=247" alt="图片描述" title="图片描述"></p>
<p>使用延迟关联查询数据510ms ,使用基于书签模式的解决方法减少到10ms以内 绝对是一个质的飞跃。<br>SELECT * FROM <code>t1</code> where client_id='xxxxx' and is_valid=1 and id<47399727 order by id desc LIMIT 100;</p>
<p><img src="/img/bVbjJ4j?w=1264&h=230" alt="图片描述" title="图片描述"></p>
<h3>四 小结</h3>
<p>从我们的优化经验和案例上来讲,根据主键定位数据的方式直接定位到主键起始位点,然后过滤所需要的数据 相对比延迟关联的速度更快些,查找数据的时候少了二级索引扫描。但是 优化方法没有银弹,没有一劳永逸的方法。比如下面的例子</p>
<p><img src="/img/bVbjJ4l?w=2014&h=726" alt="图片描述" title="图片描述"></p>
<p>order by id desc 和 order by asc 的结果相差70ms ,生产上的案例有limit 100 相差1.3s ,这是为什么呢?留给大家去思考吧。</p>
<p>最后,其实我相信还有其他优化方式,比如在使用不到组合索引的全部索引列进行覆盖索引扫描的时候使用 ICP 的方式 也能够加快大分页查询。</p>
<p>以上是我在优化分页查询方面的经验总结,抛砖引玉,有兴趣的朋友可以多交流,分享你们的优化经验案例。</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
浅谈 Android Dex 文件
https://segmentfault.com/a/1190000017059132
2018-11-19T11:01:34+08:00
2018-11-19T11:01:34+08:00
有赞技术
https://segmentfault.com/u/youzantech
19
<h2>概述</h2>
<h3>为什么要了解 Dex 文件</h3>
<p>了解了 Dex 文件以后,对日常开发中遇到一些问题能有更深的理解。如:APK 的瘦身、热修复、插件化、应用加固、Android 逆向工程、64 K 方法数限制。</p>
<h3>什么是 Dex 文件</h3>
<p>在明白什么是 Dex 文件之前,要先了解一下 JVM,Dalvik 和 ART。JVM 是 JAVA 虚拟机,用来运行 JAVA 字节码程序。Dalvik 是 Google 设计的用于 Android平台的运行时环境,适合移动环境下内存和处理器速度有限的系统。ART 即 Android Runtime,是 Google 为了替换 Dalvik 设计的新 Android 运行时环境,在Android 4.4推出。ART 比 Dalvik 的性能更好。Android 程序一般使用 Java 语言开发,但是 Dalvik 虚拟机并不支持直接执行 JAVA 字节码,所以会对编译生成的 .class 文件进行翻译、重构、解释、压缩等处理,这个处理过程是由 dx 进行处理,处理完成后生成的产物会以 .dex 结尾,称为 Dex 文件。Dex 文件格式是专为 Dalvik 设计的一种压缩格式。所以可以简单的理解为:Dex 文件是很多 .class 文件处理后的产物,最终可以在 Android 运行时环境执行。</p>
<h2>Dex 文件是怎么生成的</h2>
<p>java 代码转化为 dex 文件的流程如图所示,当然真的处理流程不会这么简单,这里只是一个形象的显示:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/fbc2b6bbee232657cddb4e1db56e9b54.png" alt="" title=""></p>
<blockquote>注:图片来源于网络</blockquote>
<p>现在来通过一个简单的例子实现 java 代码到 dex 文件的转化。</p>
<h3>从 .java 到 .class</h3>
<p>先来创建一个 Hello.java 文件,为了便于分析,这里写一些简单的代码。代码如下:</p>
<pre><code class="java">public class Hello {
private String helloString = "hello! youzan";
public static void main(String[] args) {
Hello hello = new Hello();
hello.fun(hello.helloString);
}
public void fun(String a) {
System.out.println(a);
}
}</code></pre>
<p>在该文件的同级目录下面使用 JDK 的 javac 编译这个 java 文件。</p>
<pre><code class="java">javac Hello</code></pre>
<p>javac 命令执行后会在当前目录生成 Hello.class 文件,Hello.class 文件已经可以直接在 JVM 虚拟机上直接执行。这里使用使用命令执行该文件。</p>
<pre><code class="java">java Hello</code></pre>
<p>执行后应该会在控制台打印出“hello! youzan”</p>
<p>这里也可以对 Hello.class 文件执行 javap 命令,进行反汇编。</p>
<pre><code class="java">javap -c Hello</code></pre>
<p>执行结果如下:</p>
<pre><code>public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String hello! youzan
7: putfield #3 // Field helloString:Ljava/lang/String;
10: return
public static void main(java.lang.String[]);
Code:
0: new #4 // class Hello
3: dup
4: invokespecial #5 // Method "<init>":()V
7: astore_1
8: aload_1
9: aload_1
10: getfield #3 // Field helloString:Ljava/lang/String;
13: invokevirtual #6 // Method fun:(Ljava/lang/String;)V
16: return
public void fun(java.lang.String);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_1
4: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
7: return
}</code></pre>
<p>其中 Code 之后都是具体的指令,供 JVM 虚拟机执行。指令的具体含义可以参考 JAVA 官方文档。</p>
<h4>从 .class 到 .dex</h4>
<p>上面生成的 .class 文件虽然已经可以在 JVM 环境中运行,但是如果要在 Android 运行时环境中执行还需要特殊的处理,那就是 dx 处理,它会对 .class 文件进行翻译、重构、解释、压缩等操作。</p>
<p>dx 处理会使用到一个工具 dx.jar,这个文件位于 SDK 中,具体的目录大致为 <strong>你的SDK根目录/build-tools/任意版本</strong> 里面。使用 dx 工具处理上面生成的Hello.class 文件,在 Hello.class 的目录下使用下面的命令:</p>
<pre><code>dx --dex --output=Hello.dex Hello.class</code></pre>
<p>执行完成后,会在当前目录下生成一个 Hello.dex 文件。这个 .dex 文件就可以直接在 Android 运行时环境执行,一般可以通过 PathClassLoader 去加载 dex 文件。现在在当前目录下执行 <strong>dexdump</strong> 命名来反编译:</p>
<pre><code>dexdump -d Hello.dex</code></pre>
<p>执行结果如下(部分区域的含义已经在下面描述):</p>
<pre><code>Processing 'Hello.dex'...
Opened 'Hello.dex', DEX version '035'
------ 这里是编写的 Hello.java 的类的信息 ------
Class #0 -
Class descriptor : 'LHello;'
Access flags : 0x0001 (PUBLIC)
Superclass : 'Ljava/lang/Object;'
Interfaces -
Static fields -
Instance fields -
#0 : (in LHello;)
name : 'helloString'
type : 'Ljava/lang/String;'
access : 0x0002 (PRIVATE)
------ 下面区域描述的是构造方法的信息。7010 0400 0100 1a00 0b00 之类的数字就是方法中的代码翻译成的指令。Dalvik 使用的是16位代码单元,所以这里就是4个数字为一组,每个数字是16进制。invoke-direct 这些是前面指令对应的助记符,也代表着这些指令的真正操作。如果对这些指令转化感兴趣可以去https://source.android.com/devices/tech/dalvik/instruction-formats 查看 ------
Direct methods -
#0 : (in LHello;)
name : '<init>' --- 方法名称:这个很明显就是构造方法 ---
type : '()V' --- 方法原型,()里面表示入参,()后面表示返回值,V代表void---
access : 0x10001 (PUBLIC CONSTRUCTOR) --- 方法访问类型 ---
code -
registers : 2 --- 方法使用的寄存器数量 ---
ins : 1 --- 方法入参,方法除了我们定义的参数以外,系统还会默认带一个特殊参数 ---
outs : 1
insns size : 8 16-bit code units --- 指令大小 ---
000148: |[000148] Hello.<init>:()V
000158: 7010 0400 0100 |0000: invoke-direct {v1}, Ljava/lang/Object;.<init>:()V // method@0004
00015e: 1a00 0b00 |0003: const-string v0, "hello! youzan" // string@000b
000162: 5b10 0000 |0005: iput-object v0, v1, LHello;.helloString:Ljava/lang/String; // field@0000
000166: 0e00 |0007: return-void
catches : (none)
positions :
0x0000 line=1
0x0003 line=2
locals :
0x0000 - 0x0008 reg=1 this LHello;
#1 : (in LHello;)
name : 'main'
type : '([Ljava/lang/String;)V'
access : 0x0009 (PUBLIC STATIC)
code -
registers : 3
ins : 1
outs : 2
insns size : 11 16-bit code units
000168: |[000168] Hello.main:([Ljava/lang/String;)V
000178: 2200 0000 |0000: new-instance v0, LHello; // type@0000
00017c: 7010 0000 0000 |0002: invoke-direct {v0}, LHello;.<init>:()V // method@0000
000182: 5401 0000 |0005: iget-object v1, v0, LHello;.helloString:Ljava/lang/String; // field@0000
000186: 6e20 0100 1000 |0007: invoke-virtual {v0, v1}, LHello;.fun:(Ljava/lang/String;)V // method@0001
00018c: 0e00 |000a: return-void
catches : (none)
positions :
0x0000 line=5
0x0005 line=6
0x000a line=7
locals :
Virtual methods -
#0 : (in LHello;)
name : 'fun'
type : '(Ljava/lang/String;)V'
access : 0x0001 (PUBLIC)
code -
registers : 3
ins : 2
outs : 2
insns size : 6 16-bit code units
000190: |[000190] Hello.fun:(Ljava/lang/String;)V
0001a0: 6200 0100 |0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@0001
0001a4: 6e20 0300 2000 |0002: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V // method@0003
0001aa: 0e00 |0005: return-void
catches : (none)
positions :
0x0000 line=10
0x0005 line=11
locals :
0x0000 - 0x0006 reg=1 this LHello;
source_file_idx : 1 (Hello.java)</code></pre>
<p>到此为止,已经完成了将 Java 代码转变成 Dalvik 可执行的文件,即 dex。</p>
<h2>Dex 文件的具体格式</h2>
<p>现在来分析一下 Dex 文件的具体格式,就像 MP3,MP4,JPG,PNG 文件一样,Dex 文件也有它自己的格式,只有遵守了这些格式,才能被 Android 运行时环境正确识别。</p>
<p>Dex 文件整体布局如下图所示:<br><img src="https://dn-kdt-img-test.qbox.me/public_files/2018/09/30/38399687ecc6bb950f762d71d42e7780.png" alt="" title=""><br>这些区域的数据互相关联,互相引用。由于篇幅原因,这里只是显示部分区域的关联,完整的请去官网自行查看相关数据整理。下图中的各字段都在后面的各区域的详细介绍中有具体介绍。<br><img src="https://b.yzcdn.cn/public_files/2018/09/30/d985020caa93cc8d0a8dc209b44a673a.png" alt="" title=""></p>
<p>下面将分别对文件头、索引区、类定义区域进行简单的介绍。其它区域可以去 Android 官网了解。</p>
<h3>文件头</h3>
<p>文件头区域决定了该怎样来读取这个文件。具体的格式如下表(在文件中排列的顺序就是下面表格中的顺序):<br><img src="https://b.yzcdn.cn/public_files/2018/09/30/95920ecdb3c322092b9272c9901325eb.png" alt="" title=""></p>
<h3>id 区</h3>
<p>id 区存储着字符串,type,prototype,field, method 资源的真正数据在文件中的偏移量,我们可以根据 id 区的偏移量去找到该 id 对应的真实数据。</p>
<h4>字符串 id 区域</h4>
<p>这个区块是一个偏移量列表,每个偏移量对应了一个真正的字符串资源,每个偏移量占32位。我们可以通过偏移量找到对应的实际字符串数据。具体格式如下:<br><img src="https://su.yzcdn.cn/public_files/2018/09/30/85f43c14491760b13655ab734ba89f5d.png" alt="" title=""><br>最终这个偏移的位置应该是落在数据区的。找到这个偏移量的位置后,根据下面的格式就可以读取出这个字符串资源的具体数据:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/1723b0f81e4f11527ab22061b290d480.png" alt="" title=""></p>
<h4>类型 id 区</h4>
<p>这个区块是一个索引列表,索引的值对应字符串id区域偏移量列表中的某一项。数据格式如下:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/bd1fdfa2b15b6ca61389d80c1256b49b.png" alt="" title=""><br>如果我们要找到某个类型的值,需要先根据类型id列表中的索引值去字符串id列表中找到对应的项,这一项存储的偏移量对应的字符串资源就是这个类型的字符串描述。</p>
<h4>方法原型 id 区</h4>
<p>这个区块是一个方法原型 id 列表,数据格式为:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/2ee787f50c688e0f5847583e14a7963c.png" alt="" title=""></p>
<h4>成员 id 区</h4>
<p>这个区块存储着原型 id 列表,数据格式为:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/59fcf2070a2edebad1317ded1d8ddeb6.png" alt="" title=""></p>
<h4>方法 id 区</h4>
<p>这个区块存储着方法 id 列表,数据格式为: 这个区块存储着原型 id 列表,数据格式为:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/3f249e46646e30b8d3a96ed05ef3f8bc.png" alt="" title=""></p>
<h4>类定义区</h4>
<p>这个区域存储的是类定义的列表,具体的数据结构如下:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/55099ed4c09d28fe95831fd7d3585755.png" alt="" title=""></p>
<h4>解析 dex 文件的工具</h4>
<p>这里推荐一个可以解析 dex 文件的工具 010 Editor。它可以通过预置的模板让我们更清晰的了解 dex 文件的格式。 <br><img src="https://img.yzcdn.cn/public_files/2018/09/30/214f791cb42c2f6094da0effdef35445.png" alt="" title=""></p>
<h2>Dex 文件在 Android Tinker 热修复中的应用</h2>
<p>在目前的主流的 Android 热修复方案中,Tinker有免费、开源、用户量大等优点,因此在有赞也是基于 Tinker 搭建 Android 热修复服务。Tinker 热修复的主要原理就是通过对比旧 APK 的 dex 文件与新 APK 的 dex 文件,生成补丁包,然后在 APP 中通过补丁包与旧 APK 的 dex 文件合成新的 dex 文件。流程如下图所示:<br><img src="https://github.com/WeMobileDev/article/raw/master/assets/tinker/wechat.png" alt="" title=""></p>
<blockquote>注:图片来源于 Tinker 官网</blockquote>
<h3>补丁包的生成</h3>
<p>Tinker 官方使用自研一套合成方案,就是 DexDiff。它基于 Dex文件格式的特性,具有补丁包小,消耗内存小等优点。在 DexDiff 算法中,会根据 Dex文件的格式,将 Dex 文件划分为不同的区块类,如下图:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/fb5162333994958174a87c91d2a8d32c.png" alt="" title=""><br>这些区块有一个统一的数据结构,主要的数据有区块对应的实际数据类型及在文件中的偏移量。如下图:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/3128d6f5106306513d5e259aeb65b3a6.png" alt="" title=""><br>有了区块数据中的实际数据类型与偏移量,再根据实际数据类型对应的数据结构就可以从文件中读出这个区块包含的实际数据。这里以 header 区域为例,读取代码如下(删除了部分无关代码,代码可以参照上面的 Dex 文件格式的文件头的介绍):</p>
<pre><code class="java">private void readHeader(Dex.Section headerIn) throws UnsupportedEncodingException {
byte[] magic = headerIn.readByteArray(8);
int apiTarget = DexFormat.magicToApi(magic);
checksum = headerIn.readInt();
signature = headerIn.readByteArray(20);
fileSize = headerIn.readInt();
int headerSize = headerIn.readInt();
int endianTag = headerIn.readInt();
linkSize = headerIn.readInt();
linkOff = headerIn.readInt();
mapList.off = headerIn.readInt();
stringIds.size = headerIn.readInt();
stringIds.off = headerIn.readInt();
typeIds.size = headerIn.readInt();
typeIds.off = headerIn.readInt();
protoIds.size = headerIn.readInt();
protoIds.off = headerIn.readInt();
fieldIds.size = headerIn.readInt();
fieldIds.off = headerIn.readInt();
methodIds.size = headerIn.readInt();
methodIds.off = headerIn.readInt();
classDefs.size = headerIn.readInt();
classDefs.off = headerIn.readInt();
dataSize = headerIn.readInt();
dataOff = headerIn.readInt();
}</code></pre>
<p>从文件中读取到新旧 Dex 文件各区块的具体的数据后,就可以进行对比生成补丁包了。因为各区块的数据结构不一致,因此各区块有着相应的 diff 算法来处理各区块补丁生成与合成。算法列表如图:<br><img src="https://img.yzcdn.cn/public_files/2018/09/30/0a3063d8e5fb13f3e56a78123828d4f3.png" alt="" title=""><br>这些算法会对比新旧 Dex 文件转化成数据结构以后数据的差异,然后生成相关的操作指令,存储到补丁文件,下发到客户端。</p>
<h3>补丁的合成</h3>
<p>客户端收到补丁文件后,会使用相同的读取方式,将旧 Dex 文件转换为相关的数据结构,然后使用补丁包中的操作指令,对旧 Dex 数据进行修改,生成新 Dex 数据,最后数据写入文件,生成新 Dex 文件,这样就完成了补丁的合成。</p>
<h2>写在最后</h2>
<p>本文并没有写什么特别深入的东西,对 dex 的文件格式也没有完全描述完全。主要是给大家分享一个 dex 文件的大致结构,还有一些在实际中的应用。让大家在以后遇到相关问题的时候,可以有一些方向去了解 dex 文件,然后解决问题。最后,如果大家有任何的建议或意见,欢迎反馈。</p>
<h2>参考资源</h2>
<ul>
<li><a href="https://link.segmentfault.com/?enc=MWrW9MZWIIy5Ee59SxuYVw%3D%3D.7osbaApkpcdU1w9os16W9ncRPZfZ8zRWlbwJnbD8k7Avswj5GyNh8dACUKkZRY0f" rel="nofollow">Android 官方资料</a></li>
<li><a href="https://link.segmentfault.com/?enc=CQuf6GDwHOc%2BJj4utfDmVA%3D%3D.6Sen4XJH0y8wyCvhVJM4dWSbBrf69JaF8o18ehyfLF%2BW%2FvKv7ddy3xEm4NSn%2F%2Fgb" rel="nofollow">Tinker 介绍</a></li>
<li><a href="https://link.segmentfault.com/?enc=RIS9pW0zKgwMsUqBR%2BPGdg%3D%3D.fPRAo5VLC3h3PKSh8Xq%2FB4clV%2FWcnE05qOOOOMhU8%2BlcH1otwRaRerhWgSvbCQz3" rel="nofollow">Dalvik 和 Java 字节码的对比</a></li>
</ul>
<p><img src="/img/bV50Mk" alt="图片描述" title="图片描述"></p>
资损防控体系介绍
https://segmentfault.com/a/1190000016918813
2018-11-06T10:47:58+08:00
2018-11-06T10:47:58+08:00
有赞技术
https://segmentfault.com/u/youzantech
21
<h2>1. 资损盲区</h2>
<p>随着有赞支付体量的增大,资产部门承担的资金管理,风险把控的责任也越大。我们一方面要小步快跑,快速支撑业务,又要稳住底盘,守好底线。支付业务底线就是守护用户的每一分钱,不能有资金损失。在我们搭建这套体系前,有赞支付资金类的线上监控是个盲区,缺乏自我发现的能力。业务成功了,但内部对用户的资金操作可能是错误的,导致资损。而且故障发生到发现的时间很长,且大部分是用户上报,导致故障的影响面扩大,用户的信任度降低。 <br>预防资损有很多种手段,除了事前线下通过各种测试手段保障资金安全外,线上也是非常重要的一环。除了发现问题,相应的,出现故障时,资损止血的能力也需要配套跟上。</p>
<p>举一个最基本的支付业务场景,在有赞内部会经历以下几个系统之间的交互:<br><img src="/img/bVbi9tl?w=900&h=377" alt="图片描述" title="图片描述"></p>
<p>通过上图可以看出每个系统的处理结果,会依据系统建立的模型存储在数据库中,部分关键信息会传输给下层系统。系统之间处理的重要信息如金额、账户不一致就会导致资损。目前我们也内部对账会发现这些问题,但是内部对账都是每天跑批执行一次。如果依靠内部对账来发现这个问题,资损早就发生了。需要调用很大的人力物力去追款,大部分情况下还追不回来。我们分析了有赞近一年来的资损场景,结合历史的经验,总结出资损类故障发生有几下几大类: <br>1)有正确的输入,错误的输出:比如系统与系统之间的金额存储单位不一致,或者自己处理金额正确,传输给下游的金额错误,导致后面交易金额错误;<br>2)上下游系统的数据不一致:该处理的没处理,该到达终态的单据没有到达终态;<br>3)幂等控制失效,多扣款或多入账;<br>4)系统内部逻辑错误,无对外输出;<br>5)人工修复异常场景,产生资损。</p>
<h2>2. 资损体系的诞生</h2>
<p>基于解决以上问题的目的,我们设计了实时防控资损体系。总体设计思路围绕以下几点: <br>1)发现问题的实时性,减少故障的影响面;<br>2)信息流一致性两两比对、资金流平衡型检查;<br>3)全方位监控:业务触发、人工变更资金检测、历史数据检测;<br>4)检测的准确性,无误报;<br>5)和支付链路解藕,不影响主链路。</p>
<p>平台能力是基础,检测规则是其灵魂。基于对业务的丰富经验,我们可以沉淀一些业务资金规则,从旁路来约束和检测系统逻辑的正确性。比如支付金额-退款金额应该==结算金额,退款金额不能大于支付金额,凭证支付、现金支付无资金流类型不用调用账务,支付和账务之间会经过结算的处理,账务累计出入金额和支付的金额应该要相等。</p>
<h2>3. 系统设计:</h2>
<h3>3.1 总体设计的架构图如下:</h3>
<p><img src="/img/bVbi9uA?w=1770&h=1006" alt="图片描述" title="图片描述"></p>
<p>系统定位于事前线下测试环境兜底,事中一致性检测,事后资金兜底,不对业务造成入侵,完全旁路运行。触发点有 2 个,业务事件消息和数据库变更 binlog 信息。</p>
<p>分三类信息处理: <br>1) 基于各个业务事件比如支付完成事件、退款完成事件、确认收货时结算完成事件,账务收支明细变更事件等,触发运行系统内配置的依赖此事件的规则;<br>2) 通过监听 binlog 变更,可以检测到人为操作类变更, 按定义好的逻辑生成对应的检查点,每个检查点有包含多个链路检测。触发对应的规则运行检测全链路数据的一致性、资金的平衡性;<br>3) 人工处理历史数据前,对历史数据的质量进行前置检测。保证不产生二次资损。<br>通过系统间两两核对数据一致性,或者抽象出系统内的业务规则、资金规则旁路自检来发现故障。并且实时获取数据,实时运行,对于业务处理上有滞后和缓冲的场景,我们提供了异步运行的机制,以及三次重试的机会。全面提供系统整体的容错性,无因系统设计问题导致的误报。</p>
<h3>3.2 处理流程图如下:</h3>
<p><img src="/img/bVbi9uI?w=1059&h=438" alt="图片描述" title="图片描述"><br>经过系统的沉淀之后,我们将过程中的数据存储到了 hbase,把整个支付过程落地成了可视化试图,可清晰的查看每个环节的数据形态,具体数据就不展示了。 <br>比如一笔订单可以看到,当前已经是退款完成状态,对应的支付成功时支付、结算、计费、账务数据形态:<br><img src="/img/bVbi9uW?w=2438&h=504" alt="图片描述" title="图片描述"></p>
<p>退款完成环节支付、结算、计费、账务数据形态:<br><img src="/img/bVbi9u1?w=2450&h=514" alt="图片描述" title="图片描述"></p>
<h3>3.3 资金熔断:</h3>
<p>熔断的处理流程图如下:<br><img src="/img/bVbi9u8?w=1572&h=570" alt="图片描述" title="图片描述"></p>
<p>基于我们之前建立的异常发现能力,同时我们需要具备资金止损能力。建立后台触发熔断操作入口,人工录入熔断配置或资损防控检测出异常新增并生效熔断配置,应急情况生效熔断,日常支付链路不会过熔断判断。<br>熔断支持按业务码纬度、指定的单号、商户号熔断;<br>目前我们在业务方接入的熔断埋点有 3 个点:退款、结算、出金。为什么考虑这三个地方埋点呢? <br>1) 我们整个系统的定位都是不侵入主链路,对用户无感知的,所以支付环节不考虑埋点。且钱不能流出有赞的体系外,一旦流出则无法追回。 <br>2) 在支付链路产生的故障,考虑在退款、结算环节来做拦截,且支付完成后,钱停留在有赞的中间户,此时订正支付链路数据,对商户来说无感知。 <br>3) 一旦在结算环节出现问题,则考虑最后一道兜底,出金报送银联前进行拦截。 <br>确认无误或故障处理完成后,触发解熔断操作,业务继续处理或驳回。</p>
<h2>4. 总结</h2>
<p>建立了这一整套体系后,半年时间内,我们已经在线下环境联调时就成功兜底资金处理 bug,线上也避免了多起问题。并定期的进行故障演练来检测平台能力。<br>本文主要介绍大体的设计和实现思路,后续会有详细的技术细节介绍,敬请期待。资损防控路漫漫,共勉。<br><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
基于weex的有赞无线开发框架
https://segmentfault.com/a/1190000016850041
2018-10-30T14:45:58+08:00
2018-10-30T14:45:58+08:00
有赞技术
https://segmentfault.com/u/youzantech
33
<p>出于对开发效率和动态化的要求,无线端的开发框架也一直在更新,从 Hybrid、结构化 Native View、React Native、Weex,再到现在正在大受关注的 Flutter。什么样的框架才是适合自己的团队?不仅要有技术追求,而且要考虑实际业务需要。最近,有赞移动选择了 weex 作为无线开发框架,搭建了从开发、Debug、构建、发布、数据一个闭环的流程。本文将对此进行分享。</p>
<p><img src="/img/remote/1460000016850044?w=359&h=349" alt="开发闭环" title="开发闭环"></p>
<h2>一、什么是 weex</h2>
<p>Weex 是阿里巴巴开源的一套构建高性能、可扩展的原生应用跨平台开发方案。首先总结一下 weex 的特点:</p>
<ol>
<li>
<strong>页面的开发目前支持<a href="https://link.segmentfault.com/?enc=jfcExtzlDrQ2KnA48OCTtA%3D%3D.rqC%2B2yY29mFGaHAUU3%2BFfv1n5HB1alSmLQf01m6ElGI%3D" rel="nofollow">Rax</a>和<a href="https://link.segmentfault.com/?enc=54W2PDQr9VnA55nyyXMyCA%3D%3D.vg09GjxnyGo%2BUIxnq9g%2Bw7WqdQtcIthj0xwfyriX4%2F4%3D" rel="nofollow">Vue</a></strong><p>Weex 也不是只支持 Vue 和 Rax,你也可以把自己喜欢的前端框架集成到 Weex 中,有一个文档<a href="https://link.segmentfault.com/?enc=brnQoBWiGB50qkBJHrmDXQ%3D%3D.i4tLHZ9Yd2byjINY52tgnM%2FpPk4XrZw4DSl4Gc7Ua8cA322me5fBgWnBkzvYXRzZ8zK3Ud56IL0b7mwq%2BvIMuz4eqdnwjxCRzq0QI%2FNN%2Fv0%3D" rel="nofollow">扩展前端框架</a>描述了如何实现,但是这个过程仍然非常复杂和棘手,你需要了解关于 js-native 之间通信和原生渲染引擎的许多底层细节。</p>
</li>
<li>
<strong>一次编写,三端(Android、iOS、前端)运行</strong><p>前提是都集成了 weex sdk,另外视觉表现做不到完全一样,有的会有一些差异,需要做一下适配。所以写 weex 页面的时候,如果支持三端,便需要在三端都进行自测。</p>
</li>
<li>
<strong>UI 的绘制通过 native 的组件,JavaScript 逻辑在 JS 引擎里运行,两者通过 JavaScriptCore 通信</strong><p>weex 里使用组件都需要在 native 端注册,这样 weex 里才可以使用,运行的时候通过注册时记录的 map 进行查找。weex sdk 内置注册了一些基础的组件,包括 list、text、input 等。WXJSCoreBridge 封装了 JavaScriptCore 实现 native 和 js 之间的通信。</p>
</li>
<li>
<strong>支持 Native 扩展</strong><p>可以将 native 的 UI 组件封装成 component,将 native 的逻辑代码封装成 module。从而在 weex 里可以进行使用。这里的 natiev UI 组件包括 modal、webview、image 等,这里的 native 逻辑代码包括 storage、network 等。</p>
</li>
<li>
<strong>每个 weex 页面会被打包成一个 js 文件,weex sdk 将 js 文件渲染成一个 view</strong><br>weex 的打包通过 webpack,将每个页面打包成独立的一个 js 文件,weex sdk 会将 js 进行解析,将 UI 部分绘制成一个 view, 再绑定 view 的事件与 js 代码绑定。</li>
</ol>
<h2>二、为什么要使用weex进行无线开发</h2>
<h3>1. 效率问题</h3>
<p>1)开发的人力成本</p>
<p>如果不算 web 端,一个页面本来需要 Android 和 iOS <strong>2</strong> 个人开发;使用 weex 后只需要 <strong>1</strong> 个开发页面。</p>
<p>2)开发的编译速度</p>
<p>随着项目渐渐变得庞大,Android 项目一次编译需要 <strong>2-3 分钟</strong>,机器不好的还需要 <strong>10 分钟</strong>,iOS 可能会快一点,也需要 <strong>1-2 分钟</strong>。使用 weex 后,界面修改,只需要<strong>十几秒</strong>。</p>
<p>3)测试效率</p>
<p>提测之后,发现 bug,修复完成,测试总需要重新下载一个包进行安装;使用 weex 后,跟原生无关的 bug,只要测试重启 App 就可以进行验证。</p>
<h3>2. 动态化</h3>
<p>weex 页面最后打包完是一个 js 文件,只要能做到动态下发 JavaScript,那便可以实现动态化,可以热修复,甚至可以热部署,完全替换或者新增页面。</p>
<h3>3. 成熟度</h3>
<p>在 2016 年阿里双十一中,Weex 在阿里双十一会场中的覆盖率接近 99%,页面数量接近 2000,覆盖了包括主会场、分会场、分分会场、人群会场在内几乎所有的阿里双十一会场业务。阿里双十一主会场秒开率97%,全部会场页面达到 93%。<br>2016 年 12 月 15 日,阿里巴巴宣布将移动开源项目 Weex 捐赠给 Apache 基金会开始孵化。<br>2017 年,weex 在阿里业务里增长如下图,来自 WeexConf 2018。</p>
<p><img src="/img/remote/1460000016850045" alt="阿里业务增长" title="阿里业务增长"></p>
<h3>4. 接入成本</h3>
<p>经过实践,一个移动端开发,一周时间就可以开始进行使用 weex 进行业务开发。</p>
<h2>三、如何使用 weex 进行无线开发</h2>
<p>weex 其实是一套方案,各个流程很多东西需要自己建设,把它建设得让小伙伴可以以较小成本开始使用 weex,把它建设得融入已有的系统。这方面,我们目前做了下面这几个方面,还任重道远。</p>
<p><img src="/img/remote/1460000016850046" alt="zanweex 建设" title="zanweex 建设"></p>
<h3>1. 开发工具 zweex-toolkit</h3>
<p>这是一个脚手架工具,基于 weex 官方的 weex-toolkit,用于新建 weex 工程,目前只支持 vue。</p>
<p>随着页面的增多,业务的复杂,工程会慢慢变得庞大,每次运行的时候如果全部页面都运行起来比较慢。为了解决这个问题,使用 zweex-toolkit 创建建的工程模板支持运行的时候,支持只运行指定目录下的页面,只要在 npm start 后加上参数即可,如:</p>
<pre><code>npm run start hi,helloworld</code></pre>
<p>这样就表示只运行 hi 目录下和 helloworld 下的页面。<br>另外,我们支持:</p>
<ul>
<li>新增页面<code>zweex page</code>
</li>
<li>开启调试<code>zweex debug</code>
</li>
</ul>
<h3>2. ZanWeex SDK 的实现</h3>
<p>官方 weex sdk 做的事情,就是输入一个 js 文件,然后返回一个view。考虑到每个应用的路由和个性化的需要,这一点,ZanWeex SDK 没有做其他工作,也还是返回了一个view,业务方可以根据自己的需要将view添加到自己想要展示的地方。ZanWeex SDK 做的事情主要有如下几方面:</p>
<p>1)<strong>支持下发配置,支持动态化,可以完成整个页面的替换</strong></p>
<p>weex 页面打包后的结果是一个 js 文件,所以可以进行下发进行动态更新,那么就需要有一份配置,来关联页面路由和 js 文件的关系,于是我们设计了这样的数据结构:</p>
<p>h5:页面路由地址,可以直接使用发布平台生成的 h5 地址</p>
<p>js:打包后的 js 文件地址</p>
<p>version:支持的最低 App 版本,因为新页面如果需要 native 扩展,那就需要发布新版本进行支持</p>
<p>md5:为了校验完整性,我们在配置里添加每个 js 文件的 md5。</p>
<p>2)<strong>支持多模块独立配置,互不影响</strong><br> 一个App里会有多个模块,每个模块可能由独立的团队进行负责,所以为了减少耦合,我们将配置独立,每个模块可以独立管理自己的配置,独立接入weex,不依赖于宿主App。</p>
<p>3)<strong>预加载页面模板,支持页面模板缓存和配置缓存</strong></p>
<ul><li>如果没有缓存,每次都从服务端拉取页面模板,那么是不可能达到秒开的,跟没有做缓存的H5页面就区别不大了。我们SDK会预加载页面模板到本地,打开过的页面会缓存到内存。这样渲染的时间就更接近原生的渲染时间了。</li></ul>
<p>4)<strong>支持开发时的hot reloading,前端开发般的体验</strong></p>
<ul>
<li>如果没有hot reloading,那么每次修改完页面,都得退出页面重新进入。为了省去这个操作,hot reloading是必须的。</li>
<li>weex 工程里本地开发时候,通过webpack-dev-server来启动一个websocket,zan weex sdk 打开一个weex页面后,去与它建立连接。webpack-dev-server将工程的编译状态发送给ZanWeex SDK,当接收到渲染完成的指令时,就重新渲染页面,从而达到 hot reloading的目的。</li>
</ul>
<p>5)<strong>支持页面的适配,提供环境变量</strong><br>ZanWeex SDK 会提供以下四个变量共 weex 页面使用,方便完成页面配置。</p>
<ul>
<li>容器的高度:weex.config.yzenv.viewHeight</li>
<li>容器的宽度:weex.config.yzenv.viewWidth</li>
<li>状态栏高度:weex.config.yzenv.statusBarHeight</li>
<li>底部栏高度(针对iPhone X,其他为0):weex.config.yzenv.bottomHeight</li>
</ul>
<p>6)<strong>开发阶段日志的查看</strong><br>在开发阶段,weex sdk 源码里输出的日志以及 js 里通过 console.log 输出的日志,还有 js 运行的报错,都只能通过 XCode 和 Android Studio 进行查看。这对于一个只了解一端的开发人员是非常不方便的。于是我们做了一个入口,在打开 weex 页面的时候,会显示该入口,点击即可查看所输出的日志。</p>
<p>7)<strong>参数传递</strong><br>正向传参:从 A 页面跳转到 B 页面,参数传递是开发过程肯定会遇见的一个场景。SDK 对外提供的渲染接口 renderByH5 的参数包括 url,params,data。业务方进行渲染的时候,可以将参数直接跟在 url 后面,或者通过 params、data 传入,不同方式,取的方式也不一样:</p>
<ul>
<li>url 后面的参数,会传入 data,weex 页面里直接在 data 里定义参数就会自动赋值;</li>
<li>params的参数,在 weex 页面里可以通过 weex.config.name 来获取;</li>
<li>data 传入的参数,获取方式同第一种。</li>
<li>反向传参:从 B 页面返回到 A 页面的时候,携带参数返回也是很常见的一个场景。SDK 提供了统一的存储类 ZParamStorage 来临时存储参数。页面 B 要返回的时候先把数据存入存储区,A 页面显示的时候再从存储区获取,然后清空存储区。</li>
<li>非跳转的参数传递:weex 页面之间,可以采用 BroadcastChannel 进行传参,weex 与 native 之间的传递可以通过自己封装 Module 进行实现。</li>
</ul>
<h3>3. 页面的开发</h3>
<p>前面有提到,weex 的页面目前可以采用 vue 或者 Rax 编写。对于 Vue 和 Rax 的语法这里不做陈述。这里主要总结了容易在实际开发中卡住小伙伴的几个问题。</p>
<p>1)<strong>如何判断一个页面是否用 weex 来实现?</strong></p>
<p>可以认为所有的新页面都可以采取 weex 来开发,区别在于这个页面使用的 native 能力有多少。可以通过自定义 Module 来调用 native 的能力,通过自定义 component 来使用 native 的组件;</p>
<p>2)<strong>什么时候需要自定义 Module?</strong></p>
<ul>
<li>
<p>需要原生的能力的时候,比如:</p>
<ul>
<li>要调用系统选择图片的接口</li>
<li>调用打电话、发短信的功能</li>
<li>打开其他应用</li>
</ul>
</li>
<li>
<p>调用已有的业务逻辑,比如:</p>
<ul>
<li>加密、解密逻辑</li>
<li>登录逻辑</li>
</ul>
</li>
</ul>
<p>3)<strong>什么时候需要自定义 component?</strong></p>
<ul>
<li>如果一个组件已经使用 native 实现,为了保持统一一致,那么可以将原有的组件封装成 component</li>
<li>如果一个组件不能使用 weex 实现,比如地图组件、超长图显示等</li>
</ul>
<p>4)<strong>多个弹层的布局如何实现?</strong></p>
<p>weex 页面渲染的层级,是从上而下的,越在下面的布局,显示越上层。所以要作为弹层的布局,就把它放到最下面。</p>
<p>5)<strong>页面的动画如何实现?</strong></p>
<p>官方 weex sdk 已经封装了 animation 的 module 可以直接使用,复杂的动画可以使用 BindingX 实现。</p>
<p>6)<strong>weex 的代码如何复用?</strong></p>
<p>代码都可以抽离出组件。</p>
<ul>
<li>作为一个 UI 组件,抽离成一个组件,向外暴露属性参数和事件接口;</li>
<li>作为独立的 js 函数,抽离成一个 js 供其他页面引入;</li>
<li>css 样式也可以抽离成一个 css 文件,供其他页面引入;</li>
<li>如果包含多个组件形式,可以通过 mixins 来引入。</li>
</ul>
<h3>4. 构建和打包平台</h3>
<p>我们开发了以项目为单位的构建平台:</p>
<ul>
<li>每个项目可以添加多个分支,可以是不同仓库的分支。因为一个项目有可能是跨团队跨模块的,但是需要一起发布。</li>
<li>构建通过 webpack 构建,构建之后,支持发布线下存储和线上 cdn</li>
</ul>
<p>我们还开发了以应用为单位的 weex 发布平台:</p>
<ul>
<li>这里的应用是一个抽象概念,不是传统的“应用”,可以理解成模块</li>
<li>业务方可以在构建平台构建完成后,一键跳转到发布平台进行发布,除了需要第一次填写最低支持的版本号,其他均无需操作。</li>
<li>发布平台支持灰度发布、全量发布和回滚。</li>
<li>发布平台会展示 weex 在端上的使用情况,渲染时间、渲染错误、下载时间等</li>
</ul>
<h2>四、遇到的问题以及解决方案</h2>
<p>在开发过程中,很多问题,可以通过阅读源码来解决,比如:</p>
<ul>
<li>使用 iconfont 的时候,是否已支持缓存?<p>答:已支持,包括内存缓存和文件缓存,内存缓存使用 familyname 来做 key,文件缓存使用 md5(url) 来做本地文件名</p>
</li>
<li>module实现的函数能不能返回参数?<p>答:module 的函数氛围 UIThread 和 JSThread,JSThread 对于 js 线程来说是同步的,支持直接返回参数;UIThread 对于 JS 线程来说是异步的,不支持直接返回参数,只能使用 callback</p>
</li>
</ul>
<p>另外,很多常见的问题,我们已经在 ZanWeexSDK 进行了解决,包括实现动态化、多模块的支持、缓存管理、Hot Reloading、日志查看、页面适配、参数传递等。</p>
<p>此外,还会有一些常见的问题,在此罗列一下:</p>
<ol>
<li>配置的更新机制是怎样的?更新失败,如何打开 weex 页面?<p>答: 配置的更新接口开放给业务方调用,由业务方决定什么时候调用更新接口;SDK 里做了三种处理,来尽量保证配置可以更新成功:</p>
<p>1)配置接口拉取失败后,会有三次重试;</p>
<p>2)网络从无网变成有网时,sdk 会检查配置是否已拉取,如果未拉取就主动拉取</p>
<p>3)允许业务方内置配置和 js 文件,当拉取失败后,SDK里会从内置配置里读取</p>
</li>
<li>配置的版本管理是怎样的?<p>答:配置每次发布的时候,都会指定该发布支持的 App 最低版本号。每次请求,会携带 App 版本号,服务端只会返回符合该版本号的最新配置。</p>
</li>
<li>支持不支持屏幕旋转?<p>答:答案是支持的。旋转之后,屏幕变成了横屏,weex 就按照横屏的尺寸来渲染,问题是只要你写的页面符合这种变化就可以了,跟 native 来实现页面没有什么区别。</p>
</li>
</ol>
<h2>五、未来还要继续做的事情</h2>
<ol>
<li>组件库的建设</li>
<li>性能统计,比如帧率、内存、CPU</li>
<li>配置和js文件的增量更新、推送更新</li>
<li>降级处理</li>
</ol>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
Under the Hood: NaN of JS
https://segmentfault.com/a/1190000016802117
2018-10-25T15:31:25+08:00
2018-10-25T15:31:25+08:00
有赞技术
https://segmentfault.com/u/youzantech
15
<p>在查看本文之前,请先思考两个问题。</p>
<ol>
<li>
<code>typeof (1 / undefined)</code> 是多少</li>
<li>
<code>[1,2,NaN].indexOf(NaN)</code> 输出什么</li>
</ol>
<p>如果你还不确定这两题的答案的话,请仔细阅读本文。<br>这两题的答案不会直接解释,请从文章中寻找答案。</p>
<h2>NaN 的本质</h2>
<p>我们知道 NaN(Not A Number) 会出现在<strong>任何不符合实数领域内计算规则</strong>的场景下。比如 <code>Math.sqrt(-1)</code> 就是 NaN,而 <code>1 / 0</code> 就不是 NaN。前者属于复数的范畴,而后者属于实数的范围。</p>
<p>同时需要注意的是,NaN 只会出现在浮点类型中,而不会出现在 int 类型里(当然 JS 并没有这个概念)</p>
<p>什么意思?用你熟悉的任何支持 int 和 double 两种类型的语言(比如 C)。在保证它不会偷偷做隐式类型转换的情况下,分别用 int 和 double 打印出 <code>sqrt(-1)</code>, 你就能发现只有在 double 的类型下才能看到 NaN 出现,而 int 呢?编译器甚至会给你一个 <strong>Warning。</strong></p>
<p>那么在浮点数下是如何表示一个 NaN 的呢?为了方便,下面用单精度 float 来表示,请看下图。<br><img src="/img/remote/1460000016802120" alt="" title=""><br>在 3b 情况中,NaN 得满足:<strong>从左到右,以 1 开始,不关心第 1 位的值,第 2 位到第 9 位都是 1,剩下的位不全 为 0。</strong> 关于 <a href="https://link.segmentfault.com/?enc=qBJdOAh2cqWOJEaMz5c7kA%3D%3D.qVXUDtTYdtMNEKIDjzgNn1PxpJ9qluHRCMD7b6b23630juKQj%2F%2FD2y4ldMLMlNQr" rel="nofollow">浮点数内部的组成</a>,这里不做具体的介绍,我们只需要了解到浮点数分为 3 个部分就可以:</p>
<ol>
<li>符号位</li>
<li>指数位</li>
<li>精度位</li>
</ol>
<p>其中 float 的指数位有 8 位,精度位有 32 - 1 - 8 = 23 位<br>double 的指数位有 11 位,精度位有 64 - 1 - 11 = 52 位<br>所以上面 NaN 的满足条件,可以看成:<strong>精度位不全为 0,指数位全 1</strong> 就可以了。</p>
<p>所以按上面的说法,<code>0x7f81111, 0x7fcccccc</code> 等等这些都符合 NaN 的要求了。我们可以尝试一下,自己写一个函数,用来往 <strong>8</strong> 个字节的内存的前两个字节写入全 1. 也就是连续 16 个 1,这就符合 NaN 的定义了。看下面这段代码:</p>
<pre><code class="c">double createNaN() {
unsigned char *bits = calloc(sizeof(double), 1);
// 大部分人的电脑是小端,所以要从 6 和 7 开始,而不是 0 和 1
// 不清楚概念的可以参考阮老师:
// [理解字节序 - 阮一峰的网络日志](http://www.ruanyifeng.com/blog/2016/11/byte-order.html)
bits[6] = 255;
bits[7] = 255;
unsigned char *start = bits;
double nan = *(double *)(bits);
output(nan);
free(bits);
return nan;
}</code></pre>
<p>其中 output 是一个封装,用来输出任意一个 double 的内部二进制表示。详细代码查看 <a href="https://gist.github.com/thoamsy/28b278a098d0daeb495c8dbeca40cc1a">gist</a>。<br>最后我们得到了:<br><img src="/img/remote/1460000016802121" alt="" title=""></p>
<p>看来创造一个 NaN 不是很难,对吧?<br>同样的,为了证明上面的图的正确性,再看看 <code>Infinity</code> 的内部结构是否符合<br><img src="/img/remote/1460000016802122" alt="" title=""></p>
<hr>
<h3>两种 NaN</h3>
<p>如果再细分的话,NaN 还可分为两种:</p>
<ol>
<li>Quiet NaN</li>
<li>Signaling NaN</li>
</ol>
<p>从性质上,可以认为第一种 NaN 属于“脾气比较好”,比较“文静”的一种,你甚至可以直接定义它,并使用它。<br>比如我们在 JS 中可以使用类似于 <code>NaN + 1, NaN + '123'</code> 的操作,还不会报错。</p>
<p>而 Signaling NaN 就是一个“爆脾气”。如果你想直接操作它的话,会抛出一个异常(或者称为 Trap)。也就不允许 NaN + 1 这种操作了。像这种不好惹的 NaN,根据 WiKi 中的介绍,它可以被用来:</p>
<blockquote>Filling uninitialized memory with signaling NaNs would produce the invalid operation exception if the data is used before it is initialized<br>Using an sNaN as a placeholder for a more complicated <a>object</a> , such as:<br>A representation of a number that has <a href="https://link.segmentfault.com/?enc=RwXxEC052TsuA9XuqqR8PA%3D%3D.HOOs59ba%2FdzbsaYNzudMtJr%2FMQ2B022x%2FTBBMA3%2B9BUmBsR0%2BBpPhW%2FEqSFggrpqA6EzdA6nUe6uOgsvCHy4MA%3D%3D" rel="nofollow">underflowed</a><br>A representation of a number that has <a href="https://link.segmentfault.com/?enc=%2BLaWU6tfl3TP%2BK97l03TSg%3D%3D.bVUOFtP7NaBs%2FaDu%2BO1t2vhuPBbHhOzTok%2BHzq9vulGUEPv%2FQSVgwbZIaUZSpqm1qa%2FQTtEMT1e48FhR69xOfQ%3D%3D" rel="nofollow">overflowed</a><br>Number in a higher precision format<br>A <a href="https://link.segmentfault.com/?enc=pwIgZ0FyAMxsfgYXyhULuQ%3D%3D.2Fw2SzKLcTtY1NJxaf7bJ3vZ5GQ3QZ1ZRbBbK3dmSBfoQMjqcNQU94jGKX8zp%2FZP" rel="nofollow">complex number</a>
</blockquote>
<h2>NaN != NaN</h2>
<p>如果换个角度理解,因为 NaN 的表示方式实在太多,仅仅在 float 类型中,就有 <em>2^(32-8)</em> 中情况,所以 NaN 碰到一个和它二进制表示一模一样的概率实在太低了,所以我们可以认为 <strong>NaN 不等于 NaN</strong> 😏</p>
<p>嗯。看上去似乎问题不大,但是我们都知道计算机在大多数情况下,都是按规矩办事,这种玄学问题肯定不是内部的本质吧?要是真这样,世界上每一个程序员同时输出 <code>NaN === NaN</code>,总有一个人会得到 true,然后他就到 stackoverflow 上发了一个帖:<strong>你看 NaN 其实是会等于 NaN 的!</strong> 但我们从来没有见过这样的帖子,所以计算机内部肯定不是用这种颇为靠运气的方式在处理这个问题。</p>
<p>考虑换一种方式,假设计算机内部是通过<strong>位运算</strong>来判断的。如果某一个数的内部结构满足<strong>第 2 位到第 9 位全 1,剩下的 22 位不为 0</strong>,那它就是 NaN。我们可以这样写</p>
<pre><code class="c">_Bool isnan(double whatever) {
long long num = *(long long *)(&whatever); // 浮点数不能进行位运算,所以要改成整数类型,同时保留内部的二进制组成
long long fmask = 0xfffffffffffff; // 不要数了,13 个 f,52 个 1
long long emask = 0x7ff; // 11 个 1
num <<= 1;
num >>= 1; // 清除符号位
return ((num & fmask) != 0) && (((num >> 53) & emask) == emask);
}</code></pre>
<p>你可以试着把这段 C 代码运行一下,配合上面的 <code>createNaN</code> 可以试一下,他是真的可行的!</p>
<p>接着要实现 NaN != NaN 的特性,只需要在每次 == 的时候进行检测:只要有一个操作数是 NaN,那么就返回 false。</p>
<h2>实际情况下的 NaN != NaN 的实现</h2>
<p>那么实际情况到底是怎样的呢?不同的系统会有不同的实现。</p>
<p>在 Apple 实现的 <a href="https://link.segmentfault.com/?enc=WrldSk3Fzy278L%2FMB44OLg%3D%3D.0RTP2TIITnVMl%2B5frM4%2BBvffwG1xHoPL6lNjYY9qA1qTR0INS%2BcqexyPmc3ProjQT4SxPMIknkXh5YC1eo5BWu2hUkTATmZIA7anSPdALFo%3D" rel="nofollow">C 库的头文件中</a>,可以看到,nan 在 float 下,仅仅就是一个数,它等于 <strong>0x7fc00000</strong>,也就是 <strong>0b0111 1111 1100 0000 0000 0000 0000 0000</strong>,符合上面的 NaN 的定义。<br><code>#define NAN __builtin_nanf("0x7fc00000")</code><br>而它们的 <code>isnan</code> 的实现也相当简单</p>
<pre><code class="c">#define isnan(x) \
(sizeof (x) == sizeof(float) ? __inline_isnanf((float)(x)) \
: sizeof (x) == sizeof(double) ? __inline_isnand((double)(x)) \
: __inline_isnan ((long double)(x)))
static __inline__ int __inline_isnanf( float __x ) {
return __x != __x;
}
static __inline__ int __inline_isnand( double __x ) {
return __x != __x;
}
static __inline__ int __inline_isnan( long double __x ) {
return __x != __x;
}</code></pre>
<p>仅仅只是简单的判断自己是否等于自己 🌚。在 C 中具体如何实现 <code>x !== x</code>,有两种可能:</p>
<ol>
<li>硬件支持 NaN 异常,所以永远都是 false</li>
<li>像下文中提到的 V8 的实现方式</li>
</ol>
<p>而在 V8 中,分为两个阶段:/Compile Time and Runtime/。</p>
<p>在 Compile Time,编译器如果在代码中碰到了 NaN 常量,就会自动将替换成 NaN 对应的那个常量,比如上文提到的 <strong>0x7fc00000</strong>。因为编译器已经明确知道了谁是 NaN,所以在写出形如 <code>NaN === NaN</code> 这种代码的时候,就能直接得到 false。</p>
<p>而在 Runtime 阶段,不是用户直接定义的 NaN,比如下面代码:</p>
<pre><code class="js">const obj = { a: 1, b: 2 };
let { c, d } = obj;
c *= 100;
d *= 100;
console.log(c === d);</code></pre>
<p>这种情况下,我们虽然一眼可以看出最后的 c 和 d 都是 undefined,但是编译器刚开始不知道,所以它只能在最后判等的时候,才能得到结果。而具体判断的逻辑如下图所示:<strong>我们先检查,操作数是否有 NaN,如果有?那就返回 false 吧</strong></p>
<p><img src="/img/remote/1460000016802123" alt="" title=""></p>
<p>所以 <code>Number.isNaN</code> 的 polyfill 可以怎么实现呢?</p>
<pre><code>Number.isNaN = function(value) {
return value !== value;
}</code></pre>
<p>就是这么简单 😎</p>
<h3>参考文献</h3>
<ul>
<li><a href="https://link.segmentfault.com/?enc=L3kLgnavy%2F4QVwq993Wg4g%3D%3D.WU3ty4ZFMoDChXKH3FZrlfTPedywFbH6YOG4fQWk7F9jVt21lSt5rJOgW0IOIHmaPIRFh1iF7Am1PEp0X2XfcQ%3D%3D" rel="nofollow">理解字节序 - 阮一峰的网络日志</a></li>
<li><a href="https://link.segmentfault.com/?enc=pN%2Bh7QHHqcV%2Fn7m0yDt9eQ%3D%3D.tTg%2BEM33cFQx1DfloSL4qqf%2ByeybpIh9FeHmZ%2BUt0ovMYc50xgvGlSeUT%2B1wAFHGvGydnf2mzKorD6OZcNKtxEzYy2D5AEuMXMGk%2BiAuti0%3D" rel="nofollow">NaN is not equal to NaN</a></li>
<li><a href="https://link.segmentfault.com/?enc=EJSGi5TDcQePsJ%2B6uWqNNA%3D%3D.FDO5%2Fmr%2F%2B79qgdfG%2F5Ed5A3YFvrRolq03dBQMOL1z%2FYrdpx%2BbCF8AXLhOzeBYnw4" rel="nofollow">Quiet NaN</a></li>
<li><a href="https://link.segmentfault.com/?enc=REw0QCLjPEUbsyys5IcbJQ%3D%3D.klck1fdrIlPe9mWGwGHuqOipxWelcKU1sT4OgPb7%2Bx8mhC4lfW5FVkLY3gcjSkYu" rel="nofollow">深入理解计算机系统(原书第 3 版) (豆瓣)</a></li>
</ul>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
有赞容器化实践
https://segmentfault.com/a/1190000016551255
2018-09-28T18:00:00+08:00
2018-09-28T18:00:00+08:00
有赞技术
https://segmentfault.com/u/youzantech
51
<h2>前言</h2>
<p>容器化已经成为一种趋势,它可以解决很多运维中的痛点,比如效率、成本、稳定性等问题,而接入容器的过程中往往也会碰到很多问题和不便。在有赞最开始做容器化是为了快速交付开发测试环境,在容器化的过程中,我们碰到过容器技术、运维体系适配、用户使用习惯改变等各种问题,本文主要介绍有赞容器化过程中碰到的问题以及采取的方案。</p>
<h2>有赞容器化的初衷</h2>
<p>在有赞同时会有很多个项目、日常在并行开发,环境的抢占问题严重影响了开发、测试和上线的效率,我们需要给每个项目提供一套开发联调(daily)、测试环境(qa),并且随着项目、日常的生命周期项目环境也会随着创建和销毁,我们最早的容器化需求就是怎么解决环境快速交付的问题。</p>
<p>[有赞环境]<br><img src="/img/bVbhBR0?w=943&h=564" alt="图片描述" title="图片描述"><br>上面是有赞大致的研发流程,在标准流程中我们有四套稳定环境,分别是 Daily 环境、Qa 环境、预发环境和测试环境。我们的开发、测试、联调工作一般并不会直接在稳定环境中进行,而是会拉一套独立的项目环境出来,随着代码经过开发、测试、预发验收最终发布到生产环境后再同步回 Daily/Qa 的稳定环境中。</p>
<p>[项目环境]<br><img src="/img/bVbhBSc?w=2885&h=1230" alt="图片描述" title="图片描述"></p>
<p>我们提供了一套以最小的资源投入满足最大项目并行度的环境交付方案,在 Daily/Qa 稳定环境的基础上,隔离出N个项目环境,在项目环境里只需要创建该项目所涉及应用的计算资源,其它缺失的服务调用由稳定环境提供,在项目环境里,我们大量使用了容器技术。</p>
<p>[持续交付]<br><img src="/img/bVbhBSi?w=2798&h=1531" alt="图片描述" title="图片描述"></p>
<p>后面我们又在项目环境快速交付的解决方案的基础上实现了持续交付流水线,目前已经有超过 600 套项目/持续交付环境,加上 Daily/Qa 稳定环境,涉及计算实例四五千个,这些计算实例无论是 cpu 还是内存使用率都是非常低的,容器化可以非常好的解决环境交付的效率问题,以及提高资源使用率来节省成本的投入。</p>
<h2>有赞容器化方案</h2>
<p>我们的容器化方案基于 kubernetes(1.7.10)和 docker(1.12.6)、docker(1.13.1),下面介绍一下我们在各个方面遇到的问题以及解决方案。</p>
<h3>网络</h3>
<p>有赞后端主要是 java 应用,采用定制的 dubbo 服务化方案,过程中无法做到整个单元全量容器化,和原有集群在网络路由上互通也就成了刚需,由于我们无法解决公有云上 overlay 网络和公有云网络的互通问题,所以一开始我们放弃了 overlay 网络方案,采用了托管网络下的 macvlan 方案,这样既解决了网络互通的问题也不存在网络性能问题,但是也就享受不到公有云弹性资源的优势了。随着有赞多云架构的发展以及越来越多的云厂商支持容器 overlay 网络和 vpc 网络打通,弹性资源的问题才得到了缓解。</p>
<h3>隔离性</h3>
<p>容器的隔离主要利用内核的 namespace 和 cgroup 技术,在进程、cpu、内存、IO等资源隔离限制上有比较好的表现,但其他方面和虚拟机相比存在着很多的不足,我们在使用过程中碰到最多的问题是容器里看到的 cpu 数和内存大小不准确,因为/proc文件系统无法隔离,导致容器里的进程"看到"的是物理机的 cpu 数以及内存大小。</p>
<h4>内存问题</h4>
<p>我们的 java 应用会根据服务器的内存大小来决定 jvm 参数应该怎么配置,我们是采用 lxcfs 方案来规避的。<br><img src="/img/bVbhBSx?w=1833&h=300" alt="图片描述" title="图片描述"></p>
<h4>CPU 数的问题</h4>
<p>因为我们有超卖的需求以及 kubernetes 默认也是采用 cpu share 来做 cpu 限制,虽然我们使用了 lxcfs,CPU 数还是不准的。jvm 以及很多 Java sdk 都会根据系统的 CPU 数来决定创建多少线程,导致 java 应用在线程数和内存使用上都比虚拟机多的多,严重影响运行,其他类型的应用也有类似的问题。<br>我们会根据容器的规格内置一个环境变量 NUM_CPUS,然后比如 nodejs 应用就会按照这个变量来创建它的 worker 进程数。在解决 java 类应用的问题时,我们索性通过 LD_PRELOAD 将 JVM_ActiveProcessorCount 函数覆盖掉,让它直接返回 NUM_CPUS 的值[1]。</p>
<h3>应用接入</h3>
<p>在容器化之前,有赞的应用已经全部接入到发布系统,在发布系统里已经标准化了应用的打包、发布流程,所以在应用接入方面成本还是比较小的,业务方无需提供 Dockerfile。</p>
<ol>
<li>nodejs, python,php-soa 等用 supervisord 托管的应用,只需要在 git 仓库里提供 app.yaml 文件定义运行需要的 runtime 和启动命令即可。<img src="/img/bVbhBSA?w=1097&h=110" alt="图片描述" title="图片描述">
</li>
<li>java 标准化启动的应用业务方无需改动</li>
<li>java 非标准化的应用需要做标准化改造</li>
</ol>
<h3>镜像集成</h3>
<p><img src="/img/bVbhBSN?w=951&h=452" alt="图片描述" title="图片描述"><br>容器镜像我们分了三层,依次为 stack 层(os),runtime 层(语言环境),应用层(业务代码和一些辅助agent),应用以及辅助 agent 由 runit 来启动。由于我们的配置还没有完全分离,在应用层目前还是每个环境独立打包,镜像里除了业务代码之外,我们还会根据业务的语言类型放一些辅助的 agent。我们一开始也想将各种 agent 拆成多个镜像,然后每个 pod 运行多个容器,后来因为解决不了 pod 里容器的启动顺序(服务启动有依赖)问题,就把所有服务都扔到一个容器里去运行了。</p>
<p><img src="/img/bVbhBSY?w=1243&h=865" alt="图片描述" title="图片描述"><br>我们的容器镜像集成过程也是通过 kubernetes 来调度的(会调度到指定的打包节点上),在发布任务发起时,管控系统会在集群中创建一个打包的 pod,打包程序会根据应用类型等参数编译代码、安装依赖,并且生成 Dockerifile,然后在这个 pod 中使用 docker in docker 的方式来集成容器镜像并推送到仓库。<br>为了加速应用的打包速度,我们用 pvc 缓存了 python 的 virtualenv,nodejs 的 node_modules,java 的 maven 包等文件。另外就是 docker 早的版本里,Dockerfile ADD 指令是不支持指定文件属主和分组的,这样会带来一个问题就是需要指定文件属主时(我们的应用是以 app 账号运行的)需要多运行一次 RUN chown,这样镜像也就多了一层数据,所以我们打包节点的 docker 版本采用了官方比较新的 ce 版本,因为新版本支持 ADD --chown 特性。</p>
<h3>负载均衡(ingress)</h3>
<p><img src="/img/bVbhBS1?w=1021&h=597" alt="图片描述" title="图片描述"><br>有赞的应用内部调用有比较完善的服务化和 service mesh 方案,集群内的访问不用过多考虑,负载均衡只需要考虑用户和系统访问的 http 流量,在容器化之前我们已经自研了一套统一接入系统,所以在容器化负载均衡上我们并没有完整按照 ingress 的机制来实现 controller,ingress 的资源配置是配在统一接入里的,配置里面转发的 upstream 会和 kubernetes 里的 service 关联,我们只是做了一个 sync 程序 watch kube-api,感知 service 的变化来实时更新统一接入系统中 upstream 的服务器列表信息。</p>
<h3>容器登录和调试</h3>
<p><img src="/img/bVbhBS3?w=3243&h=864" alt="图片描述" title="图片描述"><br>在容器化接入过程中开发会反馈是控制台比较难用,虽然我们优化了多次,和 iterm2 等的体验还是有所不足,最终我们还是放开了项目/持续交付环境这种需要频繁登陆调试的 ssh 登陆权限。<br>另外一个比较严重的问题是,当一个应用启动后健康检查有问题会导致 pod 一直在重新调度,而在开发过程中开发肯定是希望看到失败现场的,我们提供了调试发布模式,让容器不做健康检查。</p>
<h3>日志</h3>
<p><img src="/img/bVbhBTe?w=1101&h=442" alt="图片描述" title="图片描述"><br>有赞有专门的日志系统,我们内部叫天网,大部分日志以及业务监控数据都是通过 sdk 直接打到天网里去了,所以容器的标准输出日志仅仅作为一种辅助排查问题的手段。我们容器的日志收集采用的是 fluentd,经过 fluentd 处理后按照天网约定的日志格式打到 kafka,最终由天网处理进入 es 做存储。</p>
<h3>灰度发布</h3>
<p>我们涉及到灰度发布的流量主要包含三部分:</p>
<ol>
<li>用户端的 http 访问流量</li>
<li>应用之间的 http 调用</li>
<li>应用之间的 dubbo 调用</li>
</ol>
<p>首先,我们在入口的统一接入上统一打上灰度需要用的各种维度的标签(比如用户、店铺等),然后需要对统一接入、http client 以及 dubbo client 做改造,目的是让这些标签能够在整个调用链上透传。我们在做容器灰度发布时,会发一个灰度的 deployment,然后在统一接入以及灰度配置中心配置灰度规则,整个链路上的调用方都会感知这些灰度规则来实现灰度发布。</p>
<h2>标准环境容器化</h2>
<h3>标准环境的出发点</h3>
<ol>
<li>和项目环境类似,标准稳定环境中的 daily,qa,pre 以及 prod 中超过一半运行在低水位的服务器的资源非常浪费。</li>
<li>因为成本考虑 daily,qa,pre 里都是以单台虚拟机运行的,这样一旦需要发布稳定环境将会造成标准稳定环境和项目环境的短暂不可用。</li>
<li>虚拟机交付速度比较慢,使用虚拟机做灰度发布也比较复杂。</li>
<li>虚拟机往往会存在几年甚至更长的时间,运行过程中操作系统以及基础软件版本的收敛非常麻烦。</li>
</ol>
<h3>标准环境容器化推进</h3>
<p>经过之前项目/持续交付的上线和迭代,大部分应用本身已经具备了容器化的条件。不过对于上线来说,需要整个运维体系来适配容器化,比如监控、发布、日志等等。目前我们生产环境容器化准备基本完成,生产网已经上了部分前端 nodejs 应用,其他应用也在陆续推动中,希望以后可以分享更多生产环境中的容器化经验。</p>
<h2>结束语</h2>
<p>以上是有赞在容器化上的应用,以及在容器化过程中碰到的一些问题和解决方案,我们生产环境的容器化还处于开始阶段,后面还会碰到各种个样的问题,希望能够和大家互相学习,后面能够有更多的经验分享给大家。</p>
<h2>参考文献</h2>
<p>[1] <a href="https://link.segmentfault.com/?enc=eM2AQaTr%2B3Y7ienIdMtIZQ%3D%3D.FlOr5MdPGmP42kY5TeighN8Pn%2FEopIF8D72Hkvk8710bFGjSisNwXNvgbONjD9OaLXYwzAYcFOV%2ByvWqwWuxzQ%3D%3D" rel="nofollow">https://github.com/fabianenar...</a></p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
有赞搜索系统的技术内幕
https://segmentfault.com/a/1190000016441109
2018-09-18T15:37:48+08:00
2018-09-18T15:37:48+08:00
有赞技术
https://segmentfault.com/u/youzantech
41
<p><a href="https://link.segmentfault.com/?enc=w%2BBZiBvRxncnnDvQmGN6mQ%3D%3D.Txlkr%2FAzyKhQWa1QjC6PEJqqrAulK2F5hEz73cX0jN2CLVj6L8Psbx438zlx6bvx" rel="nofollow">上文</a>说到有赞搜索系统的架构演进,为了支撑不断演进的技术架构,除了 Elasticsearch 的维护优化之外,我们也开发了上层的中间件来应对不断提高的稳定性和性能要求。</p>
<p>Elasticsearch 的检索执行效率可以表示为:<br><em>O(num_of_files </em> logN)*</p>
<p>其中 num_of_files 表示索引文件段的个数,N 表示需要遍历的数据量,从这里我们可以总结出提升查询性能可以考虑的两点:</p>
<ol>
<li>减少遍历的索引文件数量</li>
<li>减少遍历的索引文档总数</li>
</ol>
<p>从 Elasticsearch 自身来说,减少索引文件数量方面可以参考几点:</p>
<ol>
<li>通过 optimize 接口强制合并段</li>
<li>增大 index buffer/refresh_interval,减少小段生成,控制相同数量的文档生成的新段个数</li>
</ol>
<p>不论是强制合并或者 index buffer/refresh_interval,都有其应用场景的限制,比如调整 index buffer / refresh_interval 会相应的延长数据可见时间;optimize 对冷数据集比较适用,如果数据在不断变化过程中,除了新段的生成,老数据可能因为旧段过大而得不到物理删除,反而造成较大的负担。</p>
<p>而减少文档总数方面,也可以做相应的优化:</p>
<ol>
<li>减少文档更新</li>
<li>指定 _routing 来路由查询到指定的 shard</li>
<li>通过 rollover 接口进行冷热隔离</li>
</ol>
<p>这里尤其需要注意的是减少文档更新,由于 LSM 追加写的数据组织方式,更新数据其实是新增数据+标记老数据为删除状态的组合,真实参与计算的数据量是有效数据和标记删除的数据量之和,减少文档更新次数除了减少标记删除数据之外,还可以降低段 merge 以及索引刷新的消耗。</p>
<p>考虑到实际的业务场景,如果将海量数据存储于单个索引,由于 shard 个数不可变,一方面会使得索引分配大量的 shard,数据量持续增长会逐渐拖慢索引访问性能,另一方面想要通过扩展 shard 提高读写性能需要重建海量数据,成本相当高昂。</p>
<h2>索引拆分</h2>
<p>为了增强索引的横向扩容能力,我们在中间件层面进行了索引拆分,参考实际的业务场景将大索引拆分为若干个小索引。<br>在索引拆分前,首先需要检查索引对应业务是否满足拆分的三个必要条件:</p>
<ol>
<li>读写操作必定会带入固定条件</li>
<li>读写操作维度唯一</li>
<li>用户不关心全局的搜索结果</li>
</ol>
<p>比较典型的比如店铺内商品搜索,不论买卖家都只关心固定店铺内的商品检索结果,没有跨店铺检索需求,后台店铺与商品也有固定的映射关系,这样就可以在中间件层面对读写请求进行解析,并路由到对应的子索引中,减少遍历的文档总量,可以在性能上获得明显的提升。</p>
<p><img src="/img/bVbg9dS?w=536&h=333" alt="图片描述" title="图片描述"></p>
<p>相对 Elasticsearch 自带的 _routing,这个方案具备更加灵活的控制粒度,比如可以配置白名单,将部分店铺数据路由到其他不同 SLA 级别的索引或集群,当然可以配合 _routing 以获得更好的表现。</p>
<p>索引拆分首先会带来全局索引文件数据上升的问题,不过因为没有全局搜索需求,所以不会带来实质的影响;其次比较需要注意的是数据倾斜问题,在拆分前需要先通过离线计算模拟索引拆分效果,如果发现数据倾斜严重,就可以考虑将子索引数据进行重平衡。</p>
<p><img src="/img/bVbg9d6?w=815&h=490" alt="图片描述" title="图片描述"></p>
<p>如图所示,数据重平衡在原有的拆分基础上加入一个逻辑拆分步骤:</p>
<ol>
<li>数据首先拆分为 5 个逻辑索引</li>
<li>设定重平衡因子,假设为 N</li>
<li>根据重平衡因子将逻辑索引数据顺序哈希到N个连续的物理索引中</li>
</ol>
<p><img src="/img/bVbg9eb?w=583&h=297" alt="图片描述" title="图片描述"></p>
<p>如图,按平衡因子3重平衡之后文档数据量的最大差值从 510 降为了 160,单索引占全局数据量比例从之前的 53% 降低为 26%,能够起到不错的数据平衡效果。</p>
<h2>冷热隔离</h2>
<p>在查询维度不唯一的场景下,索引拆分就不适用了,为了解决此类场景下的性能问题,可以考虑对索引进行冷热隔离。<br>比如日志/订单类型的数据,具备比较明显的时间特征,大量的操作都集中在近期的一段时间内,这时就可以考虑依据时间字段对其拆分为冷热索引。</p>
<p><img src="/img/bVbg9ei?w=559&h=319" alt="图片描述" title="图片描述"></p>
<p>Elasticsearch 自带有 rollover 接口供索引进行自动轮转,通过索引存活时间和保存的文档数量作为轮转条件,满足其中之一即可创建一个新索引并将其作为当前的活跃索引。<br>在实际应用过程中,rollover 接口需要用户感知活跃索引的变更,且自行计算查询需要访问的索引范围,为了对用户屏蔽底层这些复杂操作的细节,我们在中间件封装了索引的冷热隔离特性,从用户视角只须访问固定索引并带入固定字段即可。</p>
<p><img src="/img/bVbg9el?w=399&h=229" alt="图片描述" title="图片描述"></p>
<p>首先配置路由表,根据不同的时间跨度划定不同的路由规则,比如业务初期数据增量并不大,可以用50的时间跨度创建子索引,后期业务增量变大后逐渐缩短时间跨度至 10 来创建子索引。<br>通过查询条件的起止时间点分别从路由表中计算对应的子索引偏移量,得到起止范围,以上图为例,连续的子索引范围为 index2~index14,也就是该条件将命中此区间内的全部子索引,写操作也类似,区别在于写数据只会落到一个子索引,一般是当前的活跃索引。<br>这样冷热隔离的方式拆分可以兼容多维度的查询需求,比如订单的买卖家查询维度,而且拆分规则比较灵活,可以动态调整,另外删除数据只需要删除整个过期索引,而不必通过 delete_by_query 的方式缓慢删除索引数据。<br>除此之外,为了更好的配合用户使用,我们还对此开发了索引的自动轮转/定时清理等辅助功能。</p>
<h2>HA</h2>
<p>随着搜索系统的广泛使用,用户对系统的稳定性也提出了更高的要求,比如在机房发生断电等故障情况下,依然能够保证服务可用,这就需要我们能够将数据进行跨机房复制同步。<br>首先考虑的方案是跨机房组建 Elasticsearch 集群,这样实现非常简单,但是问题是机房的网络交互是走专线的,在集群宕机恢复数据过程中会有很大的带宽消耗,可能引起机房间的网络拥堵,因此这个方案并不可行。<br>Elasticsearch 本身也在开发 Changes API 特性,可以用于跨集群的数据同步,但可惜的是该特性仍然在开发中,在参考了主流的数据同步方法后,我们在中间件层开发了一套异步数据复制系统。</p>
<p><img src="/img/bVbg9et?w=487&h=360" alt="图片描述" title="图片描述"></p>
<p>在确认索引开启多机房复制后,首先在 proxy 侧启动增量同步,发送同步消息给 mq 作为同步程序 clones 的增量数据源,然后通过 reindex 功能从主索引全量复制数据到从索引。</p>
<p>在跨机房同步过程中,数据容易因为 MQ、proxy 异步发送等影响而乱序,Elasticsearch 可以通过乐观锁来保证数据变更的一致性,避免乱序的影响,前提是 version 能够一直保持在索引中。<br>但是在物理删除模式下,由于数据被物理清理,无法继续保持版本号的延续,这就有可能导致跨机房数据同步的脏写。</p>
<p><img src="/img/bVbg9eA?w=722&h=486" alt="图片描述" title="图片描述"></p>
<p>为了避免乐观锁失效,我们的解决方法是软删除的方式:</p>
<ol>
<li>delete 操作在中间件转换为 index 操作,文档内容仅包含一个特殊字段,不会命中正常的搜索条件,也就是正常情况下无法搜索得到该文档,达到实际的删除效果</li>
<li>在中间件将 create/get/update/delete 等操作转换为 script 请求,保持原有语义不变</li>
<li>通过软删除文档中特殊字段记录的时间戳定时清理数据(可选)</li>
</ol>
<p>为了能够感知到主从索引间的数据一致性和同步延迟,还有一套辅助的数据对账系统实时运行,可以用于主从索引数据的校验、修复并通过 MQ 消费延时计算主从数据的延迟。</p>
<h2>小结</h2>
<p>到这里有赞搜索系统的大致框架已经介绍完毕,因为篇幅的原因还有很多细节的功能设计并没有完整表述,也欢迎有兴趣的同学联系我们一起探讨,有表述错误的地方也欢迎大家联系我们纠正。<br>关于 Elasticsearch 方面本次的两篇文章都没有太多涉及,后续待我们整理完善之后会作为一个扩展阅读奉送给大家。</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
有赞线上拨测系统实践(一)
https://segmentfault.com/a/1190000016338794
2018-09-10T15:35:51+08:00
2018-09-10T15:35:51+08:00
有赞技术
https://segmentfault.com/u/youzantech
8
<h2>前言</h2>
<p>一直以来,作为互联网软件工程师接触最多的事务之一便是持续集成(Continuous integration,简称 CI)。持续集成俨然已成为主流互联网软件开发流程中一个重要的环节。现今有赞内部在实践持续交付(Continuous delivery,简称 CD),它可以被看成是后持续集成时代的产物。需要强调的是,不管是 CI 还是 CD,更多的是强调作为软件开发交付过程中的实践,而一旦交付到生产环境CI和CD就无能为力了。有赞线上拨测系统正是为了弥补这一不足。现有的线上保障手段可分为运维层面、产品层面、安全层面、服务层面和测试层面等维度。本文重点介绍我们在测试层面的实践。</p>
<h2>基于测试脚本的线上监控产生</h2>
<p>我们做测试线上拨测系统的初衷有以下几点:</p>
<ol>
<li>主动预警线上问题。有赞有很多个业务线,各个业务线有不同的开发测试同学对接,我们很难做到每次发布都把影响面评估得十分准确。运维层面的监控更多的是被动告警,即用户流量触发了线上 bug,我们才会收到报警,用户体验不够好。我们需要在线上 bug 预警方面变被动为主动,周期性地知晓各个业务线的健康状况。</li>
<li>小流量下敏捷发现线上问题。通常我们软件的发布都是在凌晨流量非常低的时候进行。发布完成后,回归时间长(靠手动),测试面有限(无法做到次次发布全量回归)。此时需要敏捷构造一波覆盖面全的流量,在小流量背景下,敏捷发现线上问题。</li>
<li>知晓紧急情况下业务的受影响范围以及后续收敛情况。例如当生产环境出现网络异常等非软件故障时,需要清楚业务层面的影响;当网络恢复后,需要知道业务影响是否都已经收敛。</li>
</ol>
<p>在此之前这些场景都需要测试人员手工介入,灵活度敏捷度都非常差。有了这套系统后,测试人员可以增加自己关注的场景,场景可以通过主动触发和定时触发来执行,通过告警系统通知到有关人员,做到第一时间排查问题,减少故障影响,降低故障时长。</p>
<h2>基础版</h2>
<p>1.0 版本我们使用通用的 SpringWeb 搭建,有赞内部称为线上机器人检查。系统结构如下</p>
<p><img src="/img/bVbgIAt?w=1021&h=538" alt="图片描述" title="图片描述"></p>
<p>1.0 版系统架构图</p>
<p>系统主要为三个模块:</p>
<ol>
<li>任务调度模块。该模块将用例执行封装成系统任务,使用 Spring Quartz 来定时触发。对外提供 API 对接有赞发布平台,每当系统发布上线完成后主动触发用例执行。</li>
<li>测试用例模块。包括业务访问,断言和告警。测试场景需要各个业务线的测试同学投入开发。</li>
<li>告警模块。对接有赞内部告警平台。</li>
</ol>
<p><img src="/img/bVbgIAJ?w=580&h=450" alt="图片描述" title="图片描述"></p>
<p>1.0版流程图</p>
<p>系统将用例分为基础用例和场景用例,支持场景并发或者顺序同步执行。具体执行策略由用例设计者结合具体情况在用例开发过程中设定。</p>
<h3>存在的问题</h3>
<p>基础版满足了最小可用,这种方式优点在于前期能够快速投入使用,且对于经常写集成用例的人来说成本不高,但对其他人(测试新人、开发、运维等)则不然。概括而言,其缺点主要集中在以下几点:</p>
<ol>
<li>业务线一旦多起来,用例代码开发成本提高;</li>
<li>随着用例数量增加,后期用例维护成本很大;</li>
<li>用例上线不灵活,每次用例改动需要重新发布;</li>
<li>无法直观看到运行情况和业务覆盖情况;</li>
<li>每次执行不区分业务,全量执行;</li>
<li>用例代码存在冗余,效率比较低。</li>
</ol>
<h2>配置化和可视化</h2>
<p>由于这些不可规避的问题,我们重新设计并发布了 2.0 版本。对应解决以上问题:</p>
<ol>
<li>测试用例和测试场景支持配置化,可以从管理平台上配置;</li>
<li>用例配置标准化,给定标准用例结构和断言策略;</li>
<li>通过管理平台来管理自己的用例,用例改动实时生效,无需发布;</li>
<li>增加前端展示,通过图表直观展示运行情况和业务覆盖情况,方便不同人群查阅;</li>
<li>对接发布平台,按照指定的应用名来区分跑哪些用例;</li>
<li>设计用例执行框架,实现核心代码复用。</li>
</ol>
<p>新版系统架构图如下</p>
<p><img src="/img/bVbgIAY?w=1024&h=768" alt="图片描述" title="图片描述"></p>
<p>2.0版系统架构图</p>
<p>用例模型如下:</p>
<table>
<thead><tr>
<th>字段</th>
<th>是否必填</th>
<th>说明</th>
</tr></thead>
<tbody>
<tr>
<td>用例名称</td>
<td>是</td>
<td>建议命名格式:“用例类型:服务:方法”</td>
</tr>
<tr>
<td>用例类型</td>
<td>是</td>
<td>两种类型可选http或dubbo</td>
</tr>
<tr>
<td>用例描述</td>
<td>是</td>
<td>场景描述</td>
</tr>
<tr>
<td>所属业务</td>
<td>是</td>
<td>用例所属业务阈</td>
</tr>
<tr>
<td>请求url</td>
<td>否</td>
<td>http协议调用的url</td>
</tr>
<tr>
<td>请求头</td>
<td>否</td>
<td>http header</td>
</tr>
<tr>
<td>请求参数</td>
<td>否</td>
<td>http或dubbo的请求入参。支持动态参数注入实现用例间依赖</td>
</tr>
<tr>
<td>服务名称</td>
<td>否</td>
<td>对应请求dubbo协议的接口名(包名+类名)</td>
</tr>
<tr>
<td>请求方法</td>
<td>是</td>
<td>http协议:GET、POST、PUT等;dubbo协议:方法名</td>
</tr>
<tr>
<td>断言</td>
<td>是</td>
<td>支持多个</td>
</tr>
<tr>
<td>是否开启</td>
<td>否</td>
<td>控制开关,关闭后不再运行。默认开启</td>
</tr>
<tr>
<td>是否登录</td>
<td>否</td>
<td>开启后,使用默认账号进行登录操作。默认不开启</td>
</tr>
<tr>
<td>是否重试</td>
<td>否</td>
<td>开启后,⽤例失败重试1次。默认否</td>
</tr>
<tr>
<td>前/后置检查</td>
<td>否</td>
<td>执行⽤例前/后,先执行前/后置检查,失败则中断</td>
</tr>
</tbody>
</table>
<blockquote>*此处略去了部分有赞内部使用的字段</blockquote>
<p>为了更直观展示线上业务的健康状况我们增加了丰富前端报表</p>
<p><img src="/img/bVbgIA4?w=1748&h=886" alt="图片描述" title="图片描述"></p>
<p>数据展示</p>
<p>新版本与老版本的主要区别在于:</p>
<ol>
<li>将执行流和数据流进行了分离,测试用例设计无需编码,支持配置化,用例作为数据存放到 DB 中重复使用,用例的执行引擎管理用例的执行流。</li>
<li>对通用的事务进行了封装,比如登录、切换店铺等操作,通过统一的线程池进行管理。</li>
<li>支持动态参数注入,实现了用例间的相互依赖,后面再单独介绍这块内容。</li>
</ol>
<p>任务执行流程图如下:</p>
<p><img src="/img/bVbgIBn?w=377&h=960" alt="图片描述" title="图片描述"></p>
<p>2.0版流程图</p>
<p>任务执行引擎通过不同的工作线程实现。不同业务用例并发执行,业务内部用例串行执行。系统根据不同的用例的类型(http/dubbo)分发到具体任务流中。</p>
<p><img src="/img/bVbgIBu?w=685&h=225" alt="图片描述" title="图片描述"></p>
<p>核心类设计</p>
<h3>用例间依赖的实现</h3>
<p>从用例的复杂度上讲,我们的用例主要分为两大类:单一场景的基础用例和复杂场景的组合用例。组合用例是在基础用例的基础上进行一定的集成,用例的输入输出存在一定的依赖。我们实现用例依赖的方式有两种:</p>
<ol>
<li>通过配置用例的前置后置关系。</li>
<li>通过参数注入。</li>
</ol>
<p>第一种方式,在配置用例的时候,给它一个前置用例,当然前置用例也是在平台中管理的。这样当执行到该用例的时候,执行引擎会先去执行前置用例。</p>
<p>第二种方式,针对 Json 格式的入参,我们定义如下格式进行参数注入:</p>
<p><code>$#a,b,c#$</code></p>
<p>各个字段分别代表的含义为:</p>
<p>a:被依赖用例的ID<br>b:被依赖用例响应的字段(key值),比如:name<br>c:可选字段,当被依赖值位于 array 里面时,取其 index 下标<br>举例:<code>{"code":"$#8,data,0#$","type":"$#10,type#$"}</code></p>
<p>参数注入的流程如下:</p>
<p><img src="/img/bVbgICx?w=567&h=790" alt="图片描述" title="图片描述"></p>
<p>参数注入流程图</p>
<h3>断言模块设计</h3>
<p>在新版系统里面,我们设计了四种类型的通用断言,几乎可以满足我们自己的所有应用场景。这四种类型分别是:<br>1.是否包含。<br>响应内容包含指定内容为 true,反之为 false。</p>
<p>2.非空/null。<br>响应内容非空/null为 true,为空/null为 false。</p>
<p>3.JSON 特定位置的值的“相等”判断。<br>这种情况系统首先会将响应内容转换成 json,添加断言时需要指定待比较对象在 json 串中的坐标。如果该坐标上的值与指定的值相等则为 true,反之为 false。<br>那么如何给一个 json 串的每个值设置一个独一无二的坐标呢?考虑到 json 存在嵌套关系且 key 可能重复,我们通过一种复合 key 的来表示这个坐标,例如有如下 json:</p>
<blockquote>{<br><br> "data": {<br><br> "list": [<br><br> "1",<br><br> "2"<br><br> ],<br><br> "info": {<br><br> "name": "<font color=#DC143C>张三</font>",<br><br> "age": 18<br><br> }<br><br> },<br><br> "code": 200<br><br>}</blockquote>
<p>对标红的值的断言可以这样表示:<code>{"data":{"info":{"name":"张三"}}}</code>,如果返回的位置的值为"张三"则判断结果为 true,否则为 false。</p>
<p>4.面向 JSON 的伪代码表达式判断</p>
<p>前面三种类型的断言仅满足了部分场景,对于一些复杂的断言仍然无法满足,比如上文 json 中 list size 的断言。为此,我们引入第四种断言方式---伪代码断言。针对 list size 的断言我们可以这样写:</p>
<p><center><code>getJSONObject("data")getJSONObject("list").size() > 0</code></center></p>
<p>代码在处理的时候会将该表达式拼接在 json 对象后进行执行。整段代码执行的结果为真断言为 true,否则为 false。<br>伪代码的动态编译、加载和调用,采用 GroovyShell 来实现。该部分代码实现如下:</p>
<pre><code>public Result compare(String response) {
Result result = new Result();
// 单例获取GroovyShell
GroovyShell shell = SingleGroovyUtil.getGroovyShell();
Binding binding = null;
JSONObject jsonObject = new JSONObject();
JSONArray jsonArray = new JSONArray();
Object value = null;
try {
if (response.startsWith("[")){
jsonArray = JSON.parseArray(response);
binding = new Binding();
binding.setVariable("data", jsonArray);
value = InvokerHelper.createScript(shell.getClass(), binding).evaluate("data." + textStatement);
}else {
jsonObject = JSON.parseObject(response);
binding = new Binding();
binding.setVariable("data", jsonObject);
value = InvokerHelper.createScript(shell.getClass(), binding).evaluate("data." + textStatement);
}
if((Boolean)value) {
result.setSuccess(true);
}else {
result.setSuccess(false);
String msg = JsonUtil.findErrMsgByJsonObject(jsonObject);
result.setMsg(String.format("断言失败。断言的内容[%s], 错误描述[%s]", this.textStatement, msg.length()>0?msg:response));
}
} catch (Exception e) {
result.setSuccess(false);
String msg = JsonUtil.findErrMsgByJsonObject(jsonObject);
result.setMsg(String.format("断言时发生异常。ErrMsg=[%s],actual=[%s]", e.getMessage(), msg.length()>0?msg:response));
} finally { // 处理完后,主动将对象置为null
binding = null;
}
return result;
}
</code></pre>
<h2>插件化</h2>
<p>新版系统满足了用例的可配置化以及可视化的要求,同时也牺牲了一部分的灵活性。例如一些复杂断言的伪代码会非常长,且可读性不高,一不留神就会出错;简单的用例依赖可以满足,复杂的用例依赖却很难满足。比如用例 A 在某些条件下依赖用例 B,其他条件下依赖用例 C,这种复杂依赖关系走配置化并不合适。基于以上考虑,我们在现有的系统的基础上又增加了插件化的特性,来支持复杂用例的接入。</p>
<p><img src="/img/bVbgIBK?w=1024&h=768" alt="图片描述" title="图片描述"></p>
<p>3.0 版系统架构图</p>
<p>插件化的设计思想如下:</p>
<ol>
<li>平台对外提供一套用例标准,测试同学开发符合标准的用例添加到平台即可运行。</li>
<li>用例与平台完全解耦,用例在平台可配置。</li>
<li>用例支持热插拔,平台无需重启。</li>
</ol>
<p>用例标准通过接口的形式对外提供,封装成jar包暴露出来。用例设计者直接依赖该jar包并实现指定接口即可。用例接口定义如下:</p>
<pre><code>public interface AbstractTestCase {
CaseResult before();
CaseResult run();
void after();
}
</code></pre>
<p>用例开发完成后打包成 jar 包上传到平台,一个 jar 包中可包含一个用例也可以包含多个用例。</p>
<p>jar 包上传后平台要做的事情如下:</p>
<ol>
<li>动态把 jar load 进 JVM</li>
<li>解析实现了 AbstractTestCase 接口的类</li>
<li>按照指定策略调用类中的方法</li>
<li>上报并展示结果数据</li>
</ol>
<p>获取 jar 包中实现了 AbstractTestCase 接口的代码如下:</p>
<pre><code>/**
* 获取jar包中某接口的实现类
*/
public static List<Class<?>> getAllImplClassesByInterface(Class c) {
List<Class<?>> filteredList = new ArrayList<Class<?>>();
//判断是否是接口
if (c.isInterface()) {
try {
//获取jar包中的所有类
List<Class> allClass = getClassesByPackageName();
allClass.forEach(clazz -> {
if (c.isAssignableFrom(clazz)) {
if (!c.equals(clazz)) {
filteredList.add(clazz);
}
}
});
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
return filteredList;
}
</code></pre>
<h2>未来</h2>
<p>未来有赞线上拨测系统会提供更丰富的功能,例如更灵活的用例执行策略,核心用例执行频率更高,边缘业务执行频率降低;更全面的报警策略,各个业务方可以自由定制关心的用例,线上问题第一时间触达;支持多机房,目前该系统只在单机房进行部署,有赞核心业务已完成多机房部署,拨测系统也会随之调整;系统支持分布式,为了防范系统单点故障,未来还会考虑进行分布式部署。</p>
<p>目前这套系统可以保障测试同学第一时间知晓有赞线上核心业务异常,将来保障的业务广度和深度会进一步提高,成为有赞线上质量保障至关重要的一环。</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
有赞搜索系统的架构演进
https://segmentfault.com/a/1190000016266836
2018-09-04T17:11:15+08:00
2018-09-04T17:11:15+08:00
有赞技术
https://segmentfault.com/u/youzantech
69
<p>有赞搜索平台是一个面向公司内部各项搜索应用以及部分 NoSQL 存储应用的 PaaS 产品,帮助应用合理高效的支持检索和多维过滤功能,有赞搜索平台目前支持了大大小小一百多个检索业务,服务于近百亿数据。</p>
<p>在为传统的搜索应用提供高级检索和大数据交互能力的同时,有赞搜索平台还需要为其他比如商品管理、订单检索、粉丝筛选等海量数据过滤提供支持,从工程的角度看,如何扩展平台以支持多样的检索需求是一个巨大的挑战。</p>
<p>我是有赞搜索团队的第一位员工,也有幸负责设计开发了有赞搜索平台到目前为止的大部分功能特性,我们搜索团队目前主要负责平台的性能、可扩展性和可靠性方面的问题,并尽可能降低平台的运维成本以及业务的开发成本。</p>
<h2>Elasticsearch</h2>
<p>Elasticsearch 是一个高可用分布式搜索引擎,一方面技术相对成熟稳定,另一方面社区也比较活跃,因此我们在搭建搜索系统过程中也是选择了 Elasticsearch 作为我们的基础引擎。</p>
<h2>架构1.0</h2>
<p>时间回到 2015 年,彼时运行在生产环境的有赞搜索系统是一个由几台高配虚拟机组成的 Elasticsearch 集群,主要运行商品和粉丝索引,数据通过 Canal 从 DB 同步到 Elasticsearch,大致架构如下:</p>
<p><img src="/img/bVbgpTv?w=661&h=283" alt="图片描述" title="图片描述"></p>
<p>通过这种方式,在业务量较小时,可以低成本的快速为不同业务索引创建同步应用,适合业务快速发展时期,但相对的每个同步程序都是单体应用,不仅与业务库地址耦合,需要适应业务库快速的变化,如迁库、分库分表等,而且多个 canal 同时订阅同一个库,也会造成数据库性能的下降。<br>另外 Elasticsearch 集群也没有做物理隔离,有一次促销活动就因为粉丝数据量过于庞大导致 Elasticsearch 进程 heap 内存耗尽而 OOM,使得集群内全部索引都无法正常工作,这给我上了深深的一课。</p>
<h2>架构 2.0</h2>
<p>我们在解决以上问题的过程中,也自然的沉淀出了有赞搜索的 2.0 版架构,大致架构如下:</p>
<p><img src="/img/bVbgpTD?w=664&h=325" alt="图片描述" title="图片描述"></p>
<p>首先数据总线将数据变更消息同步到 mq,同步应用通过消费 mq 消息来同步业务库数据,借数据总线实现与业务库的解耦,引入数据总线也可以避免多个 canal 监听消费同一张表 binlog 的虚耗。</p>
<h2>高级搜索(Advanced Search)</h2>
<p>随着业务发展,我们也逐渐出现了一些比较中心化的流量入口,比如分销、精选等,这时普通的 bool 查询并不能满足我们对搜索结果的细粒率排序控制需求,将复杂的 function_score 之类专业性较强的高级查询编写和优化工作交给业务开发负责显然是个不可取的选项,这里我们考虑的是通过一个高级查询中间件拦截业务查询请求,在解析出必要的条件后重新组装为高级查询交给引擎执行,大致架构如下:</p>
<p><img src="/img/bVbgpTF?w=664&h=311" alt="图片描述" title="图片描述"></p>
<p>这里另外做的一点优化是加入了搜索结果缓存,常规的文本检索查询 match 每次执行都需要实时计算,在实际的应用场景中这并不是必须的,用户在一定时间段内(比如 15 或 30 分钟)通过同样的请求访问到同样的搜索结果是完全可以接受的,在中间件做一次结果缓存可以避免重复查询反复执行的虚耗,同时提升中间件响应速度,对高级搜索比较感兴趣的同学可以阅读另外一篇文章<a href="https://link.segmentfault.com/?enc=XVy%2BQqACf7UyN7TpQjLK2g%3D%3D.bFIJEKSvTU4X1cXgA7qqjOgOFfANHFH%2BA6RtuoMWV0EiRTOr9Fnmyoz0hEJZ6Ny%2B" rel="nofollow">《有赞搜索引擎实践(工程篇)》</a>,这里不再细述。</p>
<h2>大数据集成</h2>
<p>搜索应用和大数据密不可分,除了通过日志分析来挖掘用户行为的更多价值之外,离线计算排序综合得分也是优化搜索应用体验不可缺少的一环,在 2.0 阶段我们通过开源的 es-hadoop 组件搭建 hive 与 Elasticsearch 之间的交互通道,大致架构如下:</p>
<p><img src="/img/bVbgpTH?w=629&h=348" alt="图片描述" title="图片描述"></p>
<p>通过 flume 收集搜索日志存储到 hdfs 供后续分析,也可以在通过 hive 分析后导出作为搜索提示词,当然大数据为搜索业务提供的远不止于此,这里只是简单列举了几项功能。</p>
<h2>问题</h2>
<p>这样的架构支撑了搜索系统一年多的运行,但是也暴露出了许多问题,首当其冲的是越发高昂的维护成本,除去 Elasticsearch 集群维护和索引本身的配置、字段变更,虽然已经通过数据总线与业务库解耦,但是耦合在同步程序中的业务代码依旧为团队带来了极大的维护负担。消息队列虽然一定程序上减轻了我们与业务的耦合,但是带来的消息顺序问题也让不熟悉业务数据状态的我们很难处理。这些问题我总结在<a href="https://link.segmentfault.com/?enc=sFpONKdfjaQfU2VWsudtCQ%3D%3D.4A2Zx4xx6mAlhXHNg6uAZg7drXEjXoWLeg3OfLk4ZQXl5NgEmn8hYmUVvAAtVuANjguBi%2FdTYnW8cfenXHegSA%3D%3D" rel="nofollow">之前写过的一篇文章</a>。<br>除此之外,流经 Elasticsearch 集群的业务流量对我们来说呈半黑盒状态,可以感知,但不可预测,也因此出现过线上集群被内部大流量错误调用压到CPU占满不可服务的故障。</p>
<h2>目前的架构 3.0</h2>
<p>针对 2.0 时代的问题,我们在 3.0 架构中做了一些针对性调整,列举主要的几点:</p>
<ol>
<li>通过开放接口接收用户调用,与业务代码完全解耦;</li>
<li>增加 proxy 用来对外服务,预处理用户请求并执行必要的流控、缓存等操作;</li>
<li>提供管理平台简化索引变更和集群管理</li>
</ol>
<p>这样的演变让有赞搜索系统逐渐的平台化,已经初具了一个搜索平台的架构:</p>
<p><img src="/img/bVbgpTN?w=664&h=433" alt="图片描述" title="图片描述"></p>
<h2>Proxy</h2>
<p>作为对外服务的出入口,proxy 除了通过 ESLoader 提供兼容不同版本 Elasticsearch 调用的标准化接口之外,也内嵌了请求校验、缓存、模板查询等功能模块。<br>请求校验主要是对用户的写入、查询请求进行预处理,如果发现字段不符、类型错误、查询语法错误、疑似慢查询等操作后以 fast fail 的方式拒绝请求或者以较低的流控水平执行,避免无效或低效能操作对整个 Elasticsearch 集群的影响。<br>缓存和 ESLoader 主要是将原先高级搜索中的通用功能集成进来,使得高级搜索可以专注于搜索自身的查询分析和重写排序功能,更加内聚。我们在缓存上做了一点小小的优化,由于查询结果缓存通常来说带有源文档内容会比较大,为了避免流量高峰频繁访问导致 codis 集群网络拥堵,我们在 proxy 上实现了一个简单的<a href="https://link.segmentfault.com/?enc=mKAaQb3aay3FXwqxEWhbbg%3D%3D.7968MDQfjEN0BQ1Kk47v3LIoYLmJgLYBOugV9Y%2Fc%2FhyKMUIJWpmagEo%2FVVpi71xRXNeFJOTzd6eGnCwKC%2FUP5Q%3D%3D" rel="nofollow">本地缓存</a>,在流量高峰时自动降级。</p>
<p>这里提一下模板查询,在查询结构(DSL)相对固定又比较冗长的情况下,比如商品类目筛选、订单筛选等,可以通过模板查询(search template)来实现,一方面简化业务编排DSL的负担,另一方面还可以通过编辑查询模板 template,利用默认值、可选条件等手段在服务端进行线上查询性能调优。</p>
<h2>管理平台</h2>
<p>为了降低日常的索引增删、字段修改、配置同步上的维护成本,我们基于 Django 实现了最初版本的搜索管理平台,主要提供一套索引变更的审批流以及向不同集群同步索引配置的功能,以可视化的方式实现索引元数据的管理,减少我们在平台日常维护上的时间成本。<br>由于开源 head 插件在效果展示上的不友好,以及暴露了部分粗暴功能:</p>
<p><img src="/img/bVbgpTX?w=1050&h=312" alt="图片描述" title="图片描述"></p>
<p>如图,可以通过点按字段使得索引按指定字段排序展示结果,在早期版本 Elasticsearch 会通过 fielddata 加载需要排序的字段内容,如果字段数据量比较大,很容易导致 heap 内存占满引发 full gc 甚至 OOM,为了避免重复出现此类问题,我们也提供了定制的可视化查询组件以支持用户浏览数据的需求。</p>
<h2>ESWriter</h2>
<p>由于 es-hadoop 仅能通过控制 map-reduce 个数来调整读写流量,实际上 es-hadoop 是以 Elasticsearch 是否拒绝请求来调整自身行为,对线上工作的集群相当不友好。为了解决这种离线读写流量上的不可控,我们在现有的 DataX 基础上开发了一个 ESWriter 插件,能够实现记录条数或者流量大小的秒级控制。</p>
<h2>挑战</h2>
<p>平台化以及配套的文档体系完善降低了用户的接入门槛,随着业务的快速增长,Elasticsearch 集群本身的运维成本也让我们逐渐不堪,虽然有物理隔离的多个集群,但不可避免的会有多个业务索引共享同一个物理集群,在不同业务间各有出入的生产标准上支持不佳,在同一个集群内部署过多的索引也是生产环境稳定运行的一个隐患。<br>另外集群服务能力的弹性伸缩相对困难,水平扩容一个节点都需要经历机器申请、环境初始化、软件安装等步骤,如果是物理机还需要更长时间的机器采购过程,不能及时响应服务能力的不足。</p>
<h2>未来的架构 4.0</h2>
<p>当前架构通过开放接口接受用户的数据同步需求,虽然实现了与业务解耦,降低了我们团队自身的开发成本,但是相对的用户开发成本也变高了,数据从数据库到索引需要经历从数据总线获取数据、同步应用处理数据、调用搜索平台开放接口写入数据三个步骤,其中从数据总线获取数据与写入搜索平台这两个步骤在多个业务的同步程序中都会被重复开发,造成资源浪费。这里我们目前也准备与 PaaS 团队内自研的DTS(Data Transporter,数据同步服务)进行集成,通过配置化的方式实现 Elasticsearch 与多种数据源之间的自动化数据同步。</p>
<p>要解决共享集群应对不同生产标准应用的问题,我们希望进一步将平台化的搜索服务提升为云化的服务申请机制,配合对业务的等级划分,将核心应用独立部署为相互隔离的物理集群,而非核心应用通过不同的应用模板申请基于 k8s 运行的 Elasticsearch 云服务。应用模板中会定义不同应用场景下的服务配置,从而解决不同应用的生产标准差异问题,而且云服务可以根据应用运行状况及时进行服务的伸缩容。</p>
<h2>小结</h2>
<p>本文从架构上介绍了有赞搜索系统演进产生的背景以及希望解决的问题,涉及具体技术细节的内容我们将会在本系列的下一篇文章中更新。</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
H5 前端性能测试实践
https://segmentfault.com/a/1190000016163966
2018-08-27T15:00:40+08:00
2018-08-27T15:00:40+08:00
有赞技术
https://segmentfault.com/u/youzantech
166
<p>H5 页面发版灵活,轻量,又具有跨平台的特性,在业务上有很多应用场景。但是同时对比 App,H5 的性能表现总是要逊色一筹,比如页面打开往往会出现白屏,滑动列表等交互场景下也不如 Native 页面流畅。针对这些白屏、卡慢之类的问题,我们测试该从哪些方面去展开测试分析和数据对比呢?接下来笔者分享一些 H5 前端测试实践的经验,抛砖引玉,希望大家一起谈论,一起挖掘更多有价值的课题。</p>
<h2>一、开篇:H5 页面加载过程浅析</h2>
<p>如下图所示,是精选平台打开 H5 页面的几个过程截图。</p>
<p><img src="/img/bVbfY68?w=1836&h=788" alt="图片描述" title="图片描述"></p>
<p>图一到图四可以简单分类,图一是 App 负责做的事情,主要是初始化 Webview 上下文;后面三张图则是一个H5页面加载的过程。其中,App 这个阶段的耗时,主要是 Native 代码的耗时,这里先不展开讨论,我们重点放在后面几个阶段。第四个图是用户直观看到的第一屏页面,我们通常称为<strong>首屏</strong>。</p>
<p><img src="/img/bVbfY7q?w=1524&h=496" alt="图片描述" title="图片描述"></p>
<p><strong>1)加载网络请求</strong></p>
<p>这个过程主要是 Webview 拿到 H5 页面 url 之后,调用 loadUrl 方法,开始去网络上请求第一个资源文件。这个阶段主要包含 dns 解析、建立网络链接、数据传输的耗时。</p>
<p><strong>2)html 解析</strong></p>
<p>Webview 拿到 html 返回后,需要从上至下解析 html 中的标签和内容,识别外链资源、计算页面框架的布局,并渲染绘制出来。在这个过程中会构建出负责页面结构的 DOM Tree 和负责页面布局展示的 Render Tree,如下图所示:</p>
<p><img src="/img/bVbfY7y?w=814&h=340" alt="图片描述" title="图片描述"></p>
<p><strong>3)外链资源加载</strong></p>
<p>这部分主要是从网络上加载外链的 css、图片和 js 等,再重新填充到 html 中。之后重新进行一次 layout 布局计算和页面渲染绘制,此时看到的才是有完整内容的页面。如下图所示,页面需要等图片和 css 加载出来后才能展示,js 也是外链资源,不过一般来说,只要放在 html 底部加载,就不会阻塞页面的渲染和展示。</p>
<p><img src="/img/bVbfY7K?w=1308&h=764" alt="图片描述" title="图片描述"></p>
<h2>二、实例分析:白屏问题</h2>
<p>前面我们已经了解了 H5 页面加载过程,接下来如果遇到白屏,我们自然会问,怎么才能知道页面当前处在哪个阶段,每个阶段耗时多长,以及整体首屏加载的耗时呢?</p>
<p>首先看下通过 PC Chrome 模拟 H5 页面的情况。Chrome Devtool 提供的 Performance 工具,可以录制页面从第一个请求到加载完成的所有事件,通过这种方式可以很详细的看到各阶段做的事情和具体的耗时。</p>
<p><img src="/img/bVbfY7Q?w=742&h=316" alt="图片描述" title="图片描述"></p>
<p>其中两个最关键的首屏耗时指标:<strong>domContentLoaded(首屏页面可见)</strong>和<strong>onLoad(首屏加载完成)</strong>的耗时,除了图示的方法,还可以通过在 console 里打印全局变量window.performance.timing,拿到时间戳并计算得到。</p>
<p>但实际我们要的是移动设备的真机数据,这个才能真实反应页面性能和用户体验。想要获取 H5 真机耗时,一种方式是 js 代码进行上报;另一种是对于 Android 设备,可以用 remote-debug 的方式远程调试真机页面。只需要保证 webview 调试开关打开 & 与 PC USB 连接且开启 USB 调试,就可以在 PC Chrome 访问 chrome://inspect 来获取调试对象。之后参考 PC Chrome 模拟 H5 的方法即可拿到数据。</p>
<p><img src="/img/bVbfY7U?w=1282&h=752" alt="图片描述" title="图片描述"></p>
<p>对于传统页面而言,实际分析发现大部分耗时还是在移动网络请求这部分,所以最直接有效的方式就是对页面进行直出改造,也就是改变先加载 html、再加载 css 等数据的情况,先在后端(比如 nodejs)并行加载首屏依赖的所有 css、js 和后台接口数据,拼装好一个完成的最终要呈现的 html 再回给前端,达到<strong>秒开</strong>的效果。</p>
<h2>三、实例分析:卡慢问题</h2>
<p>有时候用户在页面交互的过程中会遇到卡慢,比如上下滑动列表、左右切换或者轮播等。这个过程无非也是执行 js、请求资源、计算新的页面布局并渲染绘制这几件事。通过 Performance 分析就会发现,卡慢其实并不全是很多人认为的“移送设备性能就是差”,有时候其实是假性卡顿。</p>
<p><img src="/img/bVbfY8f?w=822&h=363" alt="图片描述" title="图片描述"></p>
<p>比如下面这个就是热区过小的问题:</p>
<p><img src="/img/bVbfY8y?w=872&h=530" alt="图片描述" title="图片描述"></p>
<p>真卡的情况,往往脚本报错占了很大比重,直观表现就是页面是卡死,而不是变慢。其他的诸如内存问题,通常表现是页面越来越卡,因为使用时间越长,资源消耗越大。比如页面使用了比较复杂的 canvas 动画、比较耗性能的 iframe 元素,或者直播流媒体,这种情况下容易出现内存泄漏。</p>
<p>下面这个就是 dom 节点引发的内存泄漏,不使用的 commentList 列表没有释放,越积越多到长度几万个的时候开始卡顿。</p>
<p><img src="/img/bVbfY8E?w=1548&h=1390" alt="图片描述" title="图片描述"></p>
<h2>四、总结:H5 前端性能测试方案</h2>
<p>当然,前端性能不仅仅表现在白屏、卡顿问题,也有可能是手机过度发热等等。从用户核心体验出发,我们认为,H5 前端性能最重要的参考标准就是:要以最轻量的方式,给用户最好的体验。从这个方向出发,我们积累了一些测试经验,其中最重要的必过项是<strong>首屏速度</strong>(不仅提升用户体验,还可以提升业务的转化率),其次流畅度、流量和 CPU 等,某些场景下也是需要重点考量的点。</p>
<p><img src="/img/bVbfY8N?w=850&h=379" alt="图片描述" title="图片描述"></p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
使用开源技术构建有赞分布式 KV 存储服务
https://segmentfault.com/a/1190000016075525
2018-08-20T14:15:51+08:00
2018-08-20T14:15:51+08:00
有赞技术
https://segmentfault.com/u/youzantech
2
<h2>背景</h2>
<p>在有赞早期的时候,当时只有 MySQL 做存储,codis 做缓存,随着业务发展,某些业务数据用 MySQL 不太合适, 而 codis 由于当缓存用, 并不适合做存储系统, 因此, 急需一款高性能的 NoSQL 产品做补充。考虑到当时运维和开发人员都非常少, 我们需要一个能快速投入使用, 又不需要太多维护工作的开源产品。 当时对比了几个开源产品, 最终选择了 aerospike 作为我们的 KV 存储方案。 事实证明, aerospike 作为一个成熟的商业化的开源产品承载了一个非常好的过渡时期 在很少量的开发和运维工作支持下, 一直稳定运行没有什么故障, 期间满足了很多的业务需求, 也因此能抽出时间投入更多精力解决其他的中间件问题。</p>
<p>然而随着有赞的快速发展, 单纯的 aerospike 集群慢慢开始无法满足越来越多样的业务需求。 虽然性能和稳定性依然很优秀, 但是由于其索引必须加载到内存, 对于越来越多的海量数据, 存储成本会居高不下。 更多的业务需求也决定了我们将来需要更多的数据类型来支持业务的发展。 为了充分利用已有的 aerospike 集群, 并考虑到当时的开源产品并无法满足我们所有的业务需求, 因此我们需要构建一个能满足有赞未来多年的 KV 存储服务。</p>
<h2>设计与架构</h2>
<p>在设计这样一个能满足未来多年发展的底层 KV 服务, 我们需要考虑以下几个方面:</p>
<ul>
<li>需要尽量使用有大厂背书并且活跃的开源产品, 避免过多的工作量和太长的周期</li>
<li>避免完全依赖和耦合一个开源产品, 使得无法适应未来某个开源产品的不可控变化, 以及无法享受将来的技术迭代更新和升级</li>
<li>避免使用过于复杂的技术栈, 增加后期运维成本</li>
<li>由于业务需要, 我们需要有能力做方便的扩展和定制</li>
<li>未来的业务需求发展多样, 单一产品无法满足所有的需求, 可能需要整合多个开源产品来满足复杂多样的需求</li>
<li>允许 KV 服务后端的技术变化的同时, 对业务接口应该尽量稳定, 后继升级不应该带来过多的迁移成本。</li>
</ul>
<p>基于以上几点, 我们做了如下的架构设计:</p>
<p><img src="/img/bVbfB7D?w=706&h=590" alt="图片描述" title="图片描述"></p>
<p>为了整合和方便以后的扩展, 我们使用 proxy 屏蔽了具体的后端细节, 并且使用广泛使用的 redis 协议作为我们对上层业务的接口, 一方面充分利用了开源的 redis 客户端产品减少了开发工作量, 一方面减少了业务的接入学习成本, 一方面也能对已经使用的 aerospike 集群和 codis 集群做比较平滑的整合减少业务迁移工作量。 在此架构下, 我们未来也能通过在 proxy 层面做一些协议转换工作就能很方便的利用未来的技术成果, 通过对接更多优秀的开源产品来进一步扩展我们的 KV 服务能力。</p>
<p>有了此架构后, 我们就可以在不改动现有 aerospike 集群的基础上, 来完善我们目前的KV服务短板, 因此我们基于几个成熟的开源产品自研了 ZanKV 这个分布式 KV 存储。 自研 ZanKV 有如下特点:</p>
<ul>
<li>使用 Golang 语言开发, 利用其高效的开发效率, 也能减少后期维护难度, 方便后期定制。</li>
<li>使用大厂且成熟活跃的开源组件 etcd raft,RocksDB 等构建, 减少开发工作量</li>
<li>CP 系统和现有 aerospike 的 AP 系统结合满足不同的需求</li>
<li>提供更丰富的数据结构</li>
<li>支持更大的容量, 和 aerospike 结合在不损失性能需求的前提下大大减少存储成本</li>
</ul>
<p>自研 ZanKV 的整体架构图如下所示:</p>
<p><img src="/img/bVbfB7N?w=909&h=645" alt="图片描述" title="图片描述"></p>
<p>整个集群由 placedriver + 数据节点 datanode + etcd + rsync 组成。 各个节点的角色如下:</p>
<ul>
<li>PD node: 负责数据分布和数据均衡, 协调集群里面所有的 zankv node 节点, 将元数据写入 etcd</li>
<li>datanode: 负责存储具体的数据</li>
<li>etcd: 负责存储元数据, 元数据包括数据分布映射表以及其他用于协调的元数据</li>
<li>rsync: 用于传输 snapshot 备份文件</li>
</ul>
<p>下面我们来一一讲述具体的内部实现细节。</p>
<h2>实现内幕</h2>
<h3>DataNode 数据节点</h3>
<p>首先, 我们需要一个单机的高性能高可靠的 KV 存储引擎作为基石来保障后面的所有工作的展开, 同时我们可能还需要考虑可扩展性, 以便未来引入更好的底层存储引擎。 在这一方面, 我们选择了 RocksDB 作为起点, 考虑到它的接口和易用性, 而且是 FB 经过多年的时间打造的一个已经比较稳定的开源产品, 它同时也是众多开源产品的共同选择, 基本上不会有什么问题, 也能及时响应开源社区的需求。</p>
<p>RocksDB 仅仅提供了简单的 Get,Set,Delete 几个有限的接口, 为了满足 redis 协议里面丰富的数据结构, 我们需要在 KV 基础上封装更加复杂的数据结构, 因此我们在 RocksDB 上层构建了一个数据映射层来满足我们的需求, 数据映射也是参考了几个优秀的开源产品(pika, ledis, tikv 等)。</p>
<p>完成单机存储后, 为了保证数据的可靠性, 我们通过 raft 一致性协议来可靠的将数据复制到多台机器上, 确保多台机器副本数据的一致性。 选择 raft 也是因为 etcd 已经使用 Golang 实现了一个比较完整且成熟的 raft library 供大家使用。但是 etcd 本身并不能支持海量数据的存储, 因此为了能无限扩展存储能力, 我们在 etcd raft 基础上引入了 raft group 分区概念, 使得我们能够通过不断增加 raft 分区的方法来实现同时并行处理多个 raft 复制的能力。</p>
<p>最后, 我们通过 redis 协议来完成对外服务, 可以看到, 通过以上几个分层 ZanKV DataNode 节点就能提供丰富的数据存储服务能力了, 分层结构如下图所示:</p>
<p><img src="/img/bVbfB7W?w=533&h=353" alt="图片描述" title="图片描述"></p>
<h3>Namespace 与分区</h3>
<p>为了支持海量数据, 单一分区的 raft 集群是无法满足无限扩展的目标的, 因此我们需要支持数据分区来完成 scale out。 业界常用的分区算法可以分为两类: hash 分区和 range 分区, 两种分区算法各有自己的适用场景, range 分区优势是可以全局有序, 但是需要实现动态的 merge 和 split 算法, 实现复杂, 并且某些场景容易出现写热点。 hash 分区的优势是实现简单, 读写数据一般会比较均衡分散, 缺点是分区数一般在初始化时设定为固定值, 增减分区数需要迁移大量数据, 而且很难满足全局有序的查询。 综合考虑到开发成本和某些数据结构的顺序需求, 我们目前采取前缀 hash 分区算法, 这样可以保证前缀相同的数据全局有序满足一部分业务需求的同时, 减少了开发成本保证系统能尽快上线。</p>
<p>另外, 考虑到有赞今后的业务会越来越多, 未来需要能方便的隔离不同业务, 也方便不断的加入新的特性同时能平滑升级, 我们引入了 namespace 的概念。 namespace 可以动态的添加到集群, 并且 namespace 之间的配置和数据完全隔离, 包括副本数, 分区数, 分区策略等配置都可以不同。 并且 namespace 可以支持指定一些节点放置策略, 保证 namespace 和某些特性的节点绑定(目前多机房方案通过机架感知方式实现副本至少分布在一个以上机房)。 有了 namespace, 我们就可以把一些核心的业务和非核心的业务隔离到不同的 namespace 里面, 也可以将不兼容的新特性加到新的 namespace 给新业务用, 而不会影响老的业务, 从而实现平滑升级。</p>
<h3>PlaceDriver Node 全局管理节点</h3>
<p>可以看到, 一个大的集群会有很多 namespace, 每个 namespace 又有很多分区数, 每个分区又需要多个副本, 这么多数据, 必须得有一个节点从全局的视角去优化调度整个集群的数据来保证集群的稳定和数据节点的负载均衡。 placedriver 节点需要负责指定的数据分区的节点分布,还会在某个数据节点异常时, 自动重新分配数据分布。 这里我们使用分离的无状态 PD 节点来实现, 这样带来的好处是可以独立升级方便运维, 也可以横向扩展支持大量的元数据查询服务, 所有的元数据存储在 etcd 集群上。 多个 placedriver 通过 etcd 选举来产生一个 master 进行数据节点的分配和迁移任务。 每个 placedriver 节点会 watch 集群的节点变化来感知整个集群的数据节点变化。</p>
<p>目前数据分区算法是通过 hash 分片实现的, 对于 hash 分区来说, 所有的 key 会均衡的映射到设定的初始分区数上, 一般来说分区数都会是 DataNode 机器节点数的几倍, 方便未来扩容。 因此 PD 需要选择一个算法将分区分配给对应的 DataNode, 有些系统可能会使用一致性 hash 的方式去把分区按照环形排列分摊到节点上, 但是一致性 hash 会导致数据节点变化时负载不均衡, 也不够灵活。 在 ZanKV 里我们选择维护映射表的方式来建立分区和节点的关系, 映射表会根据一定的算法并配合灵活的策略生成。</p>
<p><img src="/img/bVbfB8g?w=670&h=349" alt="图片描述" title="图片描述"></p>
<p>从上图来看, 整个读写流程: 客户端进行读写访问时, 对主 key 做 hash 得到一个整数值, 然后对分区总数取模, 得到一个分区 id, 再根据分区 id, 查找分区 id 和数据节点映射表, 得到对应数据节点, 接着客户端将命令发送给这个数据节点, 数据节点收到命令后, 根据分区算法做验证, 并在数据节点内部发送给本地拥有指定分区 id 的数据分区的 leader 来处理, 如果本地没有对应的分区 id 的 leader, 写操作会在 raft 内部转发到 leader 节点, 读操作会直接返回错误(可能在做 leader 切换)。 客户端会根据错误信息决定是否需要刷新本地 leader 信息缓存再进行重试。</p>
<p>可以看到读写压力都在分区的 leader 上面, 因此我们需要尽可能的确保每个节点上拥有均衡数量的分区 leader, 同时还要尽可能减少增减节点时发生的数据迁移。 在数据节点发生变化时, 需要动态的修改分区到数据节点的映射表, 动态调整映射表的过程就是数据平衡的过程。 数据节点变化时会触发 etcd 的 watch 事件, placedriver 会实时监测数据节点变化, 来判断是否需要做数据平衡。 为了避免影响线上服务, 可以设置数据平衡的允许时间区间。 为了避免频繁发生数据迁移, 节点发生变化后, 会根据紧急情况, 判断数据平衡的必要性, 特别是在数据节点升级过程中, 可以避免不必要的数据迁移。 考虑以下几种情况:</p>
<ul>
<li>新增节点: 平衡优先级最低, 仅在允许的时间区间并且没有异常节点时尝试迁移数据到新节点</li>
<li>少于半数节点异常: 等待一段时间后, 才会尝试将异常节点的副本数据迁移到其他节点, 避免节点短暂异常时迁移数据。</li>
<li>集群超过半数节点异常: 很可能发生了网络分区, 此时不会进行自动迁移, 如果确认不是网络分区, 可以手动强制调整集群稳定节点数触发迁移。</li>
<li>可用于分配的节点数不足: 假如副本数配置是 3, 但是可用节点少于 3 个, 则不会发生数据迁移</li>
</ul>
<p>稳定集群节点数默认只会增加, 每次发现新的数据节点, 就自动增加, 节点异常不会自动减少。 如果稳定集群节点数需要减少, 则需要调用缩容API进行设置, 这样可以避免网络分区时不必要的数据迁移。 当集群正常节点数小于等于稳定节点数一半时, 自动数据迁移将不会发生, 除非人工介入。</p>
<h3>数据过期的实现</h3>
<p>数据过期作为 redis 的功能特性之一,也是 ZanKV 需要重点考虑和设计支持的。与 redis 作为内存存储不同,ZanKV 作为强一致性的持久化存储,面临着需要处理大量过期的落盘数据的场景,在整体设计上,存在着诸多的权衡和考虑。</p>
<p>首先,ZanKV 并不支持毫秒级的数据过期(对应 redis 的 pexpire 命令),这是因为在实际的业务场景中很少存在毫秒级数据过期的需求,且在实际的生产网络环境中网络请求的 RTT 也在毫秒级别,精确至毫秒级的过期对系统压力过大且实际意义并不高。</p>
<p>在秒级数据过期上, ZanKV 支持了两种数据过期策略,分别用以不同的业务场景。用户可以根据自己的需求,针对不同的 namespace 配置不同的过期策略。下面将详细阐述两种不同过期策略的设计和权衡。</p>
<h4>一致性数据过期</h4>
<p>最初设计数据过期功能时,预期的设计目标为:保持数据一致性的情况下完全兼容 redis 数据过期的语义。一致性数据过期,就是为了满足该设计目标所做的设计方案。</p>
<p>正如上文中提到的,ZanKV 目前是使用 rocksdb 作为存储引擎的落盘存储系统,无论是何种过期策略或者实现,都需要将数据的过期信息通过一定方式的编码落盘到存储中。在一致性过期的策略下,数据的过期信息编码方式如下:</p>
<p><img src="/img/bVbfB73?w=988&h=516" alt="图片描述" title="图片描述"></p>
<p>如上图所示,在存在过期时间的情况下,任何一个 key 都需要额外存储两个信息:</p>
<ul>
<li>key 对应的数据过期时间。我们称之为表1</li>
<li>使用过期时间的 unix 时间戳为前缀编码的 key 表。我们称之为表2</li>
</ul>
<p>rocksdb 使用 LSM 作为底层数据存储结构,扫描按照过期时间顺序存储的表2速度是比较快的。在上述数据存储结构的基础上,ZanKV 通过如下方式实现一致性数据过期: 在每个 raft group 中,由 leader 进行过期数据扫描(即扫描表2),每次扫描出至当前时间点需要过期的数据信息, 通过 raft 协议发起删除请求,在删除请求处理过程中将存储的数据和过期元数据信息(表1和表2的数据)一并删除。在一致性过期的策略下,所有的数据操作都通过 raft 协议进行,保证了数据的一致性。同时,所有 redis 过期的命令都得到了很好的支持,用户可以方便的获取和修改 key 的生存时间(分别对应 redis 的 TTL 和 expire 命令),或者对 key 进行持久化(对应 redis 的 persist 指令)。但是,该方案存在以下两个明显的缺陷:</p>
<p>在大量数据过期的情况下,leader 节点会产生大量的 raft 协议的数据删除请求,造成集群网络压力。同时,数据过期删除操作在 raft 协议中处理,会阻塞写入请求,降低集群的吞吐量,造成写入性能抖动。</p>
<p>目前,我们正在计划针对这个缺陷进行优化。具体思路是在过期数据扫描由 raft group 的 leader 在后台进行,扫描后仅通过 raft 协议同步需要过期至的时间戳,各个集群节点在 raft 请求处理中删除该时间戳之前的所有过期数据。图示如下:</p>
<p><img src="/img/bVbfB8t?w=470&h=316" alt="图片描述" title="图片描述"></p>
<p>该策略能有效的减少大量数据过期情况下的 raft 请求,降低网络流量和 raft 请求处理压力。有兴趣的读者可以在 ZanKV 的开源项目上帮助我们进行相应的探索和实现。</p>
<p>另外一个缺点是任何数据的删除和写入,需要同步操作表1和表2的数据,写放大明显。因此,该方案仅适用于过期的数据量不大的情况,对大量数据过期的场景性能不够好。所以,结合实际的业务使用场景,又设计了非一致性本地删除的数据过期策略。</p>
<h4>非一致性本地删除</h4>
<p>该策略的出发点在于,绝大多数的业务仅仅关注数据保留的时长,如业务要求相关的数据保留 3 个月或者半年,而并不关注具体的数据清理时间,也不会在写入之后多次调整和修改数据的过期时间。在这种业务场景的考虑下,设计了非一致性本地删除的数据过期策略。</p>
<p>与一致性数据过期不同的是,在该策略下,不再存储表1的数据,而仅仅保留表2的数据,如下图所示:</p>
<p><img src="/img/bVbfB8D?w=638&h=325" alt="图片描述" title="图片描述"></p>
<p>同时,数据过期删除不再通过 raft 协议发起,而是集群中各个节点每隔 5 分钟扫描一次表2中的数据,并对过期的数据直接进行本地删除。</p>
<p>因为没有表2的数据,所以在该策略下,用户无法通过 ttl 指令获取到 key 对应的过期时间,也无法在设置过期时间后重新设置或者删除 key 的过期时间。但是,这也有效的减少了写放大,提高了写入性能。</p>
<p>同时,因为删除操作都由本地后台进行,消除了同步数据过期带来的集群写入性能抖动和集群网络流量压力。但是,这也牺牲了部分数据一致性。与此同时,每隔 5 分钟进行一次的扫描也无法保证数据删除的实时性。</p>
<p>总而言之,非一致性本地删除是一种权衡后的数据过期策略,适用于绝大多数的业务需求,提高了集群的稳定和吞吐量,但是牺牲了一部分的数据一致性,同时也造成部分指令的语义与 redis 不一致。</p>
<p>用户可以根据自己的需求和业务场景,在不同的 namespace 中配置不同的数据过期策略。</p>
<h4>前缀定期清理</h4>
<p>虽然非一致性删除通过优化, 已经大幅减少了服务端压力, 但是对于数据量特别大的特殊场景, 我们还可以进一步减少服务端压力。 此类业务场景一般是数据都有时间特性, 因此 key 本身会有时间戳信息 (比如日志监控这种数据), 这种情况下, 我们提供了前缀清理的接口, 可以一次性批量删除指定时间段的数据, 进一步避免服务端扫描过期数据逐个删除的压力。</p>
<h3>跨机房方案</h3>
<p>ZanKV 目前支持两种跨机房部署模式,分别适用于不同的场景。</p>
<h4>单个跨多机房集群模式</h4>
<p>此模式, 部署一个大集群, 并且都是同城机房, 延迟较小, 一般是 3 机房模式。 部署此模式, 需要保证每个副本都在不同机房均匀分布, 从而可以容忍单机房宕机后, 不影响数据的读写服务, 并且保证数据的一致性。</p>
<p>部署时, 需要在配置文件中指定当前机房的信息, 用于数据分布时感知机房信息。不同机房的数据节点, 使用不同机房信息, 这样 placedriver 进行副本配置时, 会保证每个分区的几个副本都均匀分布在不同的机房中。</p>
<p>跨机房的集群, 通过 raft 来完成各个机房副本的同步, 发生单机房故障时, 由于另外 2 个机房拥有超过一半的副本, 因此 raft 的读写操作可以不受影响, 且数据保证一致。 等待故障机房恢复后, raft 自动完成故障期间的数据同步, 使得故障机房数据在恢复后能保持同步。此模式在故障发生和恢复时都无需任何人工介入, 在多机房情况下保证单机房故障的可用性的同时,数据一致性也得到保证。 此方式由于有跨机房同步, 延迟会有少量影响。</p>
<h4>多个机房内集群间同步模式</h4>
<p>如果是异地机房, 或者机房网络延迟较高, 使用跨机房单集群部署方式, 可能会带来较高的同步延迟, 使得读写的延迟都大大增加。 为了优化延迟问题, 可以使用异地机房集群间同步模式。 由于异地机房是后台异步同步的, 异地机房不影响本地机房的延迟, 但同时引入了数据同步滞后的问题, 在故障时可能会发生数据不一致的情况。</p>
<p>此模式的部署方式稍微复杂一些, 基本原理是通过在异地机房增加一个 raft learner 节点异步的拉取 raft log 然后重放到异地机房集群。 由于每个分区都是一个独立的 raft group, 因此分区内是串行回放, 各个分区间是并行回放 raft log。 异地同步机房默认是只读的, 如果主机房发生故障需要切换时, 可能发生部分数据未同步, 需要在故障恢复后根据 raft log 进行人工修复。 此方式缺点是运维麻烦, 且故障时需要修数据, 好处是减少了正常情况下的读写延迟。</p>
<h3>性能调优经验</h3>
<p>ZanKV 在初期线上运行时, 积累了一些调优经验, 主要是 RocksDB 参数的调优和操作系统的参数调优, 大部分调优都是参考官方的文档, 这里重点说明以下几个参数:</p>
<ul>
<li>block cache: 由于 block cache 里面都是解压后的 block, 和 os 自带文件 cache 功能有所区别, 因此需要平衡两者之间的比例(一些压测经验建议10%~30%之间)。 另外分区数很多, 因此需要配置不同 RocksDB 实例共享来避免过多的内存占用。<br>write buffer: 这个无法在多个 rocksdb 实例之间共享, 因此需要避免太多, 同时又不能因为太小而发送写入 stall。 另外需要和其他几个参数配合保证: <code>level0_file_num_compaction_trigger * write_buffer_size * min_write_buffer_number_tomerge = max_bytes_for_level_base</code> 来减少写放大。</li>
<li>后台 IO 限速: 这个主要是使用 rocksdb 自带的后台 IO 限速来避免后台 compaction 带来的读写毛刺。</li>
<li>迭代器优化: 这个主要是避免 rocksdb 的标记删除特性影响数据迭代性能, 在迭代器上使用<code>rocksdb::ReadOptions::iterate_upper_bound</code>参数来提前结束迭代, 详细可以参考这篇文章: <a href="https://link.segmentfault.com/?enc=YwoC%2Bno8ln3rdT427eeV%2Bg%3D%3D.JRtauGevfS7PCT4BpesG9g%3D%3D" rel="nofollow">https://www</a>。cockroachlabs。com/blog/adventures-performance-debugging/</li>
<li>禁用透明大页 THP: 操作系统的透明大页功能在存储系统这种访问模式下, 基本都是建议关闭的, 不然读写毛刺现象会比较严重。</li>
</ul>
<pre><code># echo never > /sys/kernel/mm/redhat_transparent_hugepage/enabled
# echo never > /sys/kernel/mm/redhat_transparent_hugepage/defrag</code></pre>
<h2>Roadmap</h2>
<p>虽然 ZanKV 目前已经在有赞内部使用了一段时间, 但是仍然有很多需要完善和改进的地方, 目前还有以下几个规划的功能正在设计和开发:</p>
<h3>二级索引</h3>
<p>主要是在 HASH 这种数据类型时实现如下类似功能, 方便业务通过其他 field 字段查询数据</p>
<pre><code>IDX。FROM test_hash_table WHERE “age>24 AND age<31"</code></pre>
<h3>优化 raft log</h3>
<p>目前 etcd 的 raft 实现会把没有 snapshot 的 raft log 保存在 memory table 里面, 在 ZanKV 这种多 raft group 模式下会占用太多内存, 需要优化使得大部分 raft log 保存在磁盘, 内存只需要保留最近少量的 log 用于 follower 和 leader 之间的交互。 选择 raft log 磁盘存储需要避免双层 WAL 降低写入性能。</p>
<h3>多索引过滤</h3>
<p>二级索引只能满足简单的单 field 查询, 如果需要高效的使用多个字段同时过滤, 来满足更丰富的多维查询能力, 则需要引入多索引过滤。 此功能可以满足一大类不需要全文搜索以及精确排序需求的数据搜索场景。 业界已经有支持 range 查询的压缩位图来实现的开源产品, 在索引过滤这种特殊场景下, 性能会比倒排高出不少。</p>
<h3>数据实时导出和 OLAP 优化</h3>
<p>主要是利用 raft learner 的特点, 实时的把 raft log 导出到其他系统。 进一步做针对性的场景, 比如转换成列存做 OLAP 场景等。</p>
<p>以上特性都有巨大的开发工作量, 目前人力有限, 欢迎有志之士加入我们或者参与我们的开源项目, 希望能充分利用开源社区的力量使得我们的产品快速迭代, 提供更稳定, 更丰富的功能。</p>
<h2>总结</h2>
<p>限于篇幅, 以上只能大概讲述 ZanKV 几个重要的技术思路, 还有很多实现细节无法一一讲述清晰, 项目已经开源: <a href="https://link.segmentfault.com/?enc=PVEPCF8Pw9ZDp6iXHiINHA%3D%3D.SEl7zkuyZPZvwfVFBWU3Inbb2JHxc8THMeSkM0ivpgxFz3PPZ6jRHNEQYFa%2BRD3n" rel="nofollow">https://github.com/youzan/Zan...</a> , 欢迎大家通过阅读源码来进一步了解细节, 并贡献源码来共同构建一个更好的开源产品, 也敬请期待后继更佳丰富的功能特性实现细节介绍。</p>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
大数据开发平台(Data Platform)在有赞的最佳实践
https://segmentfault.com/a/1190000015731464
2018-07-23T12:03:16+08:00
2018-07-23T12:03:16+08:00
有赞技术
https://segmentfault.com/u/youzantech
10
<h2>前言</h2>
<p>随着公司规模的增长,对大数据的离线应用开发的需求越来越多,这些需求包括但不限于离线数据同步(MySQL/Hive/Hbase/Elastic Search 等之间的离线同步)、离线计算(Hive/MapReduce/Spark 等)、定时调度、运行结果的查询以及失败场景的报警等等。</p>
<p><em>在统一的大数据开发平台产生之前,面临一系列的问题:</em></p>
<ul>
<li>多个开发和调度入口,不同的业务部门之间的项目或组件很难复用,同时带来繁重的运维成本</li>
<li>Hadoop 的环境对业务团队的同事来讲不友好(除了要熟悉业务以外还需要对底层框架有比较深入的了解)</li>
<li>重复的开发工作(例如导表、调度等本来可以复用的模块,却需要在多个项目中重复实现)</li>
<li>频繁的跨部门需求沟通和讨论</li>
</ul>
<p>为了解决上述遇到的各类问题,同时参考了业界其他公司的大数据解决方案,我们设计并实现了<em>大数据开发平台(Data Platform,简称 DP)</em>,通过可视化的交互界面,解决离线大数据计算相关的各种环境和工具。</p>
<p>本文将介绍 DP 的系统设计以及在有赞的落地情况,内容包括:</p>
<ul>
<li>DP 的系统设计,包括架构设计,以及重点介绍了调度模块的设计</li>
<li>目前在有赞的落地现状</li>
<li>总结和展望</li>
</ul>
<h2>大数据开发平台的设计</h2>
<h3>架构设计</h3>
<p><img src="/img/bVbd932?w=1582&h=998" alt="图片描述" title="图片描述"><br>图1 DP系统架构图</p>
<p>大数据开发平台包括调度模块(基于开源airflow二次开发)、基础组件(包括公共的数据同步模块/权限管理等)、服务层(作业生命周期管理/资源管理/测试任务分发/Slave管理等)和监控(机器资源/日志/基于预测的监控)。这些模块具体功能和职责为:<br/></p>
<ul>
<li>
<p><strong>任务调度模块:</strong>支持基于任务优先级的多队列、分布式调度。在开源的 airflow 基础上进行了二次开发,主要新增功能包括:</p>
<ul>
<li>增加多种任务类型(datax/datay/导出邮件/导出es/Spark等)</li>
<li>根据任务的上下游关系以及重要程度,计算任务的全局优先级,根据全局优先级调度(优先级高的优先执行,低的则进入队列等待)</li>
<li>跨 Dag 的任务依赖关系展示(基于全局 Dag,通过任务的读写Hive表信息建立跨 Dag 的依赖关系)</li>
<li>一键 Clear 当前节点的所有依赖下游节点(支持跨Dag)</li>
</ul>
</li>
<li>
<strong>基础模块:</strong>包括离线的全量/增量数据同步、基于Binlog的增量同步、Hive 导出 ES /邮件、MySQL 同步到 Hbase (开发中)等,参考图2。</li>
</ul>
<p><img src="/img/bVbeaCg?w=484&h=314" alt="图片描述" title="图片描述"><br>图2 DP支持的离线数据同步方式(箭头表示数据流向)</p>
<ul><li>
<p><strong>服务模块:</strong>负责作业的生命周期管理,包括作业的创建(修改)、测试、发布、运维等,服务部署采用 Master / Slave 模式,参考图3所示。其中</p>
<ul>
<li>Master 节点支持 HA 以及热重启(重启期间另外一台提供服务,因此对用户是无感知的)。Master 节点的主要职责是作业的生命周期管理、测试任务分发、资源管理、通过心跳的方式监控 Slaves 等。</li>
<li>Slave 节点分布在调度集群中,与 Airflow 的 worker 节点公用机器。Slave 节点的主要职责是执行 Master 分发的命令(包括测试、机器监控脚本等)、更新资源(通过 Gitlab )等。</li>
</ul>
</li></ul>
<p><img src="/img/bVbeaCF?w=774&h=459" alt="图片描述" title="图片描述"><br>图3 DP 部署图</p>
<ul><li>
<p><strong>监控模块:</strong>对调度集群的机器、资源、调度任务进行全方位的监控和预警。按照监控的粒度和维度分成三类:</p>
<ul>
<li>_基础监控:_结合运维监控(进程、IO等)和自定义监控(包括任务环比波动监控、关键任务的产出时间监控等)对DP的Master节点和Worker节点进行基础的监控和报警。</li>
<li>_日志监控:_通过将任务运行时产出的日志采集到 Kafka,然后经过 Spark Steaming 解析和分析,可以计算每个任务运行的起止时间、Owner、使用到的资源量( MySQL 读写量、 Yarn 的 CPU / Memory 使用量、调度 Slot 的占用情况等),更进一步可以分析Yarn任务的实时运行日志,发现诸如数据倾斜、报错堆栈信息等数据。最后将这些数据存储在 NoSQL(比如 Redis )以进一步的加工和展示。</li>
<li>
<p>_任务预测监控:_通过提前一段时间模拟任务的调度(不真正的跑任务),来预测任务的开始/结束时间,同时可以提早知道可能失败、超时的任务列表,进而在问题发生之前进行规避。</p>
<ul>
<li>现阶段已经实现的功能:分析可能失败的任务列表(失败的原因可能是DB的配置发生更改、上游的节点失败等)并发送告警信息;基于过去一段时间的运行时间数据,模拟整个任务调度,可以计算出任务的开始/结束时间以及超时告警。</li>
<li>未来规划:任务的运行时长不是基于过去的数据,而是通过读取的数据量、集群资源使用率、任务计算复杂程度等多个特征维度来预测运行时长。</li>
</ul>
</li>
</ul>
</li></ul>
<h3>任务调度设计</h3>
<p>大数据开发平台的任务调度是指在作业发布之后,按照作业配置中指定的调度周期(通过 crontab 指定)在一段时间范围内(通过开始/结束时间指定)周期性的执行用户代码。任务调度需要解决的问题包括:</p>
<ol>
<li>如何支持不同类型任务?</li>
<li>如何提供任务调度的高并发(高峰时期每秒需要处理上百个任务执行)?</li>
<li>如何保证相对重要的任务(数据仓库任务)优先获取资源并执行?</li>
<li>如何在多台调度机器上实现负载均衡(主要指CPU/内存资源)?</li>
<li>如何保证调度的高可用?</li>
<li>任务调度的状态、日志等信息怎么比较友好的展示?</li>
</ol>
<p>为了解决上述问题,我们调研了多种开源框架(Azkaban/Oozie/Airflow等),最终决定采用 Airflow + Celery + Redis + MySQL 作为 DP 的任务调度模块,并结合公司的业务场景和需求,做了一些深度定制,给出了如下的解决方案(参考图4):<br><img src="/img/bVbeaCN?w=865&h=545" alt="图片描述" title="图片描述"><br>图4 基于Airflow + Celery + Redis + MySQL的任务调度</p>
<ul>
<li>
<strong>针对问题1</strong>,在 Airflow 原始的任务类型基础上,DP 定制了多种任务(实现 Operator ),包括基于 Datax 的导入导出任务、基于 Binlog 的 Datay 任务、Hive 导出 Email 任务、 Hive 导出 ElasticSearch 任务等等。</li>
<li>
<strong>针对问题2</strong>,一方面通过 Airflow 提供的 Pool + Queue + Slot 的方式实现任务并发个数的管理,以及把未能马上执行的任务放在队列中排队。另一方面通过 Celery 可以实现了任意多台Worker的分布式部署(水平扩展),理论上调度没有并发上限。</li>
<li>
<strong>针对问题3</strong>,在 Airflow 本身支持的优先级队列调度基础之上,我们根据任务的上下游关系以及标记重要的任务节点,通过全局DAG计算出每个节点的全局优先级,通过将该优先级作为任务调度的优先级。这样可以保证重要的任务会优先调度,确保重要任务产出时间的稳定性。</li>
<li>
<p><strong>针对问题4</strong>,首先不同类型的任务需要耗费不同类型的资源,比如 Spark 任务是内存密集型、Datax 任务是 CPU 密集型等,如果将同一类任务集中在一台机器上执行,容易导致部分系统资源耗尽而另外一部分资源空闲,同时如果一台机器的并发任务数过多,容易引起内存 OOM 以及 CPU 不断地切换进程上下文等问题。因此我们的解决方式是:</p>
<ul>
<li>将任务按照需要的资源量分成不同类型的任务,每种类型的任务放到一个单独的调度队列中管理。</li>
<li>每个队列设置不同的 Slot ,即允许的最大并发数</li>
<li>每台 Worker 机器同时配置多个队列</li>
<li>基于这些配置,我们可以保证每台 Worker 机器的 CPU /内存使用率保持在相对合理的使用率范围内,如图5所示</li>
</ul>
</li>
</ul>
<p><img src="/img/bVbeaC5?w=865&h=341" alt="图片描述" title="图片描述"><br>图5 调度Worker机器的内存使用情况</p>
<ul>
<li>
<strong>针对问题5</strong>,任务调度模块涉及到的角色包括 Scheduler (生产者)和 Worker (消费者),因为 Worker 本来就是分布式部署,因此部分机器不可用不会导致整个调度的不可用(其他节点可以继续服务)。而 Scheduler 存在单点问题,我们的解决方案是除了 Active Scheduler 节点之外,新增一个 Standby Scheduler(参考图3),Standby节点会周期性地监听 Active 节点的健康情况,一旦发现 Active Scheduler 不可用的情况,则 Standby 切换为 Active 。这样可以保证 Scheduler 的高可用。</li>
<li>
<strong>针对问题6</strong>,Airflow 自带的 Web 展示功能已经比较友好了。</li>
</ul>
<h2>现状</h2>
<p>DP项目从2017年1月开始立项开发,6月份正式投入生产,之后经过了N轮功能迭代,在易用性和稳定性方面有了显著提升,目前调度集群包括2台Master和13台 Slave(调度)节点(其中2台用于 Scheduler ,另外11台用于 Worker ),每天支持7k+的任务调度,满足数据仓库、数据中心、BI、商品、支付等多个产品线的应用。<br><img src="/img/bVbeaDk?w=1746&h=802" alt="图片描述" title="图片描述"><br>图6 DP调度任务数趋势图</p>
<p>目前DP支持的任务类型包括:</p>
<ul>
<li>
<p>离线数据同步:</p>
<ul>
<li>从 MySQL 到 Hive 的全量/增量数据同步(基于 Datax 二次开发)</li>
<li>从 Hive 到 MySQL 的全量/增量数据同步(基于 Datax 二次开发)</li>
<li>从 MySQL 通过 Binlog ,经过 Nsq/Hdfs/MapReduce 增量同步到 Hive( Datay ,自研)</li>
<li>从 MySQL 同步到 Hbase (基于 Datax 二次开发)</li>
<li>从 Hive 同步到 ElasticSearch (基于 Datax 二次开发)</li>
</ul>
</li>
<li>
<p>Hadoop 任务:</p>
<ul><li>Hive/MapReduce/Spark/Spark SQL</li></ul>
</li>
<li>
<p>其他任务:</p>
<ul>
<li>将 Hive 表数据以邮件形式导出(支持 PDF/Excel/Txt 格式的附件)</li>
<li>Python/Shell/Jar 形式的脚本任务</li>
</ul>
</li>
</ul>
<h2>总结和展望</h2>
<p>DP 在经过一年半的不断功能迭代和完善之后,目前日均支持7k+的任务调度,同时在稳定性和易用性方面也有了较大的提升,可以满足用户日常对大数据离线开发的大部分使用场景。同时我们也意识到大数据开发这块还有很多可以挖掘和提升的点,未来我们可能会从这些方面进一步完善平台的功能和提升用户体验:</p>
<ul>
<li>更加丰富的任务类型</li>
<li>进一步整合其他平台或工具,做到大数据开发的一站式体验</li>
<li>提供用户首页(空间),提供日常运维工具和管理页面,更加方便任务的集中管理</li>
<li>任务日志管理优化(包括快速定位出错信息/拉取和分析 Yarn 日志等)</li>
</ul>
<p><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>
浅谈前端响应式设计(一)
https://segmentfault.com/a/1190000015553823
2018-07-06T18:00:00+08:00
2018-07-06T18:00:00+08:00
有赞技术
https://segmentfault.com/u/youzantech
5
<p>现实世界有很多是以响应式的方式运作的,例如我们会在收到他人的提问,然后做出响应,给出相应的回答。在开发过程中我也应用了大量的响应式设计,积累了一些经验,希望能抛砖引玉。</p>
<p>响应式编程(Reactive Programming)和普通的编程思路的主要区别在于,响应式以推(<code>push</code>)的方式运作,而非响应式的编程思路以拉(<code>pull</code>)的方式运作。例如,事件就是一个很常见的响应式编程,我们通常会这么做:</p>
<pre><code class="js">button.on('click', () => {
// ...
})</code></pre>
<p>而非响应式方式下,就会变成这样:</p>
<pre><code class="js">while (true) {
if (button.clicked) {
// ...
}
}</code></pre>
<p>显然,无论在是代码的优雅度还是执行效率上,非响应式的方式都不如响应式的设计。</p>
<h2>Event Emitter</h2>
<p><code>Event Emitter</code>是大多数人都很熟悉的事件实现,它很简单也很实用,我们可以利用<code>Event Emitter</code>实现简单的响应式设计,例如下面这个异步搜索:</p>
<pre><code class="jsx">class Input extends Component {
state = {
value: ''
}
onChange = e => {
this.props.events.emit('onChange', e.target.value)
}
afterChange = value => {
this.setState({
value
})
}
componentDidMount() {
this.props.events.on('onChange', this.afterChange)
}
componentWillUnmount() {
this.props.events.off('onChange', this.afterChange)
}
render() {
const { value } = this.state
return (
<input value={value} onChange={this.onChange} />
)
}
}
class Search extends Component {
doSearch = (value) => {
ajax(/* ... */).then(list => this.setState({
list
}))
}
componentDidMount() {
this.props.events.on('onChange', this.doSearch)
}
componentWillUnmount() {
this.props.events.off('onChange', this.doSearch)
}
render() {
const { list } = this.state
return (
<ul>
{list.map(item => <li key={item.id}>{item.value}</li>)}
</ul>
)
}
}</code></pre>
<p>这里我们会发现用<code>Event Emitter</code>的实现有很多缺点,需要我们手动在<code>componentWillUnmount</code>里进行资源的释放。它的表达能力不足,例如我们在搜索的时候需要聚合多个数据源的时候:</p>
<pre><code class="jsx">class Search extends Component {
foo = ''
bar = ''
doSearch = () => {
ajax({
foo,
bar
}).then(list => this.setState({
list
}))
}
fooChange = value => {
this.foo = value
this.doSearch()
}
barChange = value => {
this.bar = value
this.doSearch()
}
componentDidMount() {
this.props.events.on('fooChange', this.fooChange)
this.props.events.on('barChange', this.barChange)
}
componentWillUnmount() {
this.props.events.off('fooChange', this.fooChange)
this.props.events.off('barChange', this.barChange)
}
render() {
// ...
}
}</code></pre>
<p>显然开发效率很低。</p>
<h2>Redux</h2>
<p><code>Redux</code>采用了一个事件流的方式实现响应式,在<code>Redux</code>中由于<code>reducer</code>必须是纯函数,因此要实现响应式的方式只有订阅中或者是在中间件中。</p>
<p>如果通过订阅<code>store</code>的方式,由于<code>Redux</code>不能准确拿到哪一个数据放生了变化,因此只能通过脏检查的方式。例如:</p>
<pre><code class="js">function createWatcher(mapState, callback) {
let previousValue = null
return (store) => {
store.subscribe(() => {
const value = mapState(store.getState())
if (value !== previousValue) {
callback(value)
}
previousValue = value
})
}
}
const watcher = createWatcher(state => {
// ...
}, () => {
// ...
})
watcher(store)</code></pre>
<p>这个方法有两个缺点,一是在数据很复杂且数据量比较大的时候会有效率上的问题;二是,如果<code>mapState</code>函数依赖上下文的话,就很难办了。在<code>react-redux</code>中,<code>connect</code>函数中<code>mapStateToProps</code>的第二个参数是<code>props</code>,可以通过上层组件传入<code>props</code>来获得需要的上下文,但是这样监听者就变成了<code>React</code>的组件,会随着组件的挂载和卸载被创建和销毁,如果我们希望这个响应式和组件无关的话就有问题了。</p>
<p>另一种方式就是在中间件中监听数据变化。得益于<code>Redux</code>的设计,我们通过监听特定的事件(Action)就可以得到对应的数据变化。</p>
<pre><code class="js">const search = () => (dispatch, getState) => {
// ...
}
const middleware = ({ dispatch }) => next => action => {
switch action.type {
case 'FOO_CHANGE':
case 'BAR_CHANGE': {
const nextState = next(action)
// 在本次dispatch完成以后再去进行新的dispatch
setTimeout(() => dispatch(search()), 0)
return nextState
}
default:
return next(action)
}
}</code></pre>
<p>这个方法能解决大多数的问题,但是在<code>Redux</code>中,中间件和<code>reducer</code>实际上隐式订阅了所有的事件(Action),这显然是有些不合理的,虽然在没有性能问题的前提下是完全可以接受的。</p>
<h2>面向对象的响应式</h2>
<p><code>ECMASCRIPT 5.1</code>引入了<code>getter</code>和<code>setter</code>,我们可以通过<code>getter</code>和<code>setter</code>实现一种响应式。</p>
<pre><code class="js">class Model {
_foo = ''
get foo() {
return this._foo
}
set foo(value) {
this._foo = value
this.search()
}
search() {
// ...
}
}
// 当然如果没有getter和setter的话也可以通过这种方式实现
class Model {
foo = ''
getFoo() {
return this.foo
}
setFoo(value) {
this.foo = value
this.search()
}
search() {
// ...
}
}</code></pre>
<p><code>Mobx</code>和<code>Vue</code>就使用了这样的方式实现响应式。当然,如果不考虑兼容性的话我们还可以使用<code>Proxy</code>。</p>
<p>当我们需要响应若干个值然后得到一个新值的话,在<code>Mobx</code>中我们可以这么做:</p>
<pre><code class="js">class Model {
@observable hour = '00'
@observable minute = '00'
@computed get time() {
return `${this.hour}:${this.minute}`
}
}</code></pre>
<p><code>Mobx</code>会在运行时收集<code>time</code>依赖了哪些值,并在这些值发生改变(触发<code>setter</code>)的时候重新计算<code>time</code>的值,显然要比<code>EventEmitter</code>的做法方便高效得多,相对<code>Redux</code>的<code>middleware</code>更直观。</p>
<p>但是这里也有一个缺点,基于<code>getter</code>的<code>computed</code>属性只能描述<code>y = f(x)</code>的情形,但是现实中很多情况<code>f</code>是一个异步函数,那么就会变成<code>y = await f(x)</code>,对于这种情形<code>getter</code>就无法描述了。</p>
<p>对于这种情形,我们可以通过<code>Mobx</code>提供的<code>autorun</code>来实现:</p>
<pre><code class="js">class Model {
@observable keyword = ''
@observable searchResult = []
constructor() {
autorun(() => {
// ajax ...
})
}
}</code></pre>
<p>由于运行时的依赖收集过程完全是隐式的,这里经常会遇到一个问题就是收集到意外的依赖:</p>
<pre><code class="js">class Model {
@observable loading = false
@observable keyword = ''
@observable searchResult = []
constructor() {
autorun(() => {
if (this.loading) {
return
}
// ajax ...
})
}
}</code></pre>
<p>显然这里<code>loading</code>不应该被搜索的<code>autorun</code>收集到,为了处理这个问题就会多出一些额外的代码,而多余的代码容易带来犯错的机会。<br>或者,我们也可以手动指定需要的字段,但是这种方式就不得不多出一些额外的操作:</p>
<pre><code class="js">class Model {
@observable loading = false
@observable keyword = ''
@observable searchResult = []
disposers = []
fetch = () => {
// ...
}
dispose() {
this.disposers.forEach(disposer => disposer())
}
constructor() {
this.disposers.push(
observe(this, 'loading', this.fetch),
observe(this, 'keyword', this.fetch)
)
}
}
class FooComponent extends Component {
this.mode = new Model()
componentWillUnmount() {
this.state.model.dispose()
}
// ...
}</code></pre>
<p>而当我们需要对时间轴做一些描述时,<code>Mobx</code>就有些力不从心了,例如需要延迟5秒再进行搜索。</p>
<p>在<a href="https://link.segmentfault.com/?enc=B%2FFB7Vua%2Bq%2FadGPu1ELd7g%3D%3D.IzdZ%2FDtQVT9Bu94NnhqkSt6M%2FxixrrX2MDCjp5zx58yuCq8ZdhfPSFtEnYssdgRE" rel="nofollow">下一篇</a>博客中,将介绍<code>Observable</code>处理异步事件的实践。<br><img src="/img/bV50Mk?w=640&h=400" alt="图片描述" title="图片描述"></p>