背景
我们的 HAProxy1 日志又叫流量日志,当前存储在两台 SQL Server 上,一台在纽约,一台在科罗拉多州。在 2019 年初,我们有约 4.5 年的数据,总计约 38TB。最初,数据库设计为每天只存一个表。也就是说在 2019 年初,单个数据库中大约有 1600 个表,其中包含多个数据库文件(因为数据文件的大小限制为 16TB)。每个表都有一个集群化的列存储索引,其中包含 100-400 百万行。
我必须将数据从现有的每日表中移到一个新结构中——每个月一个表。这需要同时在 NY(纽约)和 CO(科罗拉多州)服务器上完成,并且数据是存储在机械硬盘上的,所以迁移会非常缓慢而痛苦。
此外,迁移工作只能用现有服务器上仅剩的一点磁盘空间来完成。这些服务器有一个 44TB 的机械硬盘分区,而我们已经用掉了 36-38TB 不等。所以,我得这么做:
将数据迁移到新格式
删除旧数据
压缩旧的数据库文件
不断重复
我以为只要几个月就能搞定,没想到,最后整个项目花了 11 个月的时间。
一些要点
先提一些项目要点:
我是完全远程工作的,所以无法从本地计算机运行任何进程。一切任务都需要在一台永久在线的计算机上执行,因此出现任何 VPN 故障都不行。
我需要在两台单独的机器上完成工作。我当时是分别在两个数据中心的 SQL Server 上进行迁移的,所以需要在两个地点各准备一台机器,以避免网络速度问题。
我们在 Stack Overflow 中有用于各种用途的跳转盒(jump box),在 NY 和 CO 各有一个,非常适合迁移工作。
旧数据库仍在实时生产环境中运行,这意味着在我移动数据时,每天都会添加一个新表。换句话说,我的迁移对象是动态的。
数据库处于simple recovery
状态,因此我们不必处理事务日志。我们的备份是原始的源日志文件,而两地的 SQL Server(NY 和 CO)是彼此的副本。
这些就是整个项目的痛点。与 VPN 断开连接意味着我必须重新连接才能监视迁移进度。从跳转盒启动意味着经过一些清理后,所有进程都需要重新启动。由于我们仍在插入新数据,因此我一直在和目标赛跑,花费的时间越长,我需要移动的数据就越多。
为什么要这样做?
这样做有很多原因,其中之一就是技术债务。我们意识到原始的每日表结构并不理想。如果我们需要查询跨越几天或几个月的数据,它会很慢。
为什么不删除或清除某些数据?
如前所述,我们在两台服务器上只剩下很少的可用空间。我们清除了一些数据,但对数据团队来说,数据越多越好。
我们迟早要购买新硬件,目标是将数据迁移到新格式,然后当我们获得新服务器时把硬盘挪过去就行。
开始工作
每台服务器的硬盘状态如下:
一个 230GB 的 C 盘,只安装 Windows;
一个 3.64TB 的 NVMe D 盘,包含 tempdb、一个数据文件和现有 HAProxyLogs 数据库的日志文件,大约 85%已用;
一个 44TB 的 E 盘,HAProxyLogs 数据库的其余 3 个数据文件,85-90%已用。
因此,可用的中转空间很小。
经过研究,我们决定在两个服务器中同时安装几个额外的 NVMe SSD。服务器中只有 PCIe 插槽可用,所以最后把 U.2 接口的 NVMe SSD 通过转换卡装到了这些插槽上。最后,我们得到了一个 14TB 的空白 F 盘,这至少给了我一点空间。
现在,我们有了一些自由空间,是时候设置新数据库并开始迁移。我编写了脚本来创建新数据库:
CREATE DATABASE [TrafficLogs] CONTAINMENT = NONE
ON PRIMARY
( NAME = N'TrafficLogs_Current',
FILENAME = N'F:\Data\TrafficLogs_Current.mdf',
SIZE = 102400000KB,
FILEGROWTH = 5120000KB
),
FILEGROUP [TrafficLogs_Archive]
(
NAME = N'TrafficLogs_Archive1',
FILENAME = N'E:\Data\TrafficLogs_Archive1.ndf' ,
SIZE = 102400000KB,
FILEGROWTH = 5120000KB
),
(
NAME = N'TrafficLogs_Archive2',
FILENAME = N'E:\Data\TrafficLogs_Archive2.ndf',
SIZE = 102400000KB , FILEGROWTH = 5120000KB
),
(
NAME = N'TrafficLogs_Archive3',
FILENAME = N'E:\Data\TrafficLogs_Archive3.ndf',
SIZE = 102400000KB,
FILEGROWTH = 5120000KB
)
LOG ON
(
NAME = N'TrafficLogs_log',
FILENAME = N'F:\Data\TrafficLogs_log.ldf',
SIZE = 5120000KB,
MAXSIZE = 2048GB,
FILEGROWTH = 102400000KB
);
复制代码
它将所有旧的历史表(40TB 的那个)存储在TrafficLogs_Archive
文件组中,我们将使用PRIMARY
和TrafficLogs_Current
来添加新数据。
你会注意到,TrafficLogs_Archive
文件组位于快塞满的 E 盘上,而不是新的 F 盘上——稍后将详细介绍该错误。
移动所有数据
我们有了一个数据库,所以是时候开始迁移了。需要明确的是,我实际上是接管了一个从几年前就开始、停止,然后不断重复的过程。在我成为 Stack Overflow 的 DBA 之前,这个项目已经积压了很多工作。那时,大家都意识到这将是一个非常耗时的项目,并且由于我们没有足够的资源,结果不断碰壁。每天都有新表加入,任务也越来越庞大。
由于这是一个曾被废弃的项目,因此我并不是完全从头开始的。我拿到了一些脚本来:
1.创建所有新的月度表
-- written by Nick Craver
Declare @month datetime = '2015-08-01';
Declare @endmonth datetime = '2021-01-01'
WHILE @month < @endmonth
BEGIN
Set NoCount On;
Declare @prevMonth datetime = DateAdd(Month, -1, @month);
Declare @nextMonth datetime = DateAdd(Month, 1, @month);
Declare @monthTable sysname
= 'Logs_' + Cast(DatePart(Year, @month) as varchar)
+ '_' + Right('0' + Cast(DatePart(Month, @month) as varchar), 2);
Begin Try
If Object_Id(@monthTable, 'U') Is Not Null
Begin
Declare @error nvarchar(400)
= 'Month ' + Convert(varchar(10), @month, 120)
+ ' has already been moved to ' + @monthTable + ', aborting.';
Throw 501337, @error, 1;
Return;
End
-- Table Creation
Declare @tableTemplate nvarchar(4000) = '
Create Table {Name} (
[CreationDate] datetime Not Null,
<insert all the columns>,
Constraint CK_{Name}_Low Check (CreationDate >= ''{LowerDate}''),
Constraint CK_{Name}_High Check (CreationDate < ''{UpperDate}'')
) On {Filegroup};
Create Clustered Columnstore Index CCI_{Name}
On {Name} With (Data_Compression = {Compression}) On {Filegroup};';
-- Constraints exist for metadata swap
Declare @table nvarchar(4000) = @tableTemplate;
Set @table = Replace(@table, '{Name}', @monthTable);
Set @table = Replace(@table, '{Filegroup}', 'Logs_Archive');
Set @table = Replace(@table, '{LowerDate}', Convert(varchar(20), @month, 120));
Set @table = Replace(@table, '{UpperDate}', Convert(varchar(20), @nextMonth, 120));
Set @table = Replace(@table, '{Compression}', 'ColumnStore_Archive');
Print @table;
Exec sp_executesql @table;
Declare @moveSql nvarchar(4000)
= 'Create Clustered Columnstore Index CCI_{Name}
On {Name} With (Drop_Existing = On, Data_Compression = Columnstore_Archive) On Logs_Archive;';
Set @moveSql = Replace(@moveSql, '{Name}', @monthTable);
Print @moveSql;
Exec sp_executesql @moveSql;
End Try
Begin Catch
Select Error_Number() ErrorNumber,
Error_Severity() ErrorSeverity,
Error_State() ErrorState,
Error_Procedure() ErrorProcedure,
Error_Line() ErrorLine,
Error_Message() ErrorMessage;
Throw;
End Catch
set @month = dateadd(month, 1, @month)
END
GO
复制代码
2.一个 LINQPad 脚本,从最早的一天开始遍历,并将数据插入到新表中
-- written by Nick Craver
<Query Kind="Program">
<NuGetReference>Dapper</NuGetReference>
<Namespace>Dapper</Namespace>
</Query>
void Main()
{
MoveDate(new DateTime(2015, 08, 1));
DateTime date = new DateTime(2015, 08, 1);
while (date < DateTime.UtcNow)
{
MoveDate(date);
date = date.AddDays(1);
}
}
static readonly List<string> cols = new List<string> { "<col list>" };
public void MoveDate(DateTime date)
{
var tableName = GetTableName(date);
var destTable = GetDestTableName(date);
$"Attempting to migrate {date:yyyy-MM-dd} f
from {tableName} to {destTable}".Dump($"{date:yyyy-MM-dd}");
using (var conn = GetConn())
{
int rowCount;
try
{
rowCount = conn.QuerySingle<int>($"Select Count(*)
From HAProxyLogs.dbo.{tableName};");
}
catch (SqlException e)
{
(" Error migrating: " + e.Message).Dump();
return;
}
$" Summary for {date:yyyy-MM-dd}".Dump();
$" {rowCount:n0} row(s) in {tableName}".Dump();
var pb = new Util.ProgressBar($"{tableName} (0/{rowCount})");
pb.Dump(tableName + " copy");
Func<int> GetDestRowCount = ()
=> conn.QuerySingle<int>($"Select Count(*)
From {destTable}
Where CreationDate >= @date
And CreationDate < @date + 1;", new { date });
Action<long, int> UpdatePB = (copied, total) =>
{
pb.Fraction = (double)copied / total;
pb.Caption = $"{tableName} ({copied}/{total})";
};
var destRowCount = GetDestRowCount();
$" {destRowCount:n0} row(s) in {destTable}".Dump();
if (destRowCount > 0)
{
$"Rows found in destiation table - aborting!".Dump();
return;
}
var reader = conn.ExecuteReader($"Select {string.Join(",", cols)}
From HAProxyLogs.dbo.{tableName};");
using (SqlConnection dest = GetConn())
{
dest.Open();
using (SqlBulkCopy bc = new SqlBulkCopy(dest))
{
bc.BulkCopyTimeout = 5*60;
bc.BatchSize = 1048576;
bc.NotifyAfter = 100000;
bc.DestinationTableName = GetDestTableName(date);
bc.SqlRowsCopied += (e, s) => UpdatePB(s.RowsCopied, rowCount);
foreach (var c in cols)
{
bc.ColumnMappings.Add(c, c);
}
try
{
bc.WriteToServer(reader);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
finally
{
reader.Close();
}
}
var destFinalCount = GetDestRowCount();
}
}
}
public SqlConnection GetConn() =>
new SqlConnection(new SqlConnectionStringBuilder
{
DataSource = "servername",
InitialCatalog = "TrafficLogs",
IntegratedSecurity = true
}.ToString());
public string GetTableName(DateTime dt) => $"Log_{dt:yyyy_MM_dd}";
public string GetDestTableName(DateTime dt) => $"HAProxyLogs_{dt:yyyy_MM}";
复制代码
理想情况下,这是很简单的事情,但现实总是没那么完美的。
我于 2019 年 1 月 14 日在科罗拉多州开始进行迁移。从机械硬盘中提取数百万行数据,然后将其重新插入到同样硬盘上的新表中,这个速度太慢了。我原打算先完成这边迁移工作,然后再做纽约的迁移工作,但是大约一周后,我就开始了纽约服务器的数据迁移,以节省时间。
数据迁移问题
我遇到了很多问题,有的很简单,而有的就很难了。
脚本超时
第一个问题是 C#脚本。在查询数据库时,脚本会定期超时。调整设置后,我希望它在大多数情况下都能继续工作。
随机脚本错误
接下来,我遇到一个问题:如果出现错误,脚本将继续到下一张表,也就是下一天的表上。这很糟糕,因为我们希望按日期顺序为集群的列存储索引插入数据。如果脚本出错并转移到下一天,则需要对尚未结束的那一天执行清理。然后,我必须为了那么一天而重新开始。
你可能想知道我所说的“清理”是什么意思。
原始的表结构(也称为每日表)是具有通用标识(ID)列的行存储,以确保每一行都是唯一的。14 天后,我们将从表中删除主键,并添加集群的列存储索引。即使发生了向集群化列存储的迁移,ID 列也将保留。
在新表中,我们删除了 ID 列。这就是说试图弄清在故障前插入的行的唯一性会非常困难。发生故障时,还不如删除当天表中的所有内容以及该日之后的所有内容(如果已移动到另一个表中),然后重新启动该过程,结果会容易很多。
数据验证
我需要验证插入新表中的总行数是否与旧的每日表中的总行数匹配。
原始表是按天记录的,但是在某些情况下,表中可能不包含date 00:00-23:59
的数据,而可能包含date+1
的某些行。这是一个已知问题(来自 HAProxy 的日志延迟),我被告知总数不必完全匹配——如果我们丢失了一些行,也不是大问题。可当你负责将数据从一个系统移至另一个系统时,你确实希望一切都能完美匹配。
由于来自不同日期的混乱情况,我不能只查询旧表中的总行并验证新表是否包含相同的计数。我必须想出一种按天查询计数的方法。为此,我向新表中添加了一个名为OriginalLogTable
的列,然后使用旧表的名称填充它,例如Log_2019_07_01
。这样,我就可以按OriginalLogTable
分组来对比旧表和新表中的行数。这解决了我的问题,当我完成一个月的迁移后,可以使用以下脚本轻松地验证新旧表之间的每一天是否匹配:
if object_id('tempdb..#NewTableDetails') is not null
drop table #NewTableDetails
create table #NewTableDetails
(
TrafficLogOrigTable varchar(50),
TrafficLogTotalRows bigint,
OriginalLogDate date
)
if object_id('tempdb..#LogDetails') is not null
drop table #LogDetails
create table #LogDetails (LogDate date, OriginalTotalRows bigint)
declare @sql nvarchar(max) = ''
declare @startdate datetime = '2019-10-01'
declare @enddate datetime = '2019-11-01'
if object_id('tempdb..#dates') is not null
drop table #dates;
CREATE TABLE #dates
(
[Date] DATE PRIMARY KEY,
FirstOfMonth AS CONVERT(DATE, DATEADD(MONTH, DATEDIFF(MONTH, 0, [Date]), 0)),
OldTableName as concat('Log_', year([date]), '_', right('0' + rtrim(month([date])), 2), '_', right('0' + rtrim(day([date])), 2)),
NewTableName as concat('HAProxyLogs_', year([date]), '_', right('0' + rtrim(month([date])), 2))
);
INSERT #dates([Date])
SELECT d
FROM
(
SELECT d = DATEADD(DAY, rn - 1, @StartDate)
FROM
(
SELECT TOP (DATEDIFF(DAY, @StartDate, @enddate))
rn = ROW_NUMBER() OVER (ORDER BY s1.[object_id])
FROM sys.all_objects AS s1
CROSS JOIN sys.all_objects AS s2
ORDER BY s1.[object_id]
) AS x
) AS y;
-- for each new table name get the count of rows, get list of OldTables
-- Imported and then get the counts for each one
declare @newtablename nvarchar(100)
declare @date datetime
declare newtable_cursor cursor for
select distinct NewTableName
from #dates
where [date] < @enddate
open newtable_cursor
fetch next from newtable_cursor into @newtablename
while @@FETCH_STATUS = 0
begin
set @sql = 'insert into #NewTableDetails (TrafficLogOrigTable, TrafficLogTotalRows)
select
TrafficLogOrigTable = OriginalLogTable,
TrafficLogTotalRows = count(*)
from TrafficLogs.dbo.[' + @newtablename +']
group by OriginalLogTable ';
exec sp_executesql @sql
FETCH NEXT FROM newtable_cursor INTO @newtablename
end
CLOSE newtable_cursor
DEALLOCATE newtable_cursor
update #NewTableDetails
set OriginalLogDate
= Cast(Replace(Replace(TrafficLogOrigTable, 'Log_', ''), '_', '-') as date)
declare @oldTableName nvarchar(100)
declare @oldtabledate date
declare table_cursor cursor for
select OriginalLogDate, TrafficLogOrigTable
from #NewTableDetails
open table_cursor
fetch next from table_cursor into @oldtabledate, @oldTableName
while @@FETCH_STATUS = 0
begin
set @sql = 'insert into #LogDetails (LogDate, OriginalTotalRows)
select
LogDate = '''+ convert(varchar(10), @oldtabledate, 23) +''',
OriginalTotalRows = count(*)
from HAProxyLogs.dbo.[' + @oldTableName +']';
exec sp_executesql @sql
FETCH NEXT FROM table_cursor INTO @oldtabledate, @oldTableName
end
CLOSE table_cursor
DEALLOCATE table_cursor
select
nt.OriginalLogDate,
nt.TrafficLogOrigTable,
nt.TrafficLogTotalRows,
ot.OriginalTotalRows,
ot.LogDate,
IsMatch = case when nt.TrafficLogTotalRows = ot.OriginalTotalRows then 'Y' else 'N' end
from #NewTableDetails nt
full join #LogDetails ot
on nt.OriginalLogDate = ot.LogDate
order by nt.OriginalLogDate
复制代码
列更改
我还发现,原始的每日表的设计不是一成不变的,后来还添加了新列。这意味着在数据迁移过程中表结构将发生变化。我调整了 LinqPad 脚本,以便在需要时根据日期添加新列,这将确保它能正常运行下去和我们不会跳过新列。
还有更多涉及脚本调试的问题,到最后解决所有问题的新脚本变成这个样子:
<Query Kind="Program">
<NuGetReference>Dapper</NuGetReference>
<Namespace>Dapper</Namespace>
</Query>
void Main()
{
DateTime date = new DateTime(2016, 07, 26);
while (date < DateTime.UtcNow)
{
MoveDate(date);
date = date.AddDays(1);
}
}
static readonly List<string> cols = new List<string> { "<col list>" };
public void MoveDate(DateTime date)
{
var tableName = GetTableName(date);
var destTable = GetDestTableName(date);
$"Attempting to migrate {date:yyyy-MM-dd} from {tableName}
to {destTable}".Dump($"{date:yyyy-MM-dd}");
if (date >= new DateTime(2017, 01, 12))
{
cols.Add("new col1");
cols.Add("new col2");
}
using (var conn = GetConn())
{
int rowCount;
try
{
rowCount = conn.QuerySingle<int>($"Select Count(*)
From HAProxyLogs.dbo.{tableName};");
}
catch (SqlException e)
{
(" Error migrating: " + e.Message).Dump();
return;
}
$" Summary for {date:yyyy-MM-dd}".Dump();
$" {rowCount:n0} row(s) in {tableName}".Dump();
var pb = new Util.ProgressBar($"{tableName} (0/{rowCount})");
pb.Dump(tableName + " copy");
Func<int> GetDestRowCount = ()
=> conn.QuerySingle<int>($"Select Count(*)
From {destTable}
Where CreationDate >= @date
And OriginalLogTable = '{tableName}';", new { date });
Action<long, int> UpdatePB = (copied, total) =>
{
pb.Fraction = (double)copied / total;
pb.Caption = $"{tableName} ({copied}/{total})";
};
var destRowCount = GetDestRowCount();
$"There are {destRowCount:n0} row(s) for table: {destTable}".Dump();
if (destRowCount > 0)
{
$"Rows found in destination table - aborting!".Dump();
return;
}
var sqlString = $"Select {string.Join(",", cols)}, OriginalLogTable = '{tableName}'
From HAProxyLogs.dbo.{tableName};";
var reader = conn.ExecuteReader(sqlString);
using (SqlConnection dest = GetConn())
{
dest.Open();
using (SqlBulkCopy bc = new SqlBulkCopy(dest))
{
bc.BulkCopyTimeout = 15*60;
bc.BatchSize = 1048576;
bc.NotifyAfter = 100000;
bc.DestinationTableName = GetDestTableName(date);
bc.SqlRowsCopied += (e, s) => UpdatePB(s.RowsCopied, rowCount);
foreach (var c in cols)
{
bc.ColumnMappings.Add(c, c);
}
try
{
bc.ColumnMappings.Add("OriginalLogTable", "OriginalLogTable");
bc.WriteToServer(reader);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
finally
{
reader.Close();
}
}
// try updating stats after full dump to see if that stops time-outs
var statsString
= $"waitfor delay '00:00:05';
update statistics dbo.[{destTable}] [{destTable}_OriginalLogTable];";
conn.Query(statsString);
var destFinalCount = GetDestRowCount();
$"After migration there are {destFinalCount:n0} row(s) in {destTable}".Dump();
}
}
}
public SqlConnection GetConn() =>
new SqlConnection("Data Source=servername;Initial Catalog=TrafficLogs;Trusted_Connection=True;Connection Timeout = 1500;");
public string GetTableName(DateTime dt) => $"Log_{dt:yyyy_MM_dd}";
public string GetDestTableName(DateTime dt) => $"HAProxyLogs_{dt:yyyy_MM}";
复制代码
太慢了
我们都知道这将是一个缓慢的过程,可那也太慢了。
将每个旧表插入新表大约需要 2-2.5 个小时。到 2 月中旬,我还剩:
纽约的 1332 个表要迁移;
在科罗拉多州还剩 782 张表
这样下去,我还要花几个月的时间才能完成。
我想做点什么来更快地移动数据。步骤是不变的——插入旧数据,验证行数匹配,移至第二天,重复。我尝试了很多不同的方法,但都没有很快:
declare @startDate datetime = '2015-11-01'
declare @endDate datetime = '2015-12-01'
declare @OldTableName varchar(100)
declare @NewTableName varchar(100)
declare @cols varchar(max) = 'comma, separated, col, list'
declare @originalrowcount int
declare @newrowcount int
declare @finalrowcount int
declare @rowcountsql nvarchar(max)
declare @insertsql nvarchar(max)
declare @statssql nvarchar(max)
while @startDate < @endDate
begin
set @OldTableName
= concat('Log_', year(@startDate), '_'
, right('0' + rtrim(month(@startDate)), 2), '_'
, right('0' + rtrim(day(@startDate)), 2))
set @NewTableName
= concat('HAProxyLogs_', year(@startDate), '_'
, right('0' + rtrim(month(@startDate)), 2))
print concat('Attempting to migrate ', convert(varchar(10), @startDate, 120)
, ' from ', @OldTableName, ' to ', @NewTableName)
print ' '
if @startDate >= '2017-01-12'
begin
if (charindex('new col1', @cols) = 0)
begin
set @cols = concat(@cols, ', new col1')
end
if (charindex('new col2', @cols) = 0)
begin
set @cols = concat(@cols, ', new col2')
end
end
set @rowcountsql = concat('select @cnt = count(*) from HAProxyLogs.dbo.[', @OldTableName, ']');
exec sp_executesql @rowcountsql, N'@cnt int output', @cnt = @originalrowcount output;
/*
print concat('Summary for ', convert(varchar(10), @startDate, 120)
, ' ', @originalrowcount, ' rows in ', @OldTableName);
print ' '
print concat('Starting copy of ', @OldTableName);
print ' '
*/
-- check if the new table has any rows in it
set @rowcountsql
= concat('select @cnt = count(*) from ', @NewTableName
, ' where CreationDate >= ''', convert(varchar(10), @startDate, 120)
, ''' and OriginalLogTable = ''', @OldTableName, ''';')
--print @rowcountsql
exec sp_executesql @rowcountsql, N'@cnt int output', @cnt = @newrowcount output;
print concat('There are ', @newrowcount, ' row(s) for table: ', @NewTableName);
print ' '
If @newrowcount > 0
begin
print 'Rows found in the destination table - aborting'
print ' '
break;
end
set @insertsql
= concat('insert into TrafficLogs.dbo.', @NewTableName, '(', @cols, ', OriginalLogTable)
select ', @cols, ', OriginalLogTable = ''', @OldTableName, '''
from HAProxyLogs.dbo.', @OldTableName);
print @insertsql
print ' '
exec sp_executesql @insertsql;
set @statssql
= concat('update statistics TrafficLogs.dbo.', @NewTableName
, ' ', @NewTableName, '_OriginalLogTable');
print @statssql
print ' '
exec sp_executesql @statssql
set @rowcountsql
= concat('select @cnt = count(*) from ', @NewTableName
, ' where CreationDate >= ''', convert(varchar(10), @startDate, 120)
, ''' and OriginalLogTable = ''', @OldTableName, ''';')
exec sp_executesql @rowcountsql, N'@cnt int output', @cnt = @finalrowcount output;
print concat('After migration there are ', @finalrowcount, ' row(s) in the ', @NewTableName);
If @originalrowcount <> @finalrowcount
begin
print 'final row does not match original stopping operation';
print ' '
break;
end
set @startDate = dateadd(day, 1, @startDate)
END
复制代码
<#
.SYNOPSIS
Traffic Log Data Migration
.EXAMPLE
.\DataMigration.ps1 -ServerName name -StartDate 2019-01-01 -EndDate 2019-02-01
#>
[CmdletBinding()] #See http://technet.microsoft.com/en-us/library/hh847884(v=wps.620).aspx for CmdletBinding common parameters
param(
[parameter(Mandatory = $true)]
[string]$ServerName,
[parameter(Mandatory = $true)]
[DateTime]$StartDate,
[parameter(Mandatory = $true)]
[DateTime]$EndDate
)
# taken from https://blog.netnerds.net/2015/05/getting-total-number-of-rows-copied-in-sqlbulkcopy-using-powershell/
$source = 'namespace System.Data.SqlClient
{
using Reflection;
public static class SqlBulkCopyExtension
{
const String _rowsCopiedFieldName = "_rowsCopied";
static FieldInfo _rowsCopiedField = null;
public static int RowsCopiedCount(this SqlBulkCopy bulkCopy)
{
if (_rowsCopiedField == null) _rowsCopiedField
= typeof(SqlBulkCopy).GetField(_rowsCopiedFieldName, BindingFlags.NonPublic
| BindingFlags.GetField | BindingFlags.Instance);
return (int)_rowsCopiedField.GetValue(bulkCopy);
}
}
}
'
Add-Type -ReferencedAssemblies 'System.Data.dll' -TypeDefinition $source
$null = [Reflection.Assembly]::LoadWithPartialName("System.Data")
Function ConnectionString([string] $ServerName, [string] $DbName)
{
"Data Source=$ServerName;Initial Catalog=TrafficLogs;Trusted_Connection=True;Connection Timeout = 2000;"
}
Function GetOriginalTableRowCount($sqlConnection, $table){
$sqlConnection.open();
$OriginalRowCountCmd = "select OriginalCount = count(*) from " + $table;
$SqlCmd = New-Object System.Data.SqlClient.SqlCommand($OriginalRowCountCmd, $sqlConnection)
$SqlCmd.CommandTimeout = 0;
$row_count = [Int64] $SqlCmd.ExecuteScalar()
$sqlConnection.Close();
return $row_count
}
Function GetNewTableRowCount($sqlConnection, $table){
$sqlConnection.open();
$RowCountCmd = "select NewCount = count(*) from " + $table
+ " where CreationDate >= '"+($i.ToString("yyyy-MM-dd"))
+"' and OriginalLogTable = 'Log_"+($i.ToString("yyyy_MM_dd"))+"'";
$SqlCmd = New-Object System.Data.SqlClient.SqlCommand($RowCountCmd, $sqlConnection)
$SqlCmd.CommandTimeout = 0;
$row_count = [Int64] $SqlCmd.ExecuteScalar()
$sqlConnection.Close();
return $row_count
}
$cols = "col1", "col2", "col3", "...";
for ($i = $StartDate; $i -lt $EndDate; $i = $i.AddDays(1))
{
$MigrationStart = Get-Date
Write-Host "Migration started at: $MigrationStart"
$OldTableName = "HAProxyLogs.dbo.Log_"+($i.ToString("yyyy_MM_dd"))
$NewTableName = "TrafficLogs.dbo.HAProxyLogs_"+($i.ToString("yyyy_MM"))
Write-Host "Attempting to migrate "($i.ToString("yyyy_MM_dd"))" from " $OldTableName " to " $NewTableName;
# build the connection string and open it
$SrcConnStr = ConnectionString $ServerName
$SrcConn = New-Object System.Data.SqlClient.SQLConnection($SrcConnStr)
# whats the row count on the original table in HAProxyLogs
$originalRowCount = GetOriginalTableRowCount $SrcConn $OldTableName
Write-Host "Summary for " ($i.ToString("yyyy-MM-dd"))":
There are "$originalRowCount" rows in table "$OldTableName
# make sure there is no data in the table currently for the day
$newRowCount = GetNewTableRowCount $SrcConn $NewTableName
if($newRowCount -gt 0){
Write-Host "Rows found in the destination table - aborting migration"
break;
}
else {
Write-Host "No data for the date in the destination table - continuing migration"
}
# select the data we need from the original table
$selectCmdText = "select "+ ($cols -join ', ')
+", OriginalLogTable = 'Log_"+($i.ToString("yyyy_MM_dd"))
+"' from "+$OldTableName;
$SqlCommand = New-Object system.Data.SqlClient.SqlCommand($selectCmdText, $SrcConn)
$SqlCommand.CommandTimeout = 0;
$SrcConn.Open()
[System.Data.SqlClient.SqlDataReader] $SqlReader = $SqlCommand.ExecuteReader()
$destConnection = ConnectionString $ServerName
$bulkCopy = New-Object Data.SqlClient.SqlBulkCopy($destConnection)
$bulkCopy.BatchSize = 1048576
$bulkCopy.BulkCopyTimeout = 0 # 20*60
$bulkCopy.DestinationTableName = $NewTableName
$bulkCopy.NotifyAfter = 500000
# taken from https://blog.netnerds.net/2015/05/getting-total-number-of-rows-copied-in-sqlbulkcopy-using-powershell/
$bulkCopy.Add_SqlRowscopied(
{Write-Host "$($args[1].RowsCopied)/$originalRowCount rows copied
from $OldTableName to $NewTableName" })
foreach ($col in $cols){
$bulkCopy.ColumnMappings.Add($col,$col)
}
try {
Write-Host "Starting Migration"
$bulkCopy.ColumnMappings.Add("OriginalLogTable", "OriginalLogTable");
$bulkCopy.WriteToServer($SqlReader)
$total = [System.Data.SqlClient.SqlBulkCopyExtension]::RowsCopiedCount($bulkcopy)
Write-Host "$total total rows written"
}
catch {
$ex = $_.Exception
Write-Error $ex.Message
While($ex.InnerException)
{
$ex = $ex.InnerException
Write-Error $ex.Message
}
}
Finally
{
$SqlReader.Close()
$bulkCopy.Close()
$SrcConn.Close()
}
# once done update statistics on the new table
$statsCmdText = "update statistics "+$NewTableName
+" HAProxyLogs_"+($i.ToString("yyyy_MM"))+"_OriginalLogTable";
Invoke-DbaQuery -SqlInstance $ServerName -Query $statsCmdText;
Write-Host "After migration there are $total rows
were written to $NewTableName table from $OldTableName";
if($originalRowCount -ne $total){
Write-Host "total rows migrated does not match. Stopping operation"
break;
}
$total = 0;
}
复制代码
我所有的选项都很慢。
最终,我使用了 PowerShell 选项,它可以很容易地并行执行多个月份的迁移。我有 3 个 PowerShell 会话,同时处理 3 个月的表。利用 PowerShell,我可以在更短时间内获取更多数据。它仍然很慢——每个会话用大约一个星期移动了大约一个月的表,但起码比以前快了。
虽然 PowerShell 脚本并不完美,我也还是会遇到超时和错误的问题,但总体上来说效果更好。现在,我有了一个不需要每天手动处理的工作流程,该关注一下迁移中的其他一些问题了。
没有可用磁盘空间的问题
我必须尽量释放空间,同时继续将所有数据移动到新格式。
删除和缩小
我们的新硬件还没到,而这些是生产型 SQL Server,意味着旧数据库仍在接收新数据,我们每天都在添加新表。当我将表移到新数据库时,旧数据库还在添加新表,其中有数以亿行的数据。服务器都是一样的,硬盘空间捉襟见肘。
每迁移一个月的表后,我会进行最后一次验证,然后做一些清理。这包括删除刚迁移的月份的所有每日表,以及从新数据库的月度表中删除OriginalLogTable
列,腾出点空间。
我只是在同一硬盘上倒腾数据,所以腾出的空间很少。我们将格式从每日表更改为月度表,虽然新表获得了更好的压缩效果,但可用空间并没有增加很多。我们在更少的表中存储了相同数量的数据行,从长远来看这意味着更少的空间,但是为了多腾地方,我不得不缩小它们。
从旧文件中移出数据时,我也在增加新数据文件的大小。这都是在 E 盘上完成的,该分区已满 85-90%。
这就是像在走钢丝。
每个月的数据大小从 300GB 到 1TB 不等,三个旧数据文件每个大约 12TB。在每次迁移结束时,我都尝试将旧数据库文件缩小一些,减掉刚删除的部分。
一开始,我天真地尝试运行DBCC SHRINKFILE(FileName, TRUNCATEONLY)
以释放空间。如我所料,没用。
接下来,我尝试在每个文件上运行DBCCSHRINKFILE(FileName, CurrentFileSize-SizeOfDataJustDeleted)
。也不起作用。当你运行DBCCSHRINKFILE
并想将文件缩小 300GB 时,需要同样大小的中转空间才行。由于我们已经限制了存储 t 日志的分区空间,因此我无法缩小文件,毕竟 D 盘已经没地方了。
我有几个选择来释放 D 盘上的空间——移动tempdb
或将其他日志/数据库文件移动到我们新配置的具有 14TB 可用空间的 F 盘上。我决定移动tempdb
,只需执行以下命令并重新启动 SQL 服务即可。
alter database tempdb
modify file (NAME = tempdev, FILENAME = 'F:\Data\tempdev.mdf', SIZE = 20000MB);
alter database tempdb
modify file (NAME = templog, FILENAME = 'F:\Data\templog.ldf', SIZE = 1000MB);
alter database tempdb
modify file (NAME = tempdev2, FILENAME = 'F:\Data\tempdev2.mdf', SIZE = 20000MB);
alter database tempdb
modify file (NAME = tempdev3, FILENAME = 'F:\Data\tempdev3.mdf', SIZE = 20000MB);
alter database tempdb
modify file (NAME = tempdev4, FILENAME = 'F:\Data\tempdev4.mdf', SIZE = 1000MB);
复制代码
移动tempdb
不需要很多停机时间,我获得了大约 122GB 的可用空间,只是还不够。尝试将文件压缩为 300GB 或更大的块会很慢,还会影响生产。我找到了一种看似有希望的方案,类似这个样子:
declare @from int
declare @leap int
declare @to int
declare @datafile varchar(128)
declare @cmd varchar (512)
declare @starttime datetime
declare @endtime datetime
/*settings*/
set @from = 13533200 /*Current size in MB*/
set @to = 11600000 /*Goal size in MB*/
set @datafile = 'HAProxyLogs1' /*Datafile name*/
set @leap = 10000 /*Size of leaps in MB*/
while ((@from - @leap) > @to)
begin
set @starttime = getdate()
print concat('started: ', @starttime)
set @from = @from - @leap
set @cmd = 'DBCC SHRINKFILE (' + @datafile +', ' + cast(@from as varchar(20)) + ')'
print @cmd
exec(@cmd)
print '==> SHRINK SCRIPT - '+ cast ((@from-@to) as varchar (20)) + 'MB LEFT'
set @endtime = getdate()
print concat('ended: ', getdate(), ' took :' , datediff(minute, @starttime, @endtime))
end
set @cmd = 'DBCC SHRINKFILE (' + @datafile +', ' + cast(@to as varchar(20)) + ')'
print @cmd
exec(@cmd)
复制代码
我分别针对每个文件运行脚本,以从已删除月份中完全恢复磁盘空间。它很慢,但是有效。
使用新硬盘
设置新的 TrafficLogs 数据库时,我将带有存档文件(正在迁移的旧文件)的数据文件放到了机械硬盘分区上。我们没有考虑使用有 14TB 可用空间的新 F 盘,主要是因为它不足以完成整个迁移。但是几周后,出于以下几个原因,我决定使用这个硬盘:
不幸的是,这样做的唯一问题是,一旦我填满了 14TB 的磁盘空间,我就不得不将数据文件全部迁移回机械硬盘。2019 年 3 月末,我就这么干了。
硬盘,请坚持住
在 2019 年 5 月初,我还在努力迁移数据。这时我的流程回到了原点——从机械硬盘上的旧数据库复制数据,将其插入新数据库,删除旧数据,然后缩小文件,一切工作都在同样的硬盘上完成。
几个月来,我发现这一过程明显减慢了。自一月份以来,硬盘上的读/写延迟有所增加。读取的平均延迟超过 100 毫秒,而写入大约为 80 毫秒。
总体响应时间非常糟糕。
当我尝试将数据插入新表时,总会遇到PAGEIOLATCH_SH
等待。
我们使用那些机械硬盘已有数年之久,而 4 个月的迁移工作让它们不堪重负。我非常担心硬盘在完成所有数据迁移之前会出现故障。
所幸,坏事没发生。全部迁移工作用了 6 个月时间。
但事情还没完。我们还是没有新的服务器,这意味着新服务器和数据库到位后,前面创建的每个表都需要迁移。
新硬件终于来了
原计划是在 6 月/7 月获得新服务器,但结果推迟了几个月。为尽量简化新硬件的迁移任务,从 6 月到 9 月底,我每天迁移一个表以保持最新状态。
在 10 月初,新服务器已经准备好安装 SQL Server,任务快完成了。
最后两台机器都安装了 SQL Server 2017。现在是时候将巨型数据库迁移过去了。
备份和还原失败
当然,将数据库从旧服务器移动到新服务器的最直接方法是备份然后还原。我也这么尝试了,在备份时设置了脚本以将其写到新服务器上。但不幸的是,备份占用了大部分空间,结果我没有足够的临时空间还原它,太尴尬了。
再一次复制表
由于我无法做备份和还原,因此我只能一个个迁移表,像之前一年所做的一样。
我将在同一数据中心和机架中的服务器之间进行迁移,因此我想看看将数据从旧数据库批量插入到新数据库中有多快。我尝试了各种方法:
PowerShell 胜出。
是时候尝试可用性组了
我非常不想再次迁移几百个表,因此我提出了一个想法,使用可用性组(availability group)将数据库自动播种到新服务器。我们在整个基础架构中有了 AG,因此很熟悉它的用法,但我不确定它是否可以成功用于这么大规模的数据库。
我决定首先在科罗拉多服务器上设置 AG,如果成功就在纽约复制经验。需要完成以下步骤:
在科罗拉多州完成备份后,我意识到尝试自动播种时可能会有一个小问题。文档说:
自动播种要求参与可用性组的每个 SQL Server 实例上的数据和日志文件路径都相同
旧服务器的数据分别在 E 盘和 F 盘上,而新服务器用的是 D 盘。为了避免出问题,我迅速将新服务器上的分区重命名为 E,并使用以下命令将文件移到了上面:
use [master]
alter database [TrafficLogs]
modify file(name= TrafficLogs_Current, filename = 'E:\Data\TrafficLogs_Current.mdf');
alter database [TrafficLogs]
modify file(name= TrafficLogs_log, filename = 'E:\Data\TrafficLogs_log.ldf');
复制代码
于是我还必须将tempdb
移至新分区,完成所有操作后就该尝试设置 AG 了。
我在科罗拉多州建立了临时 AG,等待 SQL Server。我一直在检查两个 DMV:sys.dm_hadr_automatic_seeding
和sys.dm_hadr_physical_seeding_stats
,以查看播种过程的状态。大约 6 个小时后,我们播种了大约 17TB 的数据库。
从对网络流量的 SignalFX 监控中,我们还可以看到正在发生某些事情。下面的 SignalFX 图表显示,我们到一个盒的平均流量为 150-180Mb/sec,全都是数据库播种。
到第二天早上,播种工作已经结束,我们在新的科罗拉多服务器上有了一个包含所有表的数据库。
在启动故障转移之前,我在 AG 中的新服务器(也称为第二服务器)上运行了DBCCCHECKDB
,以确保数据库处于良好状态。22 小时后报告完成,未遇到任何问题。
是时候进行故障转移了。
所有这些工作在科罗拉多州进行时,我在纽约服务器上也启动了该过程。在花了 41 个小时备份 40TB 数据库后,我准备在纽约服务器上执行所有相同的步骤。我设置了群集、可用性组,播种数据库,运行了很长的 DBCCCHECKDB,并成功完成了故障转移。
最艰难的部分已经完成,但还有一些收尾工作要做。
最终清理
我们终于在数据库中安装了新服务器,但是还没有新的流量日志数据流到新服务器。发送流量日志的服务(流量处理服务,又称为 TPS)仍指向旧数据库。这样做是有目的的,以免打断任何使用数据的团队。我们的想法是,一旦服务器就位,我们就可以并行发布新版本,在一段时间段内在两个位置推送数据(从 HAProxy 向两个服务器发送系统日志流量)。这将允许团队逐步将其流程移植到新的数据库和表结构。
故障转移后的一周,我们发布了新的流量处理服务,而我又从旧服务器迁移了几天的数据。我还销毁了可用性组和 Windows 故障转移群集。完成所有步骤后,终于可以庆祝了。
总结
在 Stack Overflow 担任 DBA 的这段时间里,我有机会从事各种大型项目,但只有这个花了这么长时间。所有的迁移、验证、删除和调整空间工作总计花费了大约 11 个月的时间。
老实说,我喜欢接手这些大项目。计划整个项目,并从头到尾完成任务是非常激动人心的。虽然我将大部分工作都自动化了,但这个项目还是太累人了,我绝对不想在近期内再做一次(除非我有足够的硬盘空间,用不着花 11 个月的时间干杂事)。这个项目在许多方面都具有挑战性,考虑到我必须在两台服务器上做同样的工作,这就变成了双重挑战。最后,我很高兴能成功使用一个可用性组将数据库播种到新服务器上,并且很高兴最后所有数据都完成了迁移。
原文链接:
https://www.tarynpivots.com/post/migrating-40tb-sql-server-database/
评论