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
在 CentOS、Fedora 或 ALT Linux 系统上:
curl -L https://tarantool.io/fJPRtan/release/2.8/installer.sh | bash
sudo yum install cartridge-cli
在 macOS 系统上:
brew install tarantool
brew install cartridge-cli
让我们创建一个名为 myapp 的模板应用程序:
cartridge create --name myapp
cd myapp
tree .
现在我们得到一个类似于这样的项目结构:
myapp
├── app
│ └── roles
│ └── custom.lua
├── test
├── init.lua
├── myapp-scm-1.rockspec
- 该
init.lua文件是 Cartridge 应用程序的入口点。它定义了集群的配置,并在每个应用程序节点启动时调用所需的函数。 - 该
app/roles/目录包含描述应用程序业务逻辑的“角色”。 - 该
myapp-scm-1.rockspec文件指定了应用程序的依赖项。
现在,我们已经有了一个可以运行的“Hello, world!”应用程序。它可以通过以下命令启动。
cartridge build
cartridge start -d
cartridge replicasets setup --bootstrap-vshard
之后,访问该页面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',
},
}
角色生命周期
- 实例正在启动。
- 名为 的角色
role_name会等待其所有依赖角色在 中指定开始dependencies。 - 调用该
validate_config函数是为了检查角色配置是否有效。 - 角色初始化函数
init被调用。该函数执行角色首次启动时需要执行一次的操作。 - 该
apply_config函数用于应用配置(如果已指定)。此外,每当角色配置发生更改时,也会调用`validate_configand`函数。apply_config - 该角色已保存在注册表中。之后,同一节点上的其他角色可以通过以下方式访问该角色
cartridge.service_get('your_role_name'): - 角色中声明的函数将可通过其他节点访问
cartridge.rpc_call('your_role_name', 'rpc_function')。 - 在停止或重启角色之前,
stop会启动该函数。它会终止角色,例如,删除该角色创建的纤维。
集群 NoSQL 查询
在 Cartridge 中,有几种方法可以编写集群查询:
- 通过 vshard API 调用函数(这是一种复杂但灵活的方式):
vshard.router.callrw(bucket_id, 'app.roles.myrole.my_rpc_func', {...})
-
- 简单函数调用:
crud.insert/get/replace/ ... - 计算方面的支持
bucket_id有限 - 角色必须取决于
crud-router/crud-storage
- 简单函数调用:
应用结构
假设我们需要一个包含一个路由器和两组存储的集群,每组存储包含两个实例。这种拓扑结构在 Redis 集群和 MongoDB 集群中都很常见。为了实现有状态故障转移并保存当前主节点的状态,集群还会包含一个 Stateboard 实例。当需要更高的可靠性时,最好使用 etcd 集群而不是 Stateboard。
路由器会将请求分发到集群的各个部分并管理故障转移。
编写自定义角色
我们需要编写两个角色:一个用于数据存储,一个用于 HTTP API。
在 app/roles 目录中,我们创建两个新文件:app/roles/storage.lua和app/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);
将以下代码添加到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'},
}
我们不需要角色 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
GET 方法会将员工薪资值作为参数。预期响应是一个 JSON 对象,其中包含薪资高于请求中指定值的员工列表。
SELECT employee_id, name, department, position, salary
FROM employee
WHERE salary >= @salary
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
现在我们来编写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
把所有内容整合起来:
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'},
}
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))
最后一步是在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';
}
应用程序的代码已经可以运行了,但是让我们编写一些测试来确保它能按预期工作。
写作测试
每个应用程序都需要测试。通常的 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' },
...
},
},
...
}
})
让我们编写一个辅助模块,用于集成测试。在这个模块中,我们将创建一个包含两个副本集的测试集群。每个副本集包含一个实例:
辅助模块代码:
测试/帮助程序.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
集成测试代码:
测试/集成/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
运行测试
如果您之前已经启动过该应用程序。
停止应用程序:
cartridge stop
删除包含数据的目录:
rm -rf tmp/
构建应用程序并设置依赖项:
cartridge build
./deps.sh
运行代码检查工具:
.rocks/bin/luacheck .
运行测试以记录覆盖率:
.rocks/bin/luatest --coverage
生成覆盖率报告并查看结果:
.rocks/bin/luacov .
grep -A999 '^Summary' tmp/luacov.report.out
本地运行
要在本地运行应用程序,可以使用 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
要查看已配置实例的参数,请查看 instances.yml 文件。
在本地运行集群:
cartridge build
cartridge start -d
cartridge replicasets setup --bootstrap-vshard
现在我们可以进入 WebUI 加载角色配置并配置故障转移。要配置有状态故障转移,请执行以下操作:
- 点击故障转移按钮
- 选择
stateful - 请指定地址和密码:
- localhost:4401
- 密码
我们来看看它是如何运作的。现在s-1副本集中的领导者是s1-master……
我们必须阻止它:
cartridge stop s1-master
现在s1-replica他成为了领导者:
让我们恢复s1-master:
cartridge start -d s1-master
s1-master已经重新启动,但s1-replica由于有状态故障转移机制,它仍然是领导者:
让我们加载角色的配置cartridge.roles.metrics。为此,请切换到“代码”选项卡,并创建 metrics.yml 文件,内容如下:
export:
- path: '/metrics'
format: prometheus
- path: '/health'
format: health
点击“应用”按钮后,应用程序的每个节点都将可以在该localhost:8081/metrics端点查看这些指标。同时,该地址的健康检查页面localhost:8081/health也会显示。
至此,小型应用程序的基本设置就完成了:集群已准备就绪,现在我们可以编写应用程序,使用 HTTP API 或连接器与集群通信。我们还可以扩展集群的功能。
结论
许多开发者讨厌浪费时间配置数据库。我们更倾向于只编写代码,把集群管理交给框架。为了解决这个问题,我使用了 Cartridge,这是一个可以管理包含多个 Tarantool 数据库实例的集群的框架。
现在你知道了:
- 如何基于 Cartridge 和 Tarantool 构建可靠的集群应用程序?
- 如何编写代码来开发一个用于存储员工信息的小型应用程序?
- 如何添加测试?
- 如何配置集群。
希望我的经验对您有所帮助,并能引导您开始使用 Cartridge 创建应用程序。如果您能反馈是否能够快速轻松地编写 Cartridge 应用程序,或者有任何关于其使用方面的问题,我都非常乐意倾听。
接下来会发生什么?
- 请查看官方网站上的文档。
- 在沙盒中尝试 Cartridge
- 请在Telegram 群组中向社区提问。









