Дата, время и часовые пояса в бизнес-приложениях — от ловушек DateTime до принципа хранения в UTC и проектирования тестов
· Го Комура · C#, .NET, .NET Framework, Windows, Часовые пояса, Обработка даты и времени, Тестирование, Эксплуатация, Техническая консультация
«Перенесли сервер на облачную VM — и время во всех отчётах сдвинулось на 9 часов». «На устройствах, выданных зарубежному офису, дата в ежедневном отчёте почему-то оказывается предыдущим днём». «Похоже, что ночной пакетный расчёт в какой-то день сработал дважды». Обращения, связанные с датой, временем и часовыми поясами, приходят именно в такой форме — «в тот самый момент, когда изменилось окружение». И это притом, что в коде не поменялась ни одна строка.
Часто считают: «наше приложение работает только внутри Японии, так что часовые пояса нас не касаются» — но на практике значительная часть подобных обращений происходит именно с такими, казалось бы, чисто внутренними приложениями. Облачные VM и контейнеры очень часто выдаются с настройкой UTC, а зарубежные библиотеки и веб-API SaaS-сервисов возвращают метки времени в UTC или со смещением. Само приложение может считать, что живёт исключительно в японском времени, но по ту сторону границы уже давно царит мир UTC. Если значения передаются без явного указания, «относительно чего измерено это время», несоответствие остаётся незаметным до дня переноса сервера или перехода в облако — и тогда проявляется разом, в виде сдвига на 9 часов.
В этой статье, ориентируясь на бизнес-приложения на .NET, мы последовательно разберём свойство Kind у DateTime и ловушки неявных преобразований, случаи, когда стоит использовать DateTimeOffset, принцип «хранение и передача — в UTC или со смещением, локальное время — только для отображения», TimeZoneInfo и переход на летнее время, границу с базой данных и, наконец, проектирование тестов с помощью TimeProvider. В конце мы сведём пункты, которые регулярно проверяем на ревью проектных решений, в чек-лист.
1. Сначала вывод
- Корень практически всех инцидентов с датой и временем один: значение, не несущее информации о том, «относительно чего измерено это время», пересекает границу (БД, API, файл). Симптом проявляется не в день изменения кода, а в день изменения окружения.
DateTimeнесёт атрибут Kind (Utc / Local / Unspecified), значение по умолчанию — Unspecified.ToLocalTimeсчитает Unspecified значением в UTC, аToUniversalTimeсчитает его локальным — эта асимметричная неявная интерпретация и есть классический источник «сдвига на 9 часов».1- Для нового кода значением по умолчанию должен быть
DateTimeOffset. Официальное руководство прямо говорит: «рассматривайте его как тип даты и времени по умолчанию для разработки приложений».2 - Принцип таков: «хранение и передача — в UTC или со смещением, локальное время — только для отображения». При превращении значения в строку записывайте его в ISO 8601-совместимом раунд-трип формате “o”, а при чтении обратно используйте
DateTimeStyles.RoundtripKind.3 - Преобразование часовых поясов выполняется через
TimeZoneInfo. Начиная с .NET 6 можно использовать как IANA ID (Asia/Tokyo), так и Windows ID (Tokyo Standard Time), а также API для взаимного преобразования между ними.4 Однако разрешение IANA ID в Windows зависит от ICU и не работает на старых сборках Windows Server или в режиме неизменной глобализации (глава 4).5 - Даже в Японии, где нет летнего времени, вы неизбежно столкнётесь с «несуществующим» и «неоднозначным» временем DST в тот момент, когда в дело вступает устройство зарубежного офиса, интеграция с зарубежным SaaS-сервисом или сервер с настройкой UTC.6
- Откажитесь от прямого использования
DateTime.Now. ВнедряйтеTimeProvider(стандартен в .NET 8; для более старых целевых платформ используйте Microsoft.Bcl.TimeProvider), чтобы тесты могли свободно управлять временем.7
2. Kind у DateTime — что означают три значения, и инцидент с неявным преобразованием
Помимо самого значения даты и времени (Ticks), DateTime несёт ровно один дополнительный атрибут — Kind. У него три возможных значения, и по умолчанию используется Unspecified.1
| Kind | Значение | Типичный источник |
|---|---|---|
| Utc | Время, измеренное относительно UTC | DateTime.UtcNow, результат ToUniversalTime() |
| Local | Время, измеренное относительно локального часового пояса машины выполнения | DateTime.Now, результат ToLocalTime() |
| Unspecified | Базовое время неизвестно (значение по умолчанию) | new DateTime(...), DateTime.Parse (в большинстве случаев), значения, прочитанные из БД |
Важно то, что при обычном написании кода почти всё оказывается со значением Unspecified. Значения, созданные конструктором, распарсенные из строки или прочитанные из БД, по умолчанию имеют «неизвестное базовое время». Само по себе это не проблема. Проблема в том, что методы преобразования молча делают предположение о базовом времени для таких значений Unspecified.1
| Вызов | Kind=Utc | Kind=Local | Kind=Unspecified |
|---|---|---|---|
ToUniversalTime() |
возвращается как есть | преобразуется в UTC | считается локальным и преобразуется в UTC |
ToLocalTime() |
преобразуется в локальное | возвращается как есть | считается UTC и преобразуется в локальное |
Ключевой момент в том, что одно и то же значение Unspecified в зависимости от вызываемого метода интерпретируется то как «локальное», то как «UTC». В коде это выглядит так:
// Значение, прочитанное из БД. По многим путям получает Kind = Unspecified
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);
// Если машина выполнения настроена на JST (UTC+9):
Console.WriteLine(fromDb.ToLocalTime()); // 18:00 -- считается UTC, поэтому +9 часов
Console.WriteLine(fromDb.ToUniversalTime()); // 00:00 -- считается локальным, поэтому -9 часов
Инцидент возникает именно в этой форме. Если значение, сохранённое в БД как японское местное время 09:00 (Unspecified), пропустить через «на всякий случай» вызов ToLocalTime() прямо перед отображением, оно будет воспринято как UTC и превратится в 18:00. И наоборот, если где-то по пути дублируется преобразование, задуманное как «привести к UTC перед сохранением», 9 часов вычитаются дважды. Ещё неприятнее то, что это поведение зависит от настройки часового пояса машины выполнения. На машине разработки (JST) сдвиг составит 9 часов, на сервере с настройкой UTC — 0 часов, что порождает классическое «у меня не воспроизводится». «Сдвиг на 9 часов после переноса сервера», упомянутый в начале статьи, чаще всего оказывается именно этим сценарием.
2.1 Отличие от DateTimeOffset и когда что использовать
DateTimeOffset всегда несёт смещение от UTC (например, +09:00) вместе с датой и временем, поэтому одно лишь значение однозначно определяет конкретный момент в любой точке мира. Для сценариев «фиксации момента» — записей в лог, времени транзакций, записей о системных событиях — официальное руководство прямо рекомендует рассматривать DateTimeOffset как тип даты и времени по умолчанию.2 Поскольку места для неявной интерпретации Kind попросту не остаётся, большинство инцидентов, о которых идёт речь в этой статье, становятся структурно невозможными.
Тем не менее это не панацея. Учитывайте, что DateTimeOffset несёт смещение, а не саму тайм-зону. +09:00 не говорит о том, идёт ли речь о Японии или Корее, и не несёт правил перехода на летнее время.2 Если нужно воспроизвести «который час показывали бы настенные часы в этом конкретном месте», всё равно требуется сочетание с TimeZoneInfo, о котором пойдёт речь ниже. Сведём выбор типа в таблицу.
| Тип | Какую информацию несёт | Для чего хорошо подходит | Примечание |
|---|---|---|---|
DateTimeOffset |
Дата и время + смещение от UTC | Фиксация момента события, логи, границы API | Значение по умолчанию для нового кода2 |
DateTime (при работе с Kind=Utc) |
Только дата и время | Внутренние вычисления, совместимость с существующими активами | Управление Kind целиком на вашей стороне |
DateOnly / TimeOnly |
Только дата / только время | Бизнес-даты, часы работы, время закрытия | Недоступны в .NET Framework2 |
TimeSpan |
Продолжительность | Прошедшее время, разница между двумя моментами | |
TimeZoneInfo |
Определение часового пояса (включая правила перехода) | Преобразование, определение летнего времени | Глава 4 |
Переписать все существующие значения DateTime на DateTimeOffset часто нереалистично, поэтому компромисс, который мы обычно применяем в проектах модернизации, звучит так: «внутреннее представление и хранение унифицировать на DateTime с Kind=Utc, а на границах (API, сериализация) использовать DateTimeOffset или строку в формате “o”». В любом случае принцип из следующей главы служит фундаментом для обоих подходов.
3. Принцип — хранение и передача в UTC или со смещением, локальное время только для отображения
Принцип проектирования обработки даты и времени укладывается в три строки.
- Фиксируйте момент события через
DateTime.UtcNowилиDateTimeOffset.UtcNowи переносите его в UTC (или со смещением) на всём пути. - При пересечении границы (БД, API, файл, реестр) явно указывайте формат и базу отсчёта как часть спецификации.
- Преобразование в локальное время выполняйте ровно один раз — непосредственно перед выводом на экран или в отчёт.
Проектирование, при котором хранится локальное время, ставит «смысл» значения в зависимость от внешнего состояния — настройки ОС сервера. Пока приложение работает на локальном сервере с японской настройкой, проблема остаётся незаметной, но перенос на облачную VM, конфигурация DR в зарубежном регионе или расхождение настроек между dev и production — и любого из этих факторов достаточно, чтобы смысл изменился. При хранении в UTC значение имеет одинаковый смысл в любом окружении. Преобразование для отображения выполняется на стороне потребителя (по настройке пользователя или по часовому поясу площадки, хранящемуся в справочнике пользователей), поэтому одни и те же данные, просматриваемые из Токио и из Берлина, каждый раз показывают корректное для этого места время настенных часов.
3.1 Строковое представление на границах — формат ISO 8601 / “o”
Когда граница пересекается в виде строки (JSON, CSV, лог, конфигурационный файл), используйте ISO 8601-совместимый раунд-трип формат “o”. “o” сохраняет в строке Kind у DateTime и смещение у DateTimeOffset, а разбор с указанием DateTimeStyles.RoundtripKind восстанавливает исходное значение.3
using System.Globalization;
// Запись: 2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);
// Чтение обратно: восстанавливается с сохранением смещения
var restored = DateTimeOffset.Parse(
s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
Обязательно сочетайте это с CultureInfo.InvariantCulture. Если оставить культуру по умолчанию, обозначение года может измениться на устройстве, работающем с негригорианской культурой — например, с японским императорским календарём. Категорически нельзя хранить или передавать данные в формате, не несущем информации о базе отсчёта, вроде "yyyy/MM/dd HH:mm". Принимающей стороне не остаётся ничего, кроме как гадать, «относительно чего измерено это время», а догадки перестают быть верными в тот момент, когда меняется окружение. Отметим также, что представление даты и времени по умолчанию в System.Text.Json само по себе основано на ISO 8601, так что на границе JSON безопасно просто полагаться на значение по умолчанию.
Идея о том, что «формат данных, пересекающих границу, должен быть явно прописан в спецификации», касается не только даты и времени. Ровно тот же аргумент мы приводим применительно к кодировке символов и переводам строк в статье «Кодировки символов и переводы строк в Windows». Неявные допущения на границе ломаются одинаково, независимо от типа данных.
3.2 Разделяйте метку времени и «бизнес-дату»
Ещё одно разграничение — нужное для того, чтобы разобраться в примере «только у зарубежного офиса дата в ежедневном отчёте оказывается предыдущим днём» из введения. Метка времени (уникальный в мировом масштабе момент) и бизнес-дата (метка вроде «отчёт за 3 июля») — разные вещи. Если хранить бизнес-дату как «DateTime в полночь» и пропускать её через преобразование в UTC, полночь 3 июля по UTC+9 превращается в 15:00 2 июля по UTC — и в момент, когда вырезается только часть с датой, она сдвигается на день назад. Это классический механизм возникновения такой ошибки.
Храните бизнес-дату как DateOnly (а в .NET Framework — как строку в формате yyyy-MM-dd или как простое значение год/месяц/день) и решите в рамках спецификации, по какому часовому поясу вырезается дата. Если в спецификации написано что-то вроде «дата ежедневного отчёта определяется по местному времени площадки» или «закрытие периода определяется по JST головного офиса», реализация сводится к простому: преобразовать UtcNow в соответствующий часовой пояс, затем вырезать дату. Если в спецификации этого не написано, значит, спецификацией по умолчанию стала настройка машины конкретного разработчика.
4. Преобразование часовых поясов — TimeZoneInfo и системы идентификаторов
Преобразование часовых поясов выполняется через ConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime у TimeZoneInfo. На что нужно обратить внимание: эти API проверяют согласованность Kind у DateTime с исходным часовым поясом преобразования. Например, передача значения с Kind=Utc с указанием «источник — Токио» приводит к ArgumentException.8 Иными словами, код, небрежно управляющий Kind, не может даже корректно вызвать API преобразования часовых поясов. Это прямо связано с материалом главы 2.
4.1 Идентификаторы часовых поясов Windows и IANA
Для указания часового пояса существуют две системы идентификаторов.
| Windows ID | IANA ID | |
|---|---|---|
| Пример (Япония) | Tokyo Standard Time |
Asia/Tokyo |
| Пример (Германия) | W. Europe Standard Time |
Europe/Berlin |
| Кто управляет | Windows (реестр) | база данных IANA tz |
| Основное применение | Windows API, .NET (Framework) | Linux, зарубежные SaaS-сервисы, веб-API, другие языки |
В эпоху .NET Framework можно было использовать только Windows ID, что порождало постоянную проблему таблиц соответствия: «веб-API присылает Asia/Tokyo, но передать это в FindSystemTimeZoneById нельзя». Начиная с .NET 6 TimeZoneInfo.FindSystemTimeZoneById принимает идентификаторы обеих систем, и если переданный ID не зарегистрирован локально, он автоматически преобразуется и разрешается. Для явного преобразования также добавлены TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId.4
// .NET 6+: IANA ID работает как есть (Windows ID "Tokyo Standard Time" даёт тот же результат)
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo)); // время настенных часов в Токио
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin)); // время настенных часов в Берлине
// Взаимное преобразование между системами ID (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
Console.WriteLine(ianaId); // Asia/Tokyo
Практическая рекомендация: унифицировать идентификаторы часовых поясов, хранящиеся в справочнике площадок или конфигурационных файлах, на IANA ID. IANA ID — это то, что универсально понимают зарубежные SaaS-сервисы, Linux-контейнеры и другие языки, а на стороне .NET начиная с версии 6 их, как правило, можно принимать как есть. Если часть системы всё ещё работает на .NET Framework, преобразуйте в Windows ID только на этой конкретной границе. Учтите также, что FindSystemTimeZoneById выбрасывает TimeZoneNotFoundException, если ID не найден, — отлавливайте это как можно раньше, либо на экране регистрации ID площадок, либо при проверке во время запуска.
Есть одно важное предварительное условие. Разрешение IANA ID в Windows зависит от библиотеки ICU. Приложения, работающие в режиме NLS или в режиме неизменной глобализации (InvariantGlobalization=true), не могут разрешить IANA ID, и TryConvertIanaIdToWindowsId тоже завершается неудачей.5 То же ограничение действует при запуске .NET 6 на более старой ОС, где ICU не входит в поставку системы (Windows Server 2019, Windows 10 сборок 1809 и старше и т. п.), если только вы не поставляете ICU локально вместе с приложением (начиная с .NET 7 это изменено — ICU используется и на этих версиях ОС9). Иными словами, ситуация «Asia/Tokyo прекрасно разрешается на машине разработки (Windows 11), но на Windows Server 2019 у заказчика выдаёт TimeZoneNotFoundException» — вполне реальна. Если вы принимаете IANA ID в качестве справочных данных, сочетайте это решение с тремя мерами: (1) убедитесь, что разрешение IANA действительно работает на той ОС и версии .NET, на которых будет выполняться код; (2) не включайте неосмотрительно InvariantGlobalization в контейнере просто ради уменьшения размера образа; (3) в качестве страховки встройте в проверку при запуске откат через TryConvertIanaIdToWindowsId на Windows ID с повторной попыткой.
5. Летнее время (DST) — с чем сталкиваются даже чисто японские приложения
Поскольку в Японии сейчас нет летнего времени, легко предположить, что «DST нас не касается». Но вы неизбежно с ним столкнётесь, если применимо хотя бы одно из следующего:
- Приложение работает на устройствах в зарубежных офисах или у сотрудников в командировках за рубежом (локальный часовой пояс устройства использует DST)
- Приложение интегрируется с зарубежными SaaS-сервисами или веб-API и получает метки времени или расписания, основанные на местном времени
- Расчёты или пакетные задачи выполняются на серверах или VM в зарубежном регионе
- Существует закрытие периода по местному времени зарубежной площадки (например, «закрытие в полночь на каждой площадке»)
В часовых поясах с DST день перехода порождает два вида аномального времени. Несуществующее время (весной — промежуток, который пропускается при переводе часов вперёд; в Германии это 02:00–03:00 в конце марта) и неоднозначное время (осенью — промежуток, который встречается дважды из-за перевода часов назад). В .NET их можно определить через TimeZoneInfo.IsInvalidTime / IsAmbiguousTime.6 А API преобразования вроде ConvertTimeToUtc выбрасывают ArgumentException при передаче несуществующего времени и интерпретируют неоднозначное время как стандартное.8
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
// 2026-03-29 — день начала DST в Германии. Местного времени между 02:00 и 03:00 не существует
var t = new DateTime(2026, 3, 29, 2, 30, 0); // Kind = Unspecified
Console.WriteLine(berlin.IsInvalidTime(t)); // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) выбросит ArgumentException
Если в системе есть путь ввода, который «принимает строку с местным временем и преобразует её в UTC перед сохранением», это исключение затаится как ошибка, проявляющаяся лишь один день в году. Реалистичное решение — проверять IsInvalidTime на этапе валидации ввода и явно прописать в спецификации, что неоднозначное время следует «трактовать как стандартное».
5.1 Регулярное выполнение и DST — проблема «срабатывает дважды / не срабатывает»
Регулярные задания — ещё одно классическое место, где можно попасть под удар. Задание, ежедневно запускаемое в 02:30 по местному времени, просто не имеет этого момента в день начала DST и имеет его дважды в день окончания. В зависимости от реализации планировщика поведение различается — «пропускается», «срабатывает дважды», «срабатывает со сдвигом на час», — из-за чего процесс агрегации, написанный в расчёте на «срабатывает ровно раз в день», начинает либо считать данные дважды, либо терять их вовсе. Решение — сочетание трёх мер:
- Привязать расписание к UTC (или к часовому поясу без DST). Для пакетных заданий, для которых запуск в конкретное местное время на самом деле не является требованием, этого одного достаточно, чтобы проблема исчезла.
- Сделать обработку идемпотентной. Добавив маркер «уже выполнено» — пропуск, если агрегация за целевую дату уже существует, — вы избавитесь от вреда двойного срабатывания.
- Привязывать ключ агрегации к бизнес-дате (раздел 3.2). Не вычислять дату обратным ходом от времени запуска.
Проектирование регулярного выполнения через планировщик заданий (предотвращение повторного запуска, изоляция сбоев) подробно рассмотрено в статье «Задачи планировщика заданий не выполняются или завершаются с 0x1», а проектирование таймера внутри резидентной службы — в статье «Как построить и эксплуатировать службу Windows». Каким бы способом вы ни пользовались, связь между DST и расписанием необходимо прописать в спецификации одинаково тщательно.
6. Граница с базой данных — SQL Server / SQLite / ORM
Из всех границ больше всего инцидентов происходит именно на базе данных. Многие типы даты и времени в БД не сохраняют информацию о том, «относительно чего измерено это время», поэтому информация о Kind и смещении теряется в тот же момент, когда значение сохраняется.
6.1 Типы даты и времени в SQL Server
| Тип | Диапазон/точность | Информация о базе отсчёта | Рекомендация для нового использования |
|---|---|---|---|
datetime |
С 1753 года, точность около 1/300 секунды | Отсутствует | Избегать (официально задокументировано как не рекомендуемый для новой разработки)10 |
datetime2 |
С 0001 года, точность до 100 наносекунд | Отсутствует | Основной выбор для столбца, хранимого в UTC10 |
datetimeoffset |
Эквивалент datetime2 + смещение |
Сохраняет смещение | Для столбцов, где требование — воспроизвести местное время10 |
datetime — устаревший тип с грубым округлением и узким диапазоном, и официальная документация прямо указывает, что «новая разработка должна избегать datetime в пользу datetime2 / datetimeoffset».10 Насильно мигрировать существующую схему с datetime не нужно, но и выбирать его для новой таблицы нет причин.
Выбор между хранением UTC в datetime2 и использованием datetimeoffset сводится к вопросу: «нужно ли впоследствии воспроизвести смещение на момент ввода данных?» Если требования аудита или комплаенса предполагают сохранение сведений о том, «который был час по местному времени пользователя», используйте datetimeoffset; если достаточно лишь зафиксировать момент, хватит UTC в datetime2. Учтите, что, как и у DateTimeOffset, у datetimeoffset есть только смещение, а не сама тайм-зона (правила перехода). Если нужна ещё и зона, храните IANA ID в отдельном столбце.
6.2 В SQLite нет типа даты и времени
В SQLite вообще отсутствует тип хранения для даты и времени, и Microsoft.Data.Sqlite сохраняет DateTime / DateTimeOffset как TEXT.11 Формат TEXT в стиле ISO 8601 означает, что сортировка по строке эквивалентна сортировке по времени — «при условии, что формат и часовой пояс единообразны», — но обратная сторона медали в том, что в момент, когда UTC и локальное время смешиваются в одном столбце, и сортировка, и поиск по диапазону молча ломаются. При использовании SQLite остаётся только зафиксировать в качестве соглашения приложения: «этот столбец — UTC, формат такой-то». Практические аспекты работы с SQLite, включая соединения и транзакции, собраны в статье «Использование SQLite в бизнес-приложении на C#».
6.3 На что обратить внимание в EF Core / Dapper — Kind исчезает при чтении
Чтение DateTime из типа, не несущего информации о базе отсчёта (datetime2, TEXT в SQLite и т. п.), закономерно даёт Kind = Unspecified. Именно так возникает инцидент вида: «мы стандартизировали хранение на UTC, но где-то прочитанное значение снова пропускается через ToUniversalTime(), из-за чего происходит двойное преобразование». Решение — восстанавливать Kind на границе. В EF Core это можно объявить один раз, централизованно, через конвертер значений.
// EF Core: объявляем "этот столбец — UTC" один раз на уровне модели
modelBuilder.Entity<Order>()
.Property(o => o.CreatedAtUtc)
.HasConversion(
// Запись: всё, что не является UTC (включая случайно попавшие значения DateTime.Now),
// нормализуется в UTC на границе. Обратите внимание: Unspecified при преобразовании
// трактуется как локальное время
v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
// Чтение: восстанавливаем Kind
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
Нормализацию на стороне записи стоит рассматривать исключительно как последний рубеж защиты. Поскольку преобразование значения Unspecified зависит от настройки часового пояса машины выполнения, код, который напрямую подаёт DateTime.Now или значения Unspecified в путь сохранения, всё равно нужно находить и исправлять. Считайте это двухуровневой обороной: страховка плюс соглашение. При работе с Dapper или чистым ADO.NET соберите вызов DateTime.SpecifyKind сразу после маппинга в единый слой преобразования. Помогает и соглашение об именовании: если просто закладывать базу отсчёта в имя столбца и свойства (CreatedAtUtc, updated_at_utc), заметно повышается вероятность, что на ревью кто-то заметит: «вызов ToUniversalTime для этого значения выглядит подозрительно» — ведь имена читают гораздо чаще, чем документацию.
7. Синхронизация времени и тестирование — w32time и TimeProvider
7.1 Не считайте само собой разумеющимся, что часы машины идут верно
Всё сказанное выше исходило из предположения, что «сами часы машины верны», но именно эти часы синхронизирует служба времени Windows (w32time). w32time синхронизируется с сетевым источником времени по NTP, а в среде Active Directory синхронизация идёт по иерархии домена. Это фундамент для всего, что чувствительно к рассинхронизации времени, включая аутентификацию Kerberos.12
Для проектирования приложения из этого следуют два вывода. Во-первых, не делайте часы клиентского ПК основанием для бизнес-логики. Устройство с остановленной синхронизацией легко расходится на несколько минут, поэтому порядок событий и определение времени закрытия периода следует делать на основе серверного времени, а клиентское время использовать лишь как справочную информацию. Во-вторых, при обращении «время выглядит неверным» проверяйте состояние синхронизации устройства командой w32tm /query /status раньше, чем приступать к разбору самого приложения. Это отсекает целую категорию причин за минуту, ещё до того, как вы начнёте искать баг в приложении.
7.2 Откажитесь от прямого DateTime.Now — TimeProvider
Главное препятствие для тестирования логики даты и времени — DateTime.Now, прямо прописанный по всему коду. Если вы хотите протестировать «определение закрытия месяца», «сброс порядкового номера при переходе через год» или «расписание в день перехода DST», но не можете зафиксировать текущее время, придётся ждать наступления самого этого дня, чтобы проверить логику.
В .NET 8 появилась стандартная абстракция времени — TimeProvider. Она позволяет подменять GetUtcNow() / GetLocalNow() / LocalTimeZone и даже создание таймеров через единую абстракцию. Тот же тип доступен и в .NET Framework начиная с 4.6.2, и в .NET Standard 2.0 через NuGet-пакет Microsoft.Bcl.TimeProvider, так что его можно внедрить и в старые проекты. Реализация для тестов, FakeTimeProvider, поставляется пакетом Microsoft.Extensions.TimeProvider.Testing.7
public sealed class DailyReportService
{
private readonly TimeProvider _clock;
private readonly TimeZoneInfo _siteTimeZone;
public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
{
_clock = clock;
_siteTimeZone = siteTimeZone;
}
// Реализация, явно указывающая, что "бизнес-дата вырезается по часовому поясу площадки" (раздел 3.2)
public string GetReportDateKey()
{
var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
В продакшене передаётся TimeProvider.System, а в тестах — FakeTimeProvider, позволяющий свободно фиксировать и продвигать время.
[Fact]
public void BusinessDateSwitchesCorrectlyAcrossTheYearBoundary()
{
// Фиксируем начало на 23:30 JST в канун Нового года
var clock = new FakeTimeProvider(
new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var svc = new DailyReportService(clock, tokyo);
Assert.Equal("2026-12-31", svc.GetReportDateKey());
clock.Advance(TimeSpan.FromHours(1)); // мгновенно воспроизводим переход через год
Assert.Equal("2027-01-01", svc.GetReportDateKey());
}
FakeTimeProvider умеет не только фиксировать и вручную продвигать время, но и подменять локальный часовой пояс (SetLocalTimeZone), поэтому ошибку вроде «на устройстве с немецкой настройкой дата сдвигается на день назад» можно воспроизвести на японской машине CI. Если проект настолько старый на .NET Framework, что добавить даже NuGet-пакет затруднительно, тот же эффект даёт самописный интерфейс IClock с единственным членом DateTimeOffset UtcNow { get; }. Важна не изощрённость абстракции, а сам факт того, что «текущее время» рассматривается как внедряемая зависимость.
По опыту, минимальный набор моментов времени, которые стоит покрыть тестами, — вот эти пять: рубеж нового года (переход через год), конец месяца (31-е, 30-е число, февраль), 29 февраля високосного года, дни перехода DST для целевого часового пояса (весна и осень), момент около полуночи (граница бизнес-даты). Каждый из них — классическое место обитания ошибок, срабатывающих «только в этот конкретный день», и с FakeTimeProvider все они тестируются за считаные миллисекунды.
8. Чек-лист и таблица решений
Обработка даты и времени, как и UUID, — базовый элемент инфраструктуры, который часто оставляют без внимания под девизом «вроде бы работает» (это очень похоже на структуру, описанную в статье «Правда ли, что UUID не сталкиваются?»). Если зафиксировать пункты, которые проверяются при новом проектировании и на ревью, зависимость от конкретного человека исчезает.
Чек-лист для нового проектирования:
- Унифицировано ли получение момента события на
DateTimeOffset.UtcNow/TimeProvider.GetUtcNow()? - Прописаны ли в спецификации база отсчёта для хранения и передачи (UTC или со смещением) и формат (“o” / ISO 8601)?
- Определены ли тип столбца БД и база отсчёта (для SQL Server —
datetime2(UTC) илиdatetimeoffset; закладывайтеUtcв имя столбца)? - Разделены ли метка времени и бизнес-дата, и определён ли часовой пояс, по которому вырезается дата?
- Определена ли схема управления идентификаторами часовых поясов (рекомендуется IANA ID) и обработка ошибки при некорректном ID?
- Определена ли политика DST для регулярного выполнения (расписание на базе UTC + идемпотентность)?
- Внедряемы ли
TimeProvider/IClock, и существуют ли тестовые случаи для граничных моментов времени?
Код, который стоит искать через grep при ревью:
| Найденный код, к которому стоит отнестись с подозрением | Что может произойти | Как исправить |
|---|---|---|
DateTime.Now |
Сдвигается при переносе сервера. Не тестируется | UtcNow + преобразование только при отображении. Внедрить TimeProvider |
ToLocalTime() / ToUniversalTime() |
Неявная интерпретация Unspecified (глава 2) | Зафиксировать Kind на границе; преобразовывать только непосредственно перед отображением |
DateTime.Parse(s) (без указания styles) |
Зависит от культуры и часового пояса среды выполнения | ParseExact + InvariantCulture + RoundtripKind |
Хранение/передача через ToString("yyyy/MM/dd HH:mm") |
Информация о базе отсчёта теряется | Формат “o” + InvariantCulture |
Прямое использование new DateTime(...) в сравнениях или при сохранении |
Проникновение значений Unspecified | Применить SpecifyKind либо перейти на DateTimeOffset |
Новый столбец datetime в SQL Server |
Вопросы точности, диапазона и перспективности | datetime2 / datetimeoffset10 |
Большую часть этого чек-листа при новой разработке можно решить в первый же день. Исправление же после ввода в эксплуатацию превращается в археологию — приходится оценивать, по какой базе отсчёта сохранены данные за тот или иной период, и мигрировать их, — а стоимость такой работы отличается на порядок.
9. Итог
Инциденты с датой, временем и часовыми поясами разнятся по симптомам — «сдвиг на 9 часов», «дата оказывается предыдущим днём», «пакет сработал дважды», — но причина неизменно одна: значение без информации о базе отсчёта пересекает границу. Решение тоже неизменно. Получение — в UTC; хранение и передача — в UTC или со смещением + ISO 8601 (“o”); локальное время — только для отображения. На границах явно прописывайте формат и базу отсчёта в спецификации, закладывайте базу отсчёта в имя столбца БД. Часовые пояса обрабатывайте через TimeZoneInfo и IANA ID, а несуществующее и неоднозначное время DST перехватывайте валидацией ввода и идемпотентным регулярным выполнением. И внедряйте TimeProvider, чтобы и переход через год, и день перехода DST можно было воспроизвести в тестах. Сделав всё это, вы перестанете быть тем, кого вызывают после изменения окружения, и станете тем, кто указывает на проблему заранее.
Мы занимаемся расследованием причин сдвига времени при переносе серверов и переходе в облако, ревью проектных решений в области обработки даты и времени, а также поддержкой доработок для взаимодействия с зарубежными офисами (поддержка часовых поясов и DST). Если данные, уже сохранённые с разными базами отсчёта, успели перемешаться, мы можем помочь и с инвентаризацией, и с планом миграции.
Похожие статьи
- Задачи планировщика заданий не выполняются или завершаются с 0x1 — изоляция причин и надёжное проектирование эксплуатации
- Использование SQLite в бизнес-приложении на C# — режим WAL, блокировки, защита от повреждений и выбор между этим и EF Core
- Как построить и эксплуатировать службу Windows — от выбора между планировщиком заданий и превращением BackgroundService в службу
- Кодировки символов и переводы строк в Windows — основы мохибакэ и CRLF/LF
Смежные направления консультирования
KomuraSoft LLC (合同会社小村ソフト) занимается ревью проектных решений в области обработки даты и времени и часовых поясов в бизнес-приложениях, расследованием причин сдвига времени и даты при переносе серверов и переходе в облако, а также доработкой обработки даты и времени для взаимодействия с зарубежными офисами и легаси-активами.
- Техническая консультация и ревью проектных решений
- Разработка Windows-приложений
- Использование и миграция существующих активов
- Контакты
Справочные ссылки
-
Microsoft Learn, DateTime.Kind Property. О том, что значением Kind по умолчанию является Unspecified, и о влиянии Kind на результат ToLocalTime / ToUniversalTime (Unspecified трактуется ToLocalTime как UTC, а ToUniversalTime — как локальное время). ↩ ↩2 ↩3
-
Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo. О том, что DateTimeOffset рекомендуется рассматривать как тип даты и времени по умолчанию для разработки приложений, что DateTimeOffset несёт только смещение и не привязан к часовому поясу, а также о том, что DateOnly / TimeOnly недоступны в .NET Framework. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Standard date and time format strings. О том, что раунд-трип формат “o” соответствует ISO 8601 и сохраняет в строке Kind у DateTime и смещение у DateTimeOffset, а также о восстановлении значения через разбор с DateTimeStyles.RoundtripKind. ↩ ↩2
-
Microsoft Learn, What’s new in .NET 6. О том, что в .NET 6 TimeZoneInfo.FindSystemTimeZoneById принимает идентификаторы часовых поясов и IANA, и Windows, с автоматическим преобразованием, а также о добавлении TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId. ↩ ↩2
-
Microsoft Learn, .NET globalization and ICU. О том, что разрешение идентификаторов часовых поясов IANA в Windows и API взаимного преобразования зависят от ICU, о недоступности этого в режиме NLS и режиме неизменной глобализации, а также об использовании локальной поставки ICU на ОС без встроенной ICU. ↩ ↩2
-
Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method. Об определении и способе выявления «несуществующего времени», возникающего при переходе на летнее время, а также о парном методе IsAmbiguousTime (определение неоднозначного времени). ↩ ↩2
-
Microsoft Learn, What is TimeProvider?. О том, что TimeProvider встроен в .NET 8 и новее, доступен в .NET Framework 4.6.2+ / .NET Standard 2.0 через пакет Microsoft.Bcl.TimeProvider, об абстракции GetUtcNow / GetLocalNow / LocalTimeZone / создания таймеров, а также о FakeTimeProvider для тестов из пакета Microsoft.Extensions.TimeProvider.Testing. ↩ ↩2
-
Microsoft Learn, TimeZoneInfo.ConvertTime Method. О требовании согласованности DateTime.Kind с исходным часовым поясом (несоответствие приводит к ArgumentException), об интерпретации неоднозначного времени как стандартного, а также о выбросе ArgumentException при передаче несуществующего времени. ↩ ↩2
-
Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019. О том, что начиная с .NET 7 библиотеки ICU используются даже на Windows Server 2019 и подобных ОС без встроенной ICU, тогда как ранее требовалось вручную поставлять ICU локально с приложением. ↩
-
Microsoft Learn, datetime (Transact-SQL). О рекомендации избегать datetime в новой разработке в пользу time / date / datetime2 / datetimeoffset, а также о более высокой точности datetime2 / datetimeoffset и поддержке смещения часового пояса у datetimeoffset. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Data types (Microsoft.Data.Sqlite). О том, что в SQLite всего четыре примитивных типа хранения, и о том, что Microsoft.Data.Sqlite сохраняет DateTime / DateTimeOffset как TEXT. ↩
-
Microsoft Learn, Windows Time Service (W32Time). О синхронизации часов компьютеров службой времени Windows по сети через NTP, об иерархии синхронизации внутри домена Active Directory, а также о зависимости аутентификации Kerberos от синхронизации времени. ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Высокий DPI в WPF — почему всё «должно быть само в порядке», а на деле размывается и плывёт, и что с этим делать
WPF по умолчанию System DPI Aware, но при переносе окна на монитор с другим DPI всё изображение размывается, а растровые картинки становя...
Поддержка высокого DPI в WinForms — почему интерфейс размывается или разваливается на 4K-мониторах, и как это исправить
Разбираем причины размытия и поломки интерфейса WinForms-приложений на 4K-мониторах через DPI-виртуализацию и режимы DPI-осведомлённости ...
SQLite в бизнес-приложениях на C# — режим WAL, эксклюзивная блокировка, защита от повреждений и выбор между EF Core и голым ADO.NET
Разбираем практические знания по внедрению SQLite в бизнес-приложение через Microsoft.Data.Sqlite: режим WAL, SQLITE_BUSY и консолидацию ...
Как создавать и эксплуатировать службы Windows — от выбора между планировщиком заданий и службами до превращения BackgroundService в службу Windows
Разбираем, стоит ли превращать резидентную обработку в службу Windows или достаточно планировщика заданий: таблица решений, создание служ...
Японская эра, праздники и даты закрытия периода в бизнес-приложениях — устойчивый к смене эры дизайн, JapaneseCalendar и расчёт рабочих дней на практике
Показать «Рэйва 8» в отчёте, рассчитать рабочие дни без учёта праздников, оплатить в последний рабочий день месяца, следующего за закрыти...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Почему после переноса сервера время сдвигается на 9 часов?
- Корень проблемы в том, что значение, не несущее информации о том, «относительно чего измерено это время», пересекает границу — базу данных, API, файл. В .NET значение DateTime по умолчанию получает Kind = Unspecified, а методы ToLocalTime() и ToUniversalTime() молча делают предположение о базовом времени: ToLocalTime() считает Unspecified значением в UTC и прибавляет 9 часов, а ToUniversalTime() считает его локальным и вычитает 9 часов. Поскольку это поведение зависит от настройки часового пояса машины, на которой выполняется код, на машине разработки с японским временем проблема незаметна и проявляется разом в день переноса на облачную VM с настройкой UTC.
- Что использовать в новом коде — DateTime или DateTimeOffset?
- По умолчанию для нового кода стоит использовать DateTimeOffset. Он всегда хранит смещение от UTC, поэтому одно лишь значение однозначно определяет момент времени в любой точке мира, и ошибки, связанные с неявной интерпретацией Kind, структурно исключены. Официальное руководство прямо рекомендует рассматривать DateTimeOffset как тип даты и времени по умолчанию для разработки приложений. Однако смещение — это не сама тайм-зона: если нужны правила перехода на летнее время, DateTimeOffset нужно сочетать с TimeZoneInfo. Если в проекте уже много кода на DateTime, реалистичным компромиссом будет унифицировать внутреннее представление и хранение на DateTime с Kind=Utc, оставив DateTimeOffset только на границах системы.
- Нужна ли поддержка перехода на летнее время (DST) даже в приложении, предназначенном только для Японии?
- Нужна, если выполняется хотя бы одно из условий: приложение работает на устройствах в зарубежных офисах или у сотрудников в командировках за рубежом, оно взаимодействует с зарубежными SaaS-сервисами или веб-API, либо пакетные задачи выполняются на серверах в зарубежном регионе. В часовых поясах с DST в день перехода возникают два вида аномальных значений времени: «несуществующее время» и «неоднозначное время», а API преобразования TimeZoneInfo выбрасывает ArgumentException при передаче несуществующего времени. Регулярное задание, запускаемое в 02:30 по местному времени, может быть пропущено в день начала DST и выполниться дважды в день окончания, поэтому решением служит расписание, привязанное к UTC, в сочетании с идемпотентной обработкой.
- Как правильно писать тесты для логики работы с датой и временем?
- Откажитесь от прямого использования DateTime.Now в коде и внедряйте стандартный для .NET 8 TimeProvider, чтобы текущее время можно было подменять. Начиная с .NET Framework 4.6.2 тот же тип доступен через пакет Microsoft.Bcl.TimeProvider. В тестах FakeTimeProvider позволяет зафиксировать время, перемотать его вперёд и даже подменить локальный часовой пояс, что даёт возможность воспроизводить на CI ошибки, связанные с переходом через новый год или переключением DST. Минимальный набор моментов времени, которые стоит покрыть тестами: рубеж нового года, конец месяца, 29 февраля високосного года, дни перехода на летнее/зимнее время и момент около полуночи.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки