发布于 2026-01-06 0 阅读
0

30分钟内实现分布式存储

30分钟内实现分布式存储

作者:伊戈尔·佐洛塔列夫

您好,我叫Igor,是Tarantool DB团队的一员。在开发过程中,我经常需要快速构建数据库应用程序原型,例如用于测试代码或创建最小可行产品(MVP)。当然,我希望这样的原型在最终决定用于生产环境时,能够尽可能轻松地进行改进。

我不喜欢浪费时间配置 SQL 数据库、思考如何管理数据分片,或者花更多时间研究连接器接口。我更喜欢只写几行代码,运行一下,一切就能开箱即用。为了快速开发分布式应用程序,我使用 Cartridge,这是一个基于 NoSQL 数据库 Tarantool 的集群应用程序管理框架。

今天我将展示如何快速编写一个基于 Cartridge 的应用程序,并为其添加测试用例,然后运行它。这篇文章将对那些厌倦了花费大量时间进行应用程序原型设计的人,以及那些想要尝试新的 NoSQL 技术的人有所帮助。

内容

通过本文,您将了解什么是 Cartridge,以及在 Cartridge 中编写集群业务逻辑时需要牢记哪些原则。

我们将编写一个集群应用程序,用于存储公司员工的数据。具体步骤如下:

  • 使用 cartridge-cli 从模板创建应用程序
  • 使用 Lua 语言,根据 Cartridge 集群角色描述您的业务逻辑
    • 数据存储
    • 自定义 HTTP API
  • 写作测试
  • 在本地启动和配置小型集群
    • 正在下载配置
    • 配置故障转移

弹匣框架

Cartridge 是一个用于开发集群应用程序的框架。它管理多个 Tarantool NoSQL 数据库实例,并使用vshard模块进行数据分片。Tarantool 是一个持久化的内存数据库。由于数据存储在 RAM 中,因此速度非常快;同时,由于 Tarantool 会将所有数据转储到硬盘并允许您设置复制,因此也非常可靠。Cartridge 负责配置 Tarantool 节点和分片集群节点,这使得开发人员只需编写应用程序的业务逻辑并配置故障转移即可。

墨盒的优势

  • 开箱即用的分片和复制功能
  • 内置故障转移支持
  • CRUD,一种 NoSQL 集群查询语言
  • 对整个集群进行集成测试。
  • 基于 Ansible 的集群管理
  • 集群管理实用程序
  • 监控工具

创建第一个应用程序

为此,我们需要cartridge-cli。它是一个用于操作 Cartridge 应用程序的实用工具。它允许您从模板创建应用程序、管理本地运行的集群以及连接到 Tarantool 实例。

安装 Tarantool 和 cartridge-cli

在 Debian 或 Ubuntu 系统上:

curl -L https://tarantool.io/fJPRtan/release/2.8/installer.sh | bash

sudo apt install cartridge-cli
Enter fullscreen mode Exit fullscreen mode

在 CentOS、Fedora 或 ALT Linux 系统上:

curl -L https://tarantool.io/fJPRtan/release/2.8/installer.sh | bash

sudo yum install cartridge-cli
Enter fullscreen mode Exit fullscreen mode

在 macOS 系统上:

brew install tarantool

brew install cartridge-cli
Enter fullscreen mode Exit fullscreen mode

让我们创建一个名为 myapp 的模板应用程序:

cartridge create --name myapp

cd myapp

tree .
Enter fullscreen mode Exit fullscreen mode

现在我们得到一个类似于这样的项目结构:

myapp
├── app
│ └── roles
│ └── custom.lua
├── test
├── init.lua
├── myapp-scm-1.rockspec
Enter fullscreen mode Exit fullscreen mode
  • init.lua文件是 Cartridge 应用程序的入口点。它定义了集群的配置,并在每个应用程序节点启动时调用所需的函数。
  • app/roles/目录包含描述应用程序业务逻辑的“角色”。
  • myapp-scm-1.rockspec文件指定了应用程序的依赖项。

