A manufacturer I worked with had synchronous availability groups, Enterprise licensing on six cores per node, and a SAN replication contract. Their SQL Server disaster recovery design protected an order entry database the business would have been content to lose four hours of. Nobody had asked. The architecture came first and the numbers were reverse-engineered to fit it, which is the normal order and the wrong one.
Every technology below is mature and has been for a decade. What it costs, what it protects against, and whether it is the right answer all depend on two numbers that come from the business rather than from a product page.
RPO and RTO before anything else
Two numbers drive every technology decision. Pick the technology first and you will spend the project justifying whatever you already bought.
Recovery Point Objective is how much data the business accepts losing, measured in time. An RPO of 15 minutes means transactions committed in the final 15 minutes before the failure may be gone.
Recovery Time Objective is how long the business tolerates the database being unavailable, counted from the start of the outage rather than from the moment someone hands you the go-ahead.
Asking a business owner "what is your RTO" produces the answer "zero" every time. Ask three different questions:
- If this database stops, what stops with it? Name the process, not the system. "Warehouse pickers cannot scan" beats "the WMS is down."
- What does an hour of that cost in dollars, and who signs that number? A finance person, not an architect.
- If we lose the last N minutes of transactions, how do we reconstruct them?
I have run this exercise more than once and the answers keep surprising the people who commissioned it. A distribution client reversed its own priorities (composite, details changed). The order entry database, which IT treated as tier one, could lose four hours without harm: every order arrived by EDI and the trading partner held 72 hours of resends. The label printing database, which IT treated as tier three, could not lose four minutes, because a reprinted label with a duplicate serial number gets a truckload rejected at the customer dock. The DR spend moved.
Write the agreed numbers down per database, with a name next to each one.
Backups are the floor, and the log chain sets the RPO
Every technology below sits on top of backups and none of them replace backups.
In the full recovery model, your RPO is bounded by the interval between log backups plus whatever tail of the log you rescue after the failure. Log backups every hour means an RPO of an hour, no matter what you spent on the SAN. The interval is also what keeps the log file from growing without limit, which is the other half of the same decision: when the log fills before the backup runs, you find out how wide the gap had grown.
The chain matters more than the individual files. A full backup, the differentials taken since, and an unbroken sequence of log backups from the full to the point of failure: break it anywhere and everything after the break is unrestorable. Things that break it: a log backup taken to somebody's laptop without COPY_ONLY, a switch to simple recovery and back, a restore over the top of the original.
This query walks the log backup history in msdb and reports gaps. In a healthy chain each log backup's first_lsn matches the previous backup's last_lsn.
WITH log_chain AS
(
SELECT
bs.database_name,
bs.backup_set_id,
bs.backup_start_date,
bs.backup_finish_date,
bs.first_lsn,
bs.last_lsn,
bs.database_backup_lsn,
bs.begins_log_chain,
bs.is_copy_only,
bs.has_backup_checksums,
bs.is_damaged,
LAG(bs.last_lsn) OVER (PARTITION BY bs.database_name, bs.family_guid
ORDER BY bs.backup_start_date, bs.backup_set_id) AS prior_last_lsn
FROM msdb.dbo.backupset AS bs
WHERE bs.type = 'L'
AND bs.is_copy_only = 0 -- copy-only log backups do not move the archive point
AND bs.backup_start_date >= DATEADD(day, -14, GETDATE())
)
SELECT
database_name,
backup_start_date,
prior_last_lsn,
first_lsn,
CASE
WHEN begins_log_chain = 1 THEN 'New chain started here'
WHEN first_lsn <> prior_last_lsn THEN 'GAP: log backup taken elsewhere'
END AS finding
FROM log_chain
WHERE prior_last_lsn IS NOT NULL
AND (first_lsn <> prior_last_lsn OR begins_log_chain = 1)
ORDER BY database_name, backup_start_date;
family_guid stays constant across restores of the same original database, so partitioning by it keeps a restored copy's history out of the comparison. begins_log_chain flags a backup that starts a fresh chain, which tells you the break was deliberate. The is_copy_only = 0 filter matters: a copy-only log backup preserves the existing log archive point and does not affect the sequencing of regular log backups, so leaving them in reports a gap that is not there.
The companion check is coverage: which databases in full or bulk-logged recovery have gone too long without a log backup.
SELECT
d.name,
d.recovery_model_desc,
ls.log_backup_time,
DATEDIFF(minute, ls.log_backup_time, GETDATE()) AS minutes_since_log_backup,
CAST(ls.log_since_last_log_backup_mb AS decimal(18,1)) AS log_since_last_backup_mb,
CAST(ls.total_log_size_mb AS decimal(18,1)) AS total_log_mb,
ls.log_truncation_holdup_reason
FROM sys.databases AS d
CROSS APPLY sys.dm_db_log_stats(d.database_id) AS ls
WHERE d.recovery_model_desc IN ('FULL', 'BULK_LOGGED')
AND d.state_desc = 'ONLINE'
AND d.database_id <> 2
ORDER BY minutes_since_log_backup DESC;
sys.dm_db_log_stats arrived in SQL Server 2016 SP2. On older instances, fall back to log_reuse_wait_desc in sys.databases and the backupset query above.
A backup you have never restored is not a backup
Take every backup WITH CHECKSUM. It is not the default. SQL Server then verifies existing page-level checksums or torn-page detection as it reads each page, generates a checksum over the whole stream, and fails the backup on a page error. Restore verifies those checksums whenever they are present on the media. Add CONTINUE_AFTER_ERROR only for a database you already know is damaged and want salvaged; it flags the backup set as containing errors and records the bad pages in suspect_pages.
BACKUP DATABASE [Sales]
TO DISK = N'\\backup01\sql\Sales_full.bak'
WITH CHECKSUM, STATS = 5, COMPRESSION;
RESTORE VERIFYONLY FROM DISK = N'\\backup01\sql\Sales_full.bak' WITH CHECKSUM;
BACKUP and RESTORE work in every edition. COMPRESSION and encrypted backup are Enterprise and Standard only, and Express has no SQL Server Agent to schedule any of it.
Microsoft's list of what VERIFYONLY does is short: confirms the backup set is complete and all volumes readable, checks some header fields of database pages such as the page ID, validates the checksum if one is on the media, and confirms there is room on the destination. It does not verify the structure of the data inside the backup. Passing means the file is readable, not that the database inside it will come up.
The real test restores to a different instance and runs DBCC CHECKDB on the result. Automate it against a rotating subset of databases and alert on failure. A restore test someone has to remember to run stops happening in week three.
RESTORE DATABASE [Sales_RestoreTest]
FROM DISK = N'\\backup01\sql\Sales_full.bak'
WITH MOVE N'Sales' TO N'E:\RestoreTest\Sales.mdf',
MOVE N'Sales_log' TO N'F:\RestoreTest\Sales_log.ldf',
RECOVERY, CHECKSUM, STATS = 5;
DBCC CHECKDB ([Sales_RestoreTest]) WITH NO_INFOMSGS, ALL_ERRORMSGS, DATA_PURITY;
Time that restore and record the number. It is the only honest input you have for the RTO of a restore-based recovery, and it grows with the database.
Always On availability groups
An availability group streams log records from the primary to each secondary, which hardens and redoes them into its own copy of the files. Every replica has its own storage.
Synchronous commit holds the transaction open on the primary until the secondary has hardened the log record to disk and acknowledged it. The primary writes the log record, sends it concurrently, waits for the acknowledgment, and then confirms the commit to the client. This is the only mode that supports automatic failover, and it needs the secondary in the SYNCHRONIZED state with the WSFC holding quorum.
Asynchronous commit sends the log record and confirms the commit without waiting. The secondary never reaches SYNCHRONIZED. The only failover it supports is FORCE_FAILOVER_ALLOW_DATA_LOSS.
Across any real distance, asynchronous is the answer, and the reason is latency arithmetic. Every write on a synchronous replica pays a round trip. At 12ms to a site 400 miles away, a single-threaded batch doing 5,000 commits takes an extra minute. Concurrent work absorbs some of that, and a nightly serial job absorbs none of it. Teams discover this after they cut over.
Synchronous commit is also not a guarantee of zero data loss. When a synchronous secondary stops responding for longer than the primary's session timeout, which defaults to 10 seconds, the primary marks the databases on that replica NOT SYNCHRONIZING and keeps committing without it. Brent Ozar wrote the piece most people cite on this, "Synchronous Always On Availability Groups Is Not Zero Data Loss" (September 2015). Nobody gets paged, and if the primary then fails, the data written during that window is gone. SQL Server 2017 added REQUIRED_SYNCHRONIZED_SECONDARIES_TO_COMMIT to close the hole: raise it above its default of 0 and commits on the primary fail when the required number of synchronized secondaries is missing. That trades availability for durability, so make the trade on purpose.
The maximums moved too. SQL Server 2019 raised the number of synchronous-commit replicas to five including the primary, up from three in SQL Server 2017, and automatic failover works within that group. Every version from SQL Server 2014 forward supports one primary and up to eight secondaries; SQL Server 2012 was limited to four secondaries and three synchronous-commit replicas.
Microsoft's own pages disagree about that five. The 2022 editions matrix footnote reads "up to 8 secondary replicas, including 5 synchronous secondary replicas," which would make six. The ALTER AVAILABILITY GROUP reference still carries the pre-2019 limit of three, including the primary, for both SYNCHRONOUS_COMMIT and FAILOVER_MODE = AUTOMATIC. The availability modes page is the one that is version-attributed and spells out the breakdown as one primary plus four synchronous secondaries, so that is the number I design to. Confirm it against your build before you commit a topology to it.
Measure the RPO against the async secondary rather than assuming it:
SELECT
ar.replica_server_name,
DB_NAME(drs.database_id) AS database_name,
drs.synchronization_state_desc,
drs.synchronization_health_desc,
drs.last_commit_time,
drs.secondary_lag_seconds,
drs.log_send_queue_size AS log_send_queue_kb,
drs.redo_queue_size AS redo_queue_kb,
drs.redo_rate AS redo_rate_kb_per_sec,
CASE WHEN drs.redo_rate > 0
THEN drs.redo_queue_size / (drs.redo_rate * 1.0)
END AS estimated_redo_seconds
FROM sys.dm_hadr_database_replica_states AS drs
JOIN sys.availability_replicas AS ar
ON ar.replica_id = drs.replica_id
ORDER BY ar.replica_server_name, database_name;
log_send_queue_size is your data loss exposure if the primary dies right now. redo_queue_size divided by redo_rate is the extra time the secondary needs after a failover before it serves queries, and it belongs inside your RTO. Both are KB. secondary_lag_seconds has been available since SQL Server 2016.
The secondary after a failover, and read-only routing
After a forced failover with data loss, the former primary databases and any remaining secondaries sit suspended until you resume each one by hand. Put that in the runbook with the divergence it implies: transactions that existed on the old primary and never reached the new one are gone unless you pull them out of the suspended copy first.
Read-only routing sends read-intent connections to a readable secondary, and only when the client uses TCP, sets ApplicationIntent=ReadOnly, connects through the listener, and names a database in the group. Set the URL under each replica's secondary role, then the list under its primary role:
ALTER AVAILABILITY GROUP [AG1]
MODIFY REPLICA ON N'SQLDR01'
WITH (SECONDARY_ROLE (ALLOW_CONNECTIONS = READ_ONLY,
READ_ONLY_ROUTING_URL = N'TCP://SQLDR01.contoso.com:1433'));
-- SQLDR02 needs its own SECONDARY_ROLE block, same shape as SQLDR01,
-- or it sits in the list below and never receives a routed connection.
ALTER AVAILABILITY GROUP [AG1]
MODIFY REPLICA ON N'SQLPRD01'
WITH (PRIMARY_ROLE (READ_ONLY_ROUTING_LIST = (('SQLDR01','SQLDR02'), 'SQLPRD01')));
The nested parentheses load-balance across the replicas in the inner set, which SQL Server 2016 added. Set the list on every replica that can become primary, or read-only routing dies at the first failover.
An availability group is not a backup
Someone runs DELETE FROM dbo.Orders without a WHERE clause. The log records ship to every secondary within milliseconds and every replica loses the rows. Point-in-time restore from backups is the only recovery.
Physical corruption behaves the opposite way, which is its own problem. Each replica writes its own pages, so a failing HBA on the primary corrupts pages there and not on the secondary. Automatic page repair covers that: a replica that cannot read a page requests a fresh copy from a partner. Check whether it has been firing, because a busy one means hardware is dying somewhere:
SELECT
DB_NAME(database_id) AS database_name,
file_id,
page_id,
error_type, -- 2 = bad checksum, 3 = torn page
page_status, -- 4 = repaired, 5 = repair failed
modification_time
FROM sys.dm_hadr_auto_page_repair
ORDER BY modification_time DESC;
Because each replica carries independent physical pages, DBCC CHECKDB on the primary tells you nothing about the secondary. Run it on both.
The redo queue is the other thing an AG makes you own. A nightly index rebuild generates log in proportion to the index, ships all of it, and a secondary that cannot redo fast enough turns your RPO into whatever the backlog says. Maintenance that floods the redo queue is worth sizing before you schedule it.
Backups in an AG have their own rules. The automated backup preference defaults to Secondary and SQL Server does not enforce it: your job calls sys.fn_hadr_backup_is_preferred_replica and exits when it returns 0. Through SQL Server 2022 a secondary supports copy-only full backups and regular log backups and nothing else, so differentials and non-copy-only fulls run on the primary. Log backups taken on any replica form one consistent chain. SQL Server 2025 added full and differential backups on secondaries.
DECLARE @DatabaseName sysname = N'Sales';
DECLARE @BackupPath nvarchar(400) = N'\\backup01\sql\Sales_'
+ CONVERT(nvarchar(8), GETDATE(), 112)
+ REPLACE(CONVERT(nvarchar(8), GETDATE(), 108), ':', '') + N'.trn';
IF sys.fn_hadr_backup_is_preferred_replica(@DatabaseName) = 1
BEGIN
BACKUP LOG @DatabaseName
TO DISK = @BackupPath
WITH CHECKSUM, COMPRESSION;
END
Basic availability groups on Standard Edition
Standard Edition does not get availability groups. It gets basic availability groups, introduced in SQL Server 2016, and the restrictions decide whether they fit:
- Two replicas. One primary, one secondary.
- One database per availability group. Not one per replica. One, total.
- No read access on the secondary.
- No backups on the secondary.
- No integrity checks on the secondary.
- Cannot be upgraded in place to an advanced availability group. You drop it and recreate it on Enterprise.
- Cannot participate in a distributed availability group.
The full edition ledger runs wider than this one feature, and what two replicas and one database cover is the question to settle before the licensing conversation starts.
Multiple basic availability groups can coexist on one instance, which is how people cover several databases. That holds until an application needs two of them consistent with each other after a failover, because each group fails over on its own: an order system with orders and inventory in separate groups can end up with the halves on different servers at different points in time. For an application that spans databases, log shipping is the better answer.
Failover cluster instances
An FCI protects the instance. Two or more nodes share storage, the instance runs on one at a time, and the WSFC moves it to a surviving node on hardware, OS, or service failure. Clients reconnect to the same virtual network name. It covers a dead node, a failed NIC, a Windows patch, an instance that will not start on one machine. Enterprise supports up to 16 nodes, Standard supports two.
It does not cover the storage. Microsoft states it plainly: shared storage "has the potential of being the single point of failure," and the FCI depends on the storage solution for data protection. One SAN, one LUN, one corruption event, and both nodes see the same broken database. An FCI is a high availability tool and on its own not a disaster recovery tool.
The combination that works pairs an FCI for local availability with an AG replica at the DR site for the database-level copy. A replica hosted on an FCI supports manual failover only, so plan for the hand-off.
Log shipping
Log shipping is three SQL Agent jobs, a backup on the primary, a copy that moves the files, a restore on the secondary, plus an optional monitor server that records history and raises alerts when a step misses its schedule. Failover is manual. It stays the right answer in several situations, and I have deployed it inside the last year:
- Standard Edition with multiple related databases. No one-database limit, no per-database failover boundary. You restore the set together.
- A deliberate delay against human error. Hold the restore job back four hours and you have a warm copy from before someone dropped the table. An AG has no equivalent delay knob; secondaries redo continuously.
- Several secondaries in different places. Log shipping fans out to as many destinations as you want.
- A network that will not carry a steady asynchronous stream. It moves files on a schedule and survives a link that drops for twenty minutes.
- A secondary in
STANDBYfor read-only reporting, at the cost of disconnecting readers during each restore.
The RPO is the log backup interval plus copy and restore latency. The RTO covers restoring whatever the secondary has not applied plus recovery of the database. Measure it rather than estimate it.
Quorum, witnesses, and the split-brain problem
Automatic failover in an AG and failover in an FCI both depend on Windows Server Failover Clustering, which decides who survives by counting votes. Each node gets one, a witness can hold one, and more than half must be active for the cluster to run.
Split brain is what quorum stops: two halves of the cluster, separated by a network failure, each concluding it is the survivor and each bringing the database online. Microsoft calls it a scenario "where separate sections of the cluster each believe they're functioning independently, which can result in data inconsistencies or corruption." When the active vote count drops below the majority the cluster shuts down rather than risk it, so a cluster that refuses to start after a site loss is doing its job.
Configure an odd number of voting elements, and with an even number of nodes add a witness. Three types:
- Disk witness. A shared disk all nodes reach, holding a copy of the cluster configuration database. No use across sites, since a stretched cluster has no shared disk.
- File share witness. SMB 2 or later, 5MB free, dedicated to one cluster, on a machine separate from the nodes and sites. DFS and replicated storage are not supported, and Microsoft's reason is precise: DFS "can cause a partition-in-space or partition-in-time, where the cluster nodes are running independently of each other and could cause data loss." A non-domain-joined share requires the cluster to run Windows Server 2019 or later.
- Cloud witness. An Azure Standard general purpose V2 storage account on port 443, holding one blob in a container named
msft-cloud-witness, locally redundant for an on-premises cluster. For a two-site cluster this is the right answer, because the witness sits in neither site.
The failure I see most: a two-node, two-site cluster with the file share witness in the primary data center. Lose that data center and the DR node holds one vote out of three. No quorum, no automatic failover, and an engineer forcing quorum by hand at 3am. Put the witness in a third location.
Windows Server 2012 R2 added dynamic witness, which grants the witness a vote when the node count is even and withdraws it when odd. Check what the cluster believes:
Get-Cluster | Select-Object Name, DynamicQuorum, WitnessDynamicWeight
Get-ClusterNode | Select-Object Name, State, NodeWeight, DynamicWeight
Get-ClusterQuorum
Run this before an outage, not during one.
What each technology delivers
These assume a healthy network and a database you have measured. They are starting points for your own testing, not a specification.
| Technology | Typical RPO | Typical RTO | Automatic failover | Edition | Protects against |
|---|---|---|---|---|---|
| Full plus log backups to local disk | Log backup interval (5 to 60 min) | Hours, scaling with database size | No | All | Corruption, human error, total loss |
| Log shipping | Log backup interval plus copy plus restore latency (15 to 60 min) | 15 min to hours | No | Enterprise, Standard, Web | Site loss, human error (with delay) |
| Basic availability group, synchronous | Near zero within the sync window | 30 to 120 sec | Yes | Standard, one DB per group | Node loss, storage loss |
| Availability group, synchronous commit | Near zero within the sync window | 30 to 120 sec | Yes | Enterprise | Node loss, storage loss |
| Availability group, asynchronous commit | Seconds to minutes, measured by log_send_queue_size |
Minutes, plus redo queue drain | No, forced failover only | Enterprise | Site loss |
| Distributed availability group | Same as the underlying async link | Minutes, manual | No, manual only | Enterprise, 2016 and later (Standard for the Azure SQL Managed Instance link) | Loss of an entire cluster |
| Failover cluster instance | Zero, same storage | 30 sec to several min | Yes | Enterprise (16 nodes), Standard (2 nodes) | Node loss only |
Two rows deserve emphasis. An FCI's RPO is zero because both nodes read the same files, which is also why its protection against a storage disaster is zero. And an asynchronous availability group's RTO covers more than the failover command: the redo queue draining, DNS or connection strings pointing somewhere new, and the application restarting its pools.
The order to work in when a DR plan is missing
- Write down RPO and RTO per database, with a business owner's name on each number.
- Fix backups. Full, differential, and log backups
WITH CHECKSUM, a log backup interval that meets the RPO, and copies off the primary storage. - Automate a restore test to a separate instance with
DBCC CHECKDBon the result. Alert on failure, and record how long it takes. That number is the RTO of a restore-based recovery and it grows with the database. - Choose the technology against the numbers from step 1 and the edition you own. Do not buy Enterprise before you have proved Standard cannot meet the number.
- Configure quorum with the witness outside both data centers. Verify with
Get-ClusterQuorumand record what you expect to see.
Steps 1 and 3 are the ones teams skip. Step 1 decides whether the rest of the spend is aimed at anything, and step 3 is the only evidence that the floor under all of it holds.
A topology that meets the numbers still fails if nobody has rehearsed it, and the failure mode is a four-hour argument about who gets to say the word "disaster." Writing the runbook and testing it is the other half of this work.
If you would rather hand this off, it is the kind of work I do. See SQL Server DBA services, review client results, or get in touch.