微服务拆分之无锁编程

  1. 云栖社区>
  2. 博客>
  3. 正文

微服务拆分之无锁编程

sun.shuo@aliyun.com 2019-07-25 13:34:16 浏览268
展开阅读全文

介绍
如果你受够了微服务系统中无休无止的痛苦,哪些数据库事务,分布式锁,永无止境的系统优化,莫名其妙的卡死,诡异的性能波动。来尝试一下最新的无锁编程技术吧。这个技术最酷的地方就是不需要数据库事务和分布式锁就能实现分布式系统的开发。众所周知分布式锁和数据库事务的滥用导致了分布式系统耦合的问题。
我在这个系列的第二篇文章中曾经对一个开源的电商软件进行了分布式的系统分析。您可以点击下面链接找到这篇文章。
ITDSD - 1. Splitting in Microservice Architecture
在这里我已经使用AP&RP理论将这个工程改造为分布式系统。在服务端软件开发的过程中。随着用户数量的增加我们都会遇到服务端性能的瓶颈。为了解决服务端性能的瓶颈需要拆分服务端到不同的硬件以提高集群整体的承载能力。没有AP&RP理论之前这种服务端的拆分非常低效。通常人们引入大量的数据库事务和分布式锁,这些数据库事务错综复杂,并最终使人们迷失在系统耦合中。通过学习AP&RP理论可以让你具备编写无锁分布式系统的能力。本文所使用的实例就是第一个使用AP&RP理论开发的无锁分布式系统。为了说明AP&RP理论的通用性,我选择了最常见的网上商场系统作为实例。因为它的复杂度适中,适合初学者学习。并公开其源代码于github.com上。链接为gantleman/shopd。或者您可以通过下载本文的附件得到它。
性能的提升
评价一个系统重构是否真的有效,通过性能对比就可以得到结论。本文是通过对Manphil/shop工程进行改造得到的。原工程是一个非常简单而明确的网上商场系统。是由有一个服务端和一个数据库共同组成。服务端又由62个任务组成。
假设其服务端和数据库分别运行在两个服务器容器内。改造以前服务端的62个任务共享一个服务器容器。我们将其改造为分布式系统后。根据AP&RP理论可以将62个任务分为3个类型。第一种类型是多个任务必须放在一个服务器容器内。第二种类型是1个任务可以放在一个服务器容器内。第三种类型是1个任务可以复制多份放入任意数量的服务器容器内。
第一个类型的任务一共有30个分为8组,这8组任务只能分别放入8个服务器容器。这8组任务中最多的有7个任务,最少的有2个任务。在分布式系统改造前每个任务能分配的资源为一个服务器器容器的1/62。在系统改造后运行任务最多的服务器容器内每个任务所分配的资源为1/7。可知在进行分布式系统改造后单任务获得的服务器资源最少提升了8.8倍。同理可知第二种类型的任务可以获得的资源提升了62倍。而对第三种类型的任务因为可以复制到任意数量的服务器容器中,所以能够获得的性能提升没有限制,随着硬件的增加而增加。因为第一种类型的任务占任务总数的48%。所以对于48%的系统性能提升了8.8到62倍。而对于剩下的62%的任务可以获得无限的性能提升。
单机Berkeley DB写入极限为10万每秒。7个任务可以每个任务可以分配到1万左右。因为这个7个任务包含了订单完成的任务。所以这个网站的订单完成功能的理论承载极限为每秒1万。全球最大的电商网站的促销活动中订单数量的峰值为每秒8万。所以本次分布式改造理论上可以使软件性能达到世界顶级水平。当然这种大型分布式系统也需要大量的硬件作为支持。不能单独依靠软件系统性能的提升。
安装说明
开发环境:Ubuntu 16.04.4, vscode 1.25.1, mysql Ver 14.14 Distrib 5.7.26, redis 3.0.6, maven 3.6.0, java 1.8.0_201, git 2.7.4
软件框架:SpringBoot, JE.
安装好上述环境之后在根目录执行
>mvn install
然后将shop.sql导入到mysql数据库
>mysql -h localhost -u root -p test < /shop.sql --default-character-set=utf8

如果使用VSCode编辑器需要添加launch.json文件

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "java",
            "name": "Debug (Launch) - Current File",
            "request": "launch",
            "mainClass": "${file}"
        },
        {
            "type": "java",
            "name": "Debug (Launch)-DemoApplication<shopd>",
            "request": "launch",
            "mainClass": "com.github.gantleman.shopd.DemoApplication",
            "projectName": "shopd"
        }
    ]
}

编辑application.properties文件配置数据库地址和帐户密码,以及服务器端口号。

spring.datasource.url=jdbc:mysql://localhost/shop?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driverClassName=com.mysql.jdbc.Driver
server.port=8081