现在,我们已经有了一个可以运行的“Hello, world!”应用程序。它可以通过以下命令启动。

cartridge build

cartridge start -d

cartridge replicasets setup --bootstrap-vshard
Enter fullscreen mode Exit fullscreen mode

之后,访问该页面localhost:8081/hello将显示“Hello world!”。

现在我们来创建一个基于模板的小型应用程序,一个带有HTTP API的分片存储,用于存储和接收数据。为此,我们需要了解如何在Cartridge中实现集群业务逻辑。

在 Cartridge 中编写业务逻辑

每个集群应用程序都基于角色,角色是描述应用程序业务逻辑的 Lua 模块。例如,它们可以是存储数据、提供 HTTP API 或缓存来自 Oracle 数据库的数据的模块。角色被分配给一组通过复制连接在一起的实例(副本集),并在每个实例上启用。副本集可以拥有不同的角色集。

在 Cartridge 中,每个集群节点上都有一个集群配置。它描述了集群的拓扑结构,以及(可选的)您的角色将使用的配置。此类配置可以在运行时更改,从而影响角色的行为。

每个角色都具有类似这样的结构:

return {
    role_name = 'your_role_name',

    init = init,
    validate_config = validate_config,
    apply_config = apply_config,
    stop = stop,

    rpc_function = rpc_function,
    dependencies = {
        'another_role_name',
    },
}
Enter fullscreen mode Exit fullscreen mode

角色生命周期

  1. 实例正在启动。
  2. 名为 的角色role_name会等待其所有依赖角色在 中指定开始dependencies
  3. 调用该validate_config函数是为了检查角色配置是否有效。
  4. 角色初始化函数init被调用。该函数执行角色首次启动时需要执行一次的操作。
  5. apply_config函数用于应用配置(如果已指定)。此外,每当角色配置发生更改时,也会调用` validate_configand`函数。apply_config
  6. 该角色已保存在注册表中。之后,同一节点上的其他角色可以通过以下方式访问该角色cartridge.service_get('your_role_name')
  7. 角色中声明的函数将可通过其他节点访问cartridge.rpc_call('your_role_name', 'rpc_function')
  8. 在停止或重启角色之前,stop会启动该函数。它会终止角色,例如,删除该角色创建的纤维。

集群 NoSQL 查询

在 Cartridge 中,有几种方法可以编写集群查询:

  • 通过 vshard API 调用函数(这是一种复杂但灵活的方式):

vshard.router.callrw(bucket_id, 'app.roles.myrole.my_rpc_func', {...})

  • Tarantool CRUD

    • 简单函数调用:crud.insert/ get/ replace/ ...
    • 计算方面的支持bucket_id有限
    • 角色必须取决于crud-router/crud-storage

应用结构

假设我们需要一个包含一个路由器和两组存储的集群,每组存储包含两个实例。这种拓扑结构在 Redis 集群和 MongoDB 集群中都很常见。为了实现有状态故障转移并保存当前主节点的状态,集群还会包含一个 Stateboard 实例。当需要更高的可靠性时,最好使用 etcd 集群而不是 Stateboard。

路由器会将请求分发到集群的各个部分并管理故障转移。

图片描述

编写自定义角色

我们需要编写两个角色:一个用于数据存储,一个用于 HTTP API。

在 app/roles 目录中,我们创建两个新文件:app/roles/storage.luaapp/roles/api.lua

数据存储

我们来描述数据存储的作用。在这个init函数中,我们将创建一个表及其索引,然后添加crud-storage其依赖项。

init 函数中的 Lua 代码等效于以下伪 SQL 代码:

CREATE TABLE employee(
    bucket_id unsigned,
    employee_id string,
    name string,
    department string,
    position string,
    salary unsigned
);
CREATE UNIQUE INDEX primary ON employee(employee_id);
CREATE INDEX bucket_id ON employee(bucket_id);
Enter fullscreen mode Exit fullscreen mode

将以下代码添加到app/roles/storage.lua文件中:

local function init(opts)
    -- opts has the attribute indicating if the function is called at the master or at the replica
    --  we create tables only at the master instance, they will appear automatically at the replica
    if opts.is_master then
        -- Creating a table with employees
        local employee = box.schema.space.create('employee', {if_not_exists = true})

        -- setting the format
        employee:format({
            {name = 'bucket_id', type = 'unsigned'},
            {name = 'employee_id', type = 'string', comment = 'ID сотрудника'},
            {name = 'name', type = 'string', comment = 'ФИО сотрудника'},
            {name = 'department', type = 'string', comment = 'Отдел'},
            {name = 'position', type = 'string', comment = 'Должность'},
            {name = 'salary', type = 'unsigned', comment = 'Зарплата'}
        })

        -- Create the primary index
        employee:create_index('primary', {parts = {{field = 'employee_id'}},
            if_not_exists = true })

        -- Indexing by bucket_id, it is necessary for sharding
        employee:create_index('bucket_id', {parts = {{field = 'bucket_id'}},
            unique = false,
            if_not_exists = true })
    end

    return true
end

return {
    init = init,
    -- <<< remembering the crud-storage dependency
    dependencies = {'cartridge.roles.crud-storage'},
}
Enter fullscreen mode Exit fullscreen mode

我们不需要角色 API 中的其余功能,因为我们的角色没有配置,也不会分配资源在角色工作完成后进行清理。

HTTP API

我们需要第二个角色来填充表格数据,并在需要时检索这些数据。该角色将访问 Cartridge 的内置 HTTP 服务器。它依赖于crud-router……

我们来定义一个处理 POST 请求的函数。请求体将包含要保存到数据库的对象。

local function post_employee(request)
    -- getting an object from the request body
    local employee = request:json()

    -- writing it to the database
    local _, err = crud.insert_object('employee', employee)

    -- if an error occurs, writing it to the log and returning 500
    if err ~= nil then
        log.error(err)
        return {status = 500}
    end
    return {status = 200}
end
Enter fullscreen mode Exit fullscreen mode

GET 方法会将员工薪资值作为参数。预期响应是一个 JSON 对象,其中包含薪资高于请求中指定值的员工列表。

SELECT employee_id, name, department, position, salary
FROM employee
WHERE salary >= @salary
Enter fullscreen mode Exit fullscreen mode
local function get_employees_by_salary(request)
    -- get the salary parameter from the query
    local salary = tonumber(request:query_param('salary') or 0)

    -- selecting the employee data
    local employees, err = crud.select('employee', {{'>=', 'salary', salary}})

    -- if an error occurs, writing it to the log and returning 500
    if err ~= nil then
        log.error(err)
        return { status = 500 }
    end

    -- the employees table stores the list of rows that meet the condition and the space format
    -- the unflatten_rows function converts a table row to a key-value table
    employees = crud.unflatten_rows(employees.rows, employees.metadata)

    employees = fun.iter(employees):map(function(x) 
        return {
            employee_id = x.employee_id, 
            name = x.name, 
            department = x.department, 
            position = x.position, 
            salary = x.salary,
        }
    end):totable()
    return request:render({json = employees})
end
Enter fullscreen mode Exit fullscreen mode

现在我们来编写init该角色的函数。这里我们将访问 Cartridge 的注册表来获取一个 HTTP 服务器,并使用它来为应用程序分配 HTTP 端点。

local function init()
    -- getting an HTTP-server from the Cartridge's registry
    local httpd = assert(cartridge.service_get('httpd'), "Failed to get httpd serivce")

    -- setting the routes
    httpd:route({method = 'GET', path = '/employees'}, get_employees_by_salary)
    httpd:route({method = 'POST', path = '/employee'}, post_employee)

    return true
end
Enter fullscreen mode Exit fullscreen mode

把所有内容整合起来:

app/roles/api.lua

local cartridge = require('cartridge')
local crud = require('crud')
local log = require('log')
local fun = require('fun')

-- the 'GET /employees' method returns a list of employees with salaries greater than the one specified in the request
local function get_employees_by_salary(request)
    -- getting the salary parameter from the query
    local salary = tonumber(request:query_param('salary') or 0)

    -- selecting the employee data
    local employees, err = crud.select('employee', {{'>=', 'salary', salary}})

    -- if an error occurs, writing it to the log and returning 500
    if err ~= nil then
        log.error(err)
        return { status = 500 }
    end

    -- the employees table stores the list of rows that meet the condition and the space format
    -- the unflatten_rows function converts a table row to a key-value table
    employees = crud.unflatten_rows(employees.rows, employees.metadata)
    employees = fun.iter(employees):map(function(x) 
        return {
            employee_id = x.employee_id, 
            name = x.name, 
            department = x.department, 
            position = x.position, 
            salary = x.salary,
        }
    end):totable()
    return request:render({json = employees})
end

local function post_employee(request)
    -- getting an object from the request body
    local employee = request:json()

    -- writing it to the database
    local _, err = crud.insert_object('employee', employee)

    -- if an error occurs, writing it to the log and returning 500
    if err ~= nil then
        log.error(err)
        return {status = 500}
    end
    return {status = 200}
end

local function init()
    -- getting an HTTP-server from the Cartridge's registry
    local httpd = assert(cartridge.service_get('httpd'), "Failed to get httpd service")

    -- setting the routes
    httpd:route({method = 'GET', path = '/employees'}, get_employees_by_salary)
    httpd:route({method = 'POST', path = '/employee'}, post_employee)

    return true
end

return {
    init = init,
    -- addind the crud-storage dependency
    dependencies = {'cartridge.roles.crud-router'},
}
Enter fullscreen mode Exit fullscreen mode

init.lua

我们来介绍一下init.lua文件。它是 Cartridge 应用程序的入口点。要配置集群实例,需要在 Cartridge 的 init 文件中调用cartridge.cfg()函数。

cartridge.cfg(<opts>, <box_opts>)

  • <opts>默认集群参数
    • 可用角色列表(所有角色都必须指定,即使是永久角色,才能在集群中显示)
    • 分片参数
    • WebUI 配置
    • ETC
  • <box_opts>Tarantool 的默认参数(传递给实例的 box.cfg{})
#!/usr/bin/env tarantool

require('strict').on()

-- specifying the path to search for modules
if package.setsearchroot ~= nil then
    package.setsearchroot()
end

-- configuring Cartridge
local cartridge = require('cartridge')

local ok, err = cartridge.cfg({
    roles = {
        'cartridge.roles.vshard-storage',
        'cartridge.roles.vshard-router',
        'cartridge.roles.metrics',
        -- <<< Adding crud roles
        'cartridge.roles.crud-storage',
        'cartridge.roles.crud-router',
        -- <<< Adding custom roles
        'app.roles.storage',
        'app.roles.api',
    },
    cluster_cookie = 'myapp-cluster-cookie',
})

assert(ok, tostring(err))
Enter fullscreen mode Exit fullscreen mode

最后一步是在myapp-scm-1.rockspec文件中描述应用程序的依赖项

package = 'myapp'
version = 'scm-1'
source  = {
    url = '/dev/null',
}
-- Adding the dependencies
dependencies = {
    'tarantool',
    'lua >= 5.1',
    'checks == 3.1.0-1',
    'cartridge == 2.7.3-1',
    'metrics == 0.11.0-1',
    'crud == 0.8.0-1',
}
build = {
    type = 'none';
}
Enter fullscreen mode Exit fullscreen mode

应用程序的代码已经可以运行了,但是让我们编写一些测试来确保它能按预期工作。

写作测试

每个应用程序都需要测试。通常的 luatest 足以进行单元测试。但要编写集成测试,您可能需要使用 cartridge.test-helpers 模块。它随 Cartridge 一起提供,可用于运行任意结构的集群进行测试。

local cartridge_helpers = require('cartridge.test-helpers')
-- creating a test cluster
local cluster = cartridge_helpers.Cluster:new({
    server_command = './init.lua', -- test application entrypoint
    datadir = './tmp', -- directory for xlog, snap, and other files
    use_vshard = true, -- enable cluster sharding
    -- list of replica sets:
    replicasets = {
        {
            alias = 'api',
            uuid = cartridge_helpers.uuid('a'),
            roles = {'app.roles.custom'}, -- list of roles assigned to the replicaset
            -- list of instances in the replicaset:
            servers = {
                { instance_uuid = cartridge_helpers.uuid('a', 1), alias = 'api' },
                ...
            },
        },
        ...
    }
})
Enter fullscreen mode Exit fullscreen mode

让我们编写一个辅助模块,用于集成测试。在这个模块中,我们将创建一个包含两个副本集的测试集群。每个副本集包含一个实例:

辅助模块代码:

测试/帮助程序.lua

local fio = require('fio')
local t = require('luatest')
local cartridge_helpers = require('cartridge.test-helpers')

local helper = {}

helper.root = fio.dirname(fio.abspath(package.search('init')))
helper.datadir = fio.pathjoin(helper.root, 'tmp', 'db_test')
helper.server_command = fio.pathjoin(helper.root, 'init.lua')

helper.cluster = cartridge_helpers.Cluster:new({
    server_command = helper.server_command,
    datadir = helper.datadir,
    use_vshard = true,
    replicasets = {
        {
            alias = 'api',
            uuid = cartridge_helpers.uuid('a'),
            roles = {'app.roles.api'},
            servers = {
                { instance_uuid = cartridge_helpers.uuid('a', 1), alias = 'api' },
            },
        },
        {
            alias = 'storage',
            uuid = cartridge_helpers.uuid('b'),
            roles = {'app.roles.storage'},
            servers = {
                { instance_uuid = cartridge_helpers.uuid('b', 1), alias = 'storage' },
            },
        },
    }
})

function helper.truncate_space_on_cluster(cluster, space_name)
    assert(cluster ~= nil)
    for _, server in ipairs(cluster.servers) do
        server.net_box:eval([[
            local space_name = ...
            local space = box.space[space_name]
            if space ~= nil and not box.cfg.read_only then
                space:truncate()
            end
        ]], {space_name})
    end
end

function helper.stop_cluster(cluster)
    assert(cluster ~= nil)
    cluster:stop()
    fio.rmtree(cluster.datadir)
end

t.before_suite(function()
    fio.rmtree(helper.datadir)
    fio.mktree(helper.datadir)
    box.cfg({work_dir = helper.datadir})
end)

return helper
Enter fullscreen mode Exit fullscreen mode

集成测试代码:

测试/集成/api_test.lua

local t = require('luatest')
local g = t.group('integration_api')

local helper = require('test.helper')
local cluster = helper.cluster

g.before_all = function()
    g.cluster = helper.cluster
    g.cluster:start()
end

g.after_all = function()
    helper.stop_cluster(g.cluster)
end

g.before_each = function()
    helper.truncate_space_on_cluster(g.cluster, 'employee')
end

g.test_get_employee = function()
    local server = cluster.main_server

    -- filling the storage with data via HTTP API:
    local response = server:http_request('post', '/employee',
        {json = {name = 'John Doe', department = 'Delivery', position = 'Developer',
        salary = 10000, employee_id = 'john_doe'}})
    t.assert_equals(response.status, 200)

    response = server:http_request('post', '/employee',
        {json = {name = 'Jane Doe', department = 'Delivery', position = 'Developer',
        salary = 20000, employee_id = 'jane_doe'}})
    t.assert_equals(response.status, 200)

    -- Making a GET request and checking if the output data is correct
    response = server:http_request('get', '/employees?salary=15000.0')
    t.assert_equals(response.status, 200)
    t.assert_equals(response.json[1], {name = 'Jane Doe', department = 'Delivery', employee_id = 'jane_doe',
        position = 'Developer', salary = 20000
    })
end
Enter fullscreen mode Exit fullscreen mode