点击F5启动调试模式。
打开浏览输入http://localhost:8081/main即可打开主页。
分布式系统的说明
这个分布式系统是由5个部分组成,分别是nginx反向代理,springboot服务器,reids内存数据库,mysql数据库,以及还没有开发完成的分布式管理器。还有一个非独立运行的JE数据库。分布式管理器在当前版本并没有一个独立的实体。其负责管理redis数据库中的routeconfig字段的数据发布。所以我们需要通过运行测试例程t7()函数,来实现routeconfig字段的数据发布。
如果按纯粹的AP&RP理论进行微服务化的拆分,那么是不需要mysql服务器的。在第一个版本实现中是遵循原AP&RP理论。首先需要将数据按读写功能进行分析。这部分工作在ITDSD2中以及完成并存储在了shop.xlsx文档,你在下载区可以找到它。被分析过的数据按读写功能分别提前存储到每个JE数据库内。用户通过nginx调用对应功能的springboot服务器。再由springboot服务将数据发布到redis内存数据库上。
在最新的版本里,为了能够有效地管理分布式系统引入了缓存机制。缓存机制可以将Mysql数据库一部分的数据读取到JE数据库。在不使用这些缓存数据后可以将数据再写回Mysql数据库。与其他分布式系统中的缓存机制不同的是。这套缓存机制使用页缓存架构。避免了使用不存在数据导致反复触发读取缓存。也使得缓存随机命中率得到了提高。其效果要好于在分布式系统逐条交换数据。
到这里我们成功地创建了一个单机的调试环境。如何进行分布式系统的部署呢?只要创建一个nginx反向代理服务器。并依据shop.xlsx文件所显示的分析结果。将可以分布的任务单独创建一个springboot服务器并修改对应的routeconfig字段。假设我们把/admin/activity/show任务单独放入一个服务器。由shop.xlsx文件可以知道/admin/activity/show任务属于可以任意复制多个类型。那么只要单独创建一个springboot服务器,并设置端口为8082并修改redis中的routeconfig字段。以及修改反向代理的nginx,使得/admin/activity/show的调用指向8082。/admin/activity/show任务就被单独的拆分出去了。
fig1

在集群状态下服务器请求由nginx反向代理服务器到springboot服务器。Springboot服务器检查是否命中页缓存。如果没有命中就从mysql服务器调入数据。当前Springboot服务器调入数据后将数据发布到redis服务器中供其它Springboot服务器读取。其它Springboot服务器读取数据redis服务器中的数据如果不存在。就触发负责管理指定数据的Springboot服务器更新缓存。注意这里如果不是负责管理指定数据的Springboot服务器是无权直接读取mysql数据库的指定数据。只能通知负责管理指定数据的Springboot服务器进行操作。
索引和缓存的代码分析
使用AP&RP理论进行微服务化的拆分本身非常简单。对于面向用户的产品逻辑更改也非常的少。在重构过程中最大的问题是构建索引和缓存页调度。Redis不支持构建索引,需要使用map和set结构自行构建。JE数据库源于Berkeley DB,其支持索引但索引结构和Myslq完全不同。把数据从Mysql数据库读入JE数据库时需要进行重新创建索引。 Mysql数据库并没有将索引数据作为单独的数据提供给使用者。只能间接的通过指令使用索引数据。这与索引数据严重依赖数据结构有关。索引数据会因为数据集合的改变而改变。所以在工程中可以看到大量构建索引和载入缓存的代码。
以getAllAddressByUser函数为例。请看代码注释

@Override
public List<Address> getAllAddressByUser(Integer userID, String url) {
    List<Address> re = new ArrayList<Address>();
///这里查询指定userID的数据是否被载入到redis
    if(redisu.hHasKey(classname_extra+"pageid", cacheService.PageID(userID).toString())) {
        //读取redis的索引数据,这个索引内包含有userID的所有addressID
        Set<Object> ro = redisu.sGet("address_u"+userID.toString());
        if(ro != null){
            for (Object id : ro) {
                Address r =  getAddressByKey((Integer)id, url);
                if (r != null)
                    re.add(r);
            }
///增加索引页的引用次数,为了索引页的调度               
 redisu.hincr(classname_extra+"pageid", cacheService.PageID(userID).toString(), 1);               
        }
    }else {
///如果指定页没有在缓存内就触发载入页面。
        if(!cacheService.IsLocal(url)){
            cacheService.RemoteRefresh("/addressuserpage", userID);
        }else{
            RefreshUserDBD(userID, true, true);
        }
///如果载入成功就再次读取数据。
        if(redisu.hHasKey(classname_extra+"pageid", cacheService.PageID(userID).toString())) {
            //read redis
            Set<Object> ro = redisu.sGet("address_u"+userID.toString());
            if(ro != null){
                for (Object id : ro) {
                    Address r =  getAddressByKey((Integer)id, url);
                    if (r != null)
                        re.add(r);
                }
                redisu.hincr(classname_extra+"pageid", cacheService.PageID(userID).toString(), 1);               
            }
        }
    }
    return re;
}

缓存的载入和索引的创建通常的混合在一起。如果索引没有命中需要载入相关的索引数据的缓存。因为存在分布式架构所以每个服务器只存储了部分数据。所以要严格禁止对数据进行全局检索。例如动态检查用户名下全部地址。所以我在数据库内创建address_user表用于储存用户名下所有地址的id。如果没有address_user就需要每次查询用户地址的时候进行一次数据库address表的全局查询。因为address_user的索引数据存在。我只要获得对应用户的索引数据就知道他的全部地址id了。
缓存的管理是通过cacheService实现的。我使用了表的ID作为缓存的分割标致。这样可以通过ID方便的计算出当前所在页面。当然这样的简化的写法会导致名字,类别,商品搜索等纯文字类的检索功能无法实现页缓存。商品搜索以后会通过大数据的方式实现。使用名字的数据可以通过bit矩阵的方式。这些可能会在后续的改进中实现。
结论
这是首个在分布式中实现无锁编程的示例,让我遗憾的是他还不是一个框架。对于希望在分布式工程中实现无锁编程的程序员。这个示例可以作为一个很好的开始。使用类似设计的分布式系统将不会再受到分布式锁与数据库事物带来的耦合影响。因为遵循AP&RP理论所设计的系统不存在分布式锁和数据库事物。超大型的多人互动系统的软件开发将不在高不可及。普通的程序员也可以轻易的上手。我想这将会很好地推动服务器端软件的普及工作。

网友评论

登录后评论
0/500
评论
sun.shuo@aliyun.com
+ 关注