运行测试

如果您之前已经启动过该应用程序。

停止应用程序:

cartridge stop
Enter fullscreen mode Exit fullscreen mode

删除包含数据的目录:

rm -rf tmp/
Enter fullscreen mode Exit fullscreen mode

构建应用程序并设置依赖项:

cartridge build

./deps.sh
Enter fullscreen mode Exit fullscreen mode

运行代码检查工具:

.rocks/bin/luacheck .
Enter fullscreen mode Exit fullscreen mode

图片描述

运行测试以记录覆盖率:

.rocks/bin/luatest --coverage
Enter fullscreen mode Exit fullscreen mode

图片描述

生成覆盖率报告并查看结果:

.rocks/bin/luacov .
grep -A999 '^Summary' tmp/luacov.report.out
Enter fullscreen mode Exit fullscreen mode

图片描述

本地运行

要在本地运行应用程序,可以使用 cartridge-cli,但需要将我们编写的角色添加到 replicasets.yml 文件中:

router:
  instances:
  - router
  roles:
  - failover-coordinator
  - app.roles.api
  all_rw: false
s-1:
  instances:
  - s1-master
  - s1-replica
  roles:
  - app.roles.storage
  weight: 1
  all_rw: false
  vshard_group: default
s-2:
  instances:
  - s2-master
  - s2-replica
  roles:
  - app.roles.storage
  weight: 1
  all_rw: false
  vshard_group: default
Enter fullscreen mode Exit fullscreen mode

要查看已配置实例的参数,请查看 instances.yml 文件。

在本地运行集群:

cartridge build
cartridge start -d
cartridge replicasets setup --bootstrap-vshard
Enter fullscreen mode Exit fullscreen mode

图片描述

现在我们可以进入 WebUI 加载角色配置并配置故障转移。要配置有状态故障转移,请执行以下操作:

  • 点击故障转移按钮
  • 选择stateful
  • 请指定地址和密码:
    • localhost:4401
    • 密码

图片描述

我们来看看它是如何运作的。现在s-1副本集中的领导者是s1-master……

图片描述

我们必须阻止它:

cartridge stop s1-master
Enter fullscreen mode Exit fullscreen mode

现在s1-replica他成为了领导者:

图片描述

让我们恢复s1-master

cartridge start -d s1-master
Enter fullscreen mode Exit fullscreen mode

s1-master已经重新启动,但s1-replica由于有状态故障转移机制,它仍然是领导者:

图片描述

让我们加载角色的配置cartridge.roles.metrics。为此,请切换到“代码”选项卡,并创建 metrics.yml 文件,内容如下:

export:
  - path: '/metrics'
    format: prometheus
  - path: '/health'
    format: health
Enter fullscreen mode Exit fullscreen mode

图片描述

点击“应用”按钮后,应用程序的每个节点都将可以在该localhost:8081/metrics端点查看这些指标。同时,该地址的健康检查页面localhost:8081/health也会显示。

至此,小型应用程序的基本设置就完成了:集群已准备就绪,现在我们可以编写应用程序,使用 HTTP API 或连接器与集群通信。我们还可以扩展集群的功能。

结论

许多开发者讨厌浪费时间配置数据库。我们更倾向于只编写代码,把集群管理交给框架。为了解决这个问题,我使用了 Cartridge,这是一个可以管理包含多个 Tarantool 数据库实例的集群的框架。

现在你知道了:

  • 如何基于 Cartridge 和 Tarantool 构建可靠的集群应用程序?
  • 如何编写代码来开发一个用于存储员工信息的小型应用程序?
  • 如何添加测试?
  • 如何配置集群。

希望我的经验对您有所帮助,并能引导您开始使用 Cartridge 创建应用程序。如果您能反馈是否能够快速轻松地编写 Cartridge 应用程序,或者有任何关于其使用方面的问题,我都非常乐意倾听。

接下来会发生什么?

文章来源:https://dev.to/tarantool/distributed-storage-in-30-minutes-1a9f