Дата, время и часовые пояса в бизнес-приложениях — ловушки DateTime, хранение в UTC и тесты
· Обновлено: · Го Комура · C#, .NET, .NET Framework, Windows, Часовые пояса, Обработка даты и времени, Тестирование, Эксплуатация, Техническая консультация
История изменений (2 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Переведено имя метода в примере теста даты и времени. Утверждения статьи не менялись.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619949)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Дата, время и часовые пояса в бизнес-приложениях — ловушки DateTime, хранение в UTC и тесты. KomuraSoft LLC. https://comcomponent.com/ru/blog/business-app-datetime-timezone-guide/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.21619949
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.21619950
«Перенесли сервер на облачную VM — и время во всех отчётах сдвинулось на 9 часов». «На устройствах зарубежного офиса дата в ежедневном отчёте оказывается предыдущим днём». «Похоже, ночной расчёт в какой-то день отработал дважды». Обращения про дату, время и часовые пояса приходят именно так: «сломалось в тот день, когда изменилось окружение». В коде при этом не поменяли ни строки.
Часто думают: «приложение только для Японии, часовые пояса ни при чём». На практике большая часть таких обращений как раз про эти «чисто внутренние» системы. Облачные VM и контейнеры часто выдают с UTC, а зарубежные библиотеки и веб-API SaaS возвращают метки времени в UTC или со смещением. Само приложение может жить «только в японском времени», но по ту сторону границы давно UTC. Если значения ходят без явного указания, относительно какого пояса задано время, несоответствие незаметно до дня переноса сервера или ухода в облако — и тогда всплывает сразу, сдвигом на 9 часов.
Ниже — разбор для бизнес-приложений на .NET: Kind у DateTime и ловушки неявных преобразований, когда брать DateTimeOffset, принцип «хранение и передача — в UTC или со смещением, местное время — только на экране», TimeZoneInfo и летнее время, граница с БД и тесты через TimeProvider. В конце — чек-лист пунктов, которые мы каждый раз смотрим на ревью проектных решений.
Для кого статья и как её читать
Читатель — разработчик, который пишет и сопровождает бизнес-приложения на .NET (C#). Как раз тот, кто считает, что «у нас только внутренний рынок, пояса не нужны». Примеры — на C# / .NET 8; где то же самое доступно в .NET Framework, это отдельно помечено.
Статья длинная, поэтому сначала — маршруты по цели.
| Цель | Что читать |
|---|---|
| Внутреннее небольшое приложение, нужен рабочий минимум | глава 1 (вывод) → глава 2 (ловушка Kind) → глава 3 (хранение в UTC) → глава 6 (граница с БД). Этих четырёх достаточно, чтобы закрыть большую часть сбоев |
| Нужно локализовать уже наблюдаемый сдвиг | глава 2, особенно воспроизведение в разделе 2.1 → в главе 8 блок «код, который стоит искать через grep» |
| Есть зарубежные офисы или интеграция с зарубежным SaaS | плюс глава 4 (идентификаторы поясов) и глава 5 (летнее время) |
| Нужно воспроизвести в тестах ошибки на границе года и конца месяца | раздел 7.2 (TimeProvider) |
| Нужны только критерии ревью нового проекта | чек-лист в главе 8 |
Термины, которые дальше идут сокращениями
Сведём заранее то, что в тексте появляется аббревиатурами.
| Термин | Смысл |
|---|---|
| UTC (Coordinated Universal Time) | Всемирное координированное время. Общая шкала; японское время (JST) — это UTC+9 |
| ISO 8601 | Международный стандарт строковой записи даты и времени. Пример: 2026-07-03T13:30:00+09:00 |
| IANA tz database | База определений часовых поясов и истории их изменений. Ведёт IANA (Internet Assigned Numbers Authority), идентификаторы вида Asia/Tokyo (раздел 4.1) |
| ICU (International Components for Unicode) | Стандартная библиотека интернационализации. С .NET 5 разрешение культур и часовых поясов опирается на неё (раздел 4.1)1 |
| NLS (National Language Support) | API интернационализации Windows, который был до ICU. .NET можно запустить в режиме NLS, но тогда идентификаторы IANA не разрешаются1 |
| DST (Daylight Saving Time) | Летнее время. В Японии его сейчас нет, но оно встречается при работе с зарубежными офисами и зарубежным SaaS (глава 5) |
| Kind | Признак DateTime: относительно чего задано время. Три значения: Utc / Local / Unspecified (глава 2) |
1. Сначала вывод
Коротко: получать, хранить и передавать время в UTC (или со смещением); в местное переводить ровно один раз — непосредственно перед выводом на экран. Ниже — что это значит по пунктам и что происходит, если принцип нарушить.
- Корень почти всех сбоев с датой и временем один: через границу (БД, API, файл) проходит значение без сведений, относительно какого пояса задано время. Симптом проявляется не в день правки кода, а в день смены окружения.
- У
DateTimeесть признак Kind (Utc / Local / Unspecified), по умолчанию — Unspecified.ToLocalTimeсчитает Unspecified временем UTC, аToUniversalTimeсчитает его местным. Эта асимметрия — типичный источник «сдвига на 9 часов».2 - Для нового кода по умолчанию берите
DateTimeOffset. Официальное руководство прямо говорит: рассматривайте его как тип даты и времени по умолчанию для приложений.3 - Принцип: «хранение и передача — в UTC или со смещением, местное время — только для отображения». В строку пишите ISO 8601 в формате циклического преобразования
"o", обратно читайте сDateTimeStyles.RoundtripKind.4 - Преобразования поясов делайте через
TimeZoneInfo. С .NET 6 работают и идентификатор IANA (Asia/Tokyo), и идентификатор Windows (Tokyo Standard Time); есть и API взаимного преобразования.5 На Windows разрешение IANA зависит от ICU и не работает на старых сборках Windows Server и в режиме инвариантной глобализации (глава 4).1 - Летнего времени в Японии нет, но как только в цепочке появляются устройство зарубежного офиса, зарубежный SaaS или сервер с UTC, вы попадаете на «несуществующее» и «неоднозначное» время DST.6
- Уберите прямые вызовы
DateTime.Now. ВнедряйтеTimeProvider(в .NET 8 он в составе платформы; на старых целевых — пакет Microsoft.Bcl.TimeProvider), чтобы тесты свободно управляли временем.7
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. Kind у DateTime — три значения и сбои из-за неявного преобразования
Кроме самого значения даты и времени (Ticks) у DateTime есть ровно один дополнительный признак — Kind. Значений три, по умолчанию Unspecified.2
| Kind | Смысл | Откуда обычно берётся |
|---|---|---|
| Utc | Время относительно UTC | DateTime.UtcNow, результат ToUniversalTime() |
| Local | Время относительно местного пояса машины, на которой выполняется код | DateTime.Now, результат ToLocalTime() |
| Unspecified | Система отсчёта неизвестна (значение по умолчанию) | new DateTime(...), DateTime.Parse (в большинстве случаев), чтение из БД |
Главное: обычный код почти сплошь даёт Unspecified. И конструктор, и разбор строки, и чтение из БД по умолчанию не знают, относительно какого пояса задано время. Само по себе это не ошибка. Ошибка в том, что методы преобразования молча подставляют систему отсчёта для таких Unspecified-значений.2
| Вызов | 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 Воспроизвести «сдвиг на 9 часов» у себя — эксперимент на пять минут
Самый быстрый способ перестать говорить «на машине разработки не воспроизводится» — сменить часовой пояс на этой машине и прогнать тот же код. Укладывается в пять минут.
Сначала консольное приложение:
// .NET 8 / C#. Unspecified-значение, имитирующее «9:00 по японскому времени» из БД
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);
Console.WriteLine($"Часовой пояс ОС : {TimeZoneInfo.Local.Id}");
Console.WriteLine($"Kind : {fromDb.Kind}");
Console.WriteLine($"ToLocalTime() : {fromDb.ToLocalTime():HH:mm}");
Console.WriteLine($"ToUniversalTime() : {fromDb.ToUniversalTime():HH:mm}");
Дальше так. Пояс переключайте штатной tzutil (если команда отказывается менять настройку, запустите командную строку от имени администратора).
tzutil /g— запомните текущий идентификатор, чтобы потом вернуть. На японской машине разработки будетTokyo Standard Time.- Запустите приложение и запишите результат.
tzutil /s "UTC"— переключите пояс на UTC, перезапустите приложение и снова посмотрите результат.TimeZoneInfo.Localкэшируется внутри процесса, поэтому у уже запущенного процесса пояс не сменится.tzutil /s "Tokyo Standard Time"— верните как было.
Тот же код и те же входные данные дают разный результат.
| Часовой пояс машины | ToLocalTime() |
ToUniversalTime() |
|---|---|---|
Tokyo Standard Time (UTC+9) |
18:00 (считается UTC, +9 часов) | 00:00 (считается местным, −9 часов) |
UTC |
09:00 (без изменений) | 09:00 (без изменений) |
В этом и есть «на машине разработки не воспроизводится». Код, который преобразует Unspecified, меняет результат из-за внешней настройки машины: на JST сдвиг наглядные 9 часов, на облачной VM с UTC сдвига нет. Наоборот, данные, которые годами хранили в местном времени, после переноса на UTC-сервер перестают «компенсировать» друг друга — и сдвиг выходит наружу.
Заменить этот эксперимент на FakeTimeProvider нельзя. FakeTimeProvider.SetLocalTimeZone подменяет только свойство LocalTimeZone этого TimeProvider, а не TimeZoneInfo.Local всего процесса.7 ToLocalTime() / ToUniversalTime() в коде выше смотрят на второе, поэтому подмена в тесте просто повторит настройку машины CI.
Отсюда же причина убрать ToLocalTime() из прикладного кода. Метод, который читает настройку всего процесса, из теста не подменить. Если преобразования поясов должны входить в регрессию, принимайте целевой пояс аргументом.
// .NET 8 / C#. Целевой пояс передаётся снаружи. Если внедрён TimeProvider,
// достаточно передать provider.LocalTimeZone
static DateTime JstWallClockToUtc(DateTime wallClock, TimeZoneInfo zone)
=> TimeZoneInfo.ConvertTimeToUtc(
DateTime.SpecifyKind(wallClock, DateTimeKind.Unspecified), zone);
// Тест: результат не зависит от часового пояса машины
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
Assert.Equal(
new DateTime(2026, 7, 3, 0, 0, 0, DateTimeKind.Utc), // 9:00 JST = 0:00 UTC
JstWallClockToUtc(new DateTime(2026, 7, 3, 9, 0, 0), tokyo));
FakeTimeProvider из раздела 7.2 работает только если целевой код идёт через provider.GetLocalNow() или provider.LocalTimeZone. До прямых вызовов DateTime.Now и ToLocalTime() он не достаёт. Если нужно увидеть, как настройка самой машины меняет результат, надёжнее процедура с tzutil выше.
2.2 Чем DateTimeOffset отличается и когда что брать
DateTimeOffset всегда несёт смещение от UTC (например +09:00) вместе с датой и временем, поэтому одно значение однозначно задаёт момент на шкале UTC. Для «фиксации момента» — записи в журнал, времени сделки, системного события — официальное руководство прямо рекомендует рассматривать DateTimeOffset как тип по умолчанию.3 Неявной трактовке Kind здесь просто некуда деться, поэтому большая часть сбоев из этой статьи структурно невозможна.
Это не панацея. У DateTimeOffset есть смещение, а не часовой пояс. По +09:00 не отличить Японию от Кореи, и правил перехода на летнее время у значения нет.3 Если нужно воспроизвести, который час показывали бы часы в конкретном месте, всё равно нужен TimeZoneInfo из главы 4. Сведём выбор в таблицу.
| Тип | Что хранит | Где уместен | Замечание |
|---|---|---|---|
DateTimeOffset |
Дата и время + смещение от UTC | Фиксация момента, журналы, границы API | По умолчанию для нового кода3 |
DateTime (работа с Kind=Utc) |
Только дата и время | Внутренние расчёты, совместимость с уже существующим кодом | Kind ведёте сами |
DateOnly / TimeOnly |
Только дата / только время | Бизнес-даты, часы работы, время закрытия | В .NET Framework нет3 |
TimeSpan |
Интервал | Длительность, разница двух моментов | |
TimeZoneInfo |
Определение пояса, включая правила перехода | Преобразование, проверка летнего времени | Глава 4 |
Переписать все существующие DateTime на DateTimeOffset часто нереально. Компромисс, который мы обычно берём в доработках: «внутри и при хранении — DateTime с Kind=Utc, на границах (API, сериализация) — DateTimeOffset или строка в формате "o"». В любом случае фундамент — принцип следующей главы.
3. Принцип — хранение и передача в UTC или со смещением, местное время только на экране
Проектирование даты и времени укладывается в три строки.
- Момент события берите через
DateTime.UtcNowилиDateTimeOffset.UtcNowи дальше несите его в UTC (или со смещением). - Когда значение пересекает границу (БД, API, файл, реестр), формат и систему отсчёта явно фиксируйте в спецификации.
- В местное время переводите ровно один раз — непосредственно перед выводом на экран или в отчёт.
На схеме преобразование разрешено только в одном месте.
flowchart TD
IN["Фиксация и получение<br/>DateTimeOffset.UtcNow<br/>TimeProvider.GetUtcNow()"]
APP["Внутри приложения<br/>везде UTC<br/>сравнения и сортировка тоже в UTC"]
DB["БД<br/>datetime2, хранение в UTC<br/>имя столбца CreatedAtUtc"]
WIRE["API, файлы, журналы<br/>формат циклического преобразования o по ISO 8601<br/>2026-07-03T04:30:00.0000000Z"]
VIEW["Одно преобразование перед показом<br/>TimeZoneInfo.ConvertTimeFromUtc<br/>целевой пояс — из настроек пользователя или площадки"]
USER["Экран и отчёты<br/>местное гражданское время"]
NG["Хранение и передача местного времени<br/>смысл значения зависит от настроек ОС<br/>после переноса сдвиг на 9 часов"]
IN --> APP
APP --> DB
APP --> WIRE
DB --> APP
WIRE --> APP
APP --> VIEW --> USER
APP -.->|"так делать нельзя"| NG
Хранение местного времени ставит смысл значения в зависимость от внешнего состояния — настройки ОС сервера. Пока приложение крутится на локальном сервере с японскими настройками, проблемы не видно. Перенос на облачную VM, DR в зарубежном регионе, расхождение dev и production — любого из этих факторов достаточно, чтобы смысл изменился. При хранении в UTC значение значит одно и то же в любом окружении. Перевод для показа идёт по настройке пользователя (или по поясу площадки в справочнике пользователей), поэтому одни и те же данные в Токио и в Берлине показывают верное местное время.
3.1 Строка на границе — ISO 8601 / формат "o"
Когда граница пересекается строкой (JSON, CSV, журнал, файл настроек), используйте формат циклического преобразования "o" по ISO 8601. "o" сохраняет в строке Kind у DateTime и смещение у DateTimeOffset; разбор с DateTimeStyles.RoundtripKind возвращает исходное значение.4
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 — основы искажения текста и CRLF/LF». Неявные договорённости на границе ломаются одинаково, какой бы тип данных ни был.
3.2 Различайте метку времени и бизнес-дату
Ещё одно различие нужно, чтобы разобрать случай из введения: «только у зарубежного офиса дата ежедневного отчёта — предыдущий день». Метка времени (единственный момент на шкале UTC) и бизнес-дата (ярлык вроде «отчёт за 3 июля») — разные вещи. Если бизнес-дату хранить как «DateTime в полночь» и пропустить через перевод в UTC, полночь 3 июля по UTC+9 станет 15:00 2 июля по UTC — и в момент, когда оставляют только дату, она уезжает на день назад. Это типичный механизм.
Бизнес-дату храните как DateOnly (в .NET Framework — строка yyyy-MM-dd или тройка год/месяц/день) и в спецификации решите, по какому поясу отсекается дата. Если написано «дата ежедневного отчёта — по местному времени площадки» или «закрытие периода — по JST головного офиса», реализация сводится к тому, чтобы перевести UtcNow в нужный пояс и отсечь дату. Если этого в спецификации нет, спецификацией по факту стала настройка машины конкретного разработчика.
В схеме таблиц это разные типы столбцов. Для SQL Server так (подробности типов — в разделе 6.1).
| Назначение | Пример столбца (SQL Server) | Тип на стороне .NET | Зачем так |
|---|---|---|---|
| Метка времени (когда произошло) | created_at_utc datetime2(3) NOT NULL |
DateTime (Kind=Utc) |
Хранить в UTC и заложить систему отсчёта в имя столбца (раздел 6.3) |
| Метка времени (нужно воспроизвести местное) | signed_at datetimeoffset(3) NOT NULL |
DateTimeOffset |
Сохранить смещение на момент ввода |
| Бизнес-дата (ярлык отчёта или закрытия) | report_date date NOT NULL |
DateOnly |
Без времени и без пояса |
| Пояс, по которому отсекают дату | site_time_zone_id varchar(64) NOT NULL |
string (разрешается в TimeZoneInfo) |
В справочнике площадок хранить идентификатор IANA (раздел 4.1) |
| Время закрытия, часы работы | closing_time time(0) NOT NULL |
TimeOnly |
У «каждый день 17:30» не должно быть даты |
Суть: в столбце бизнес-даты не используйте тип со временем. Стоит сделать report_date типом datetime2 и положить туда 00:00 — и путь к сдвигу на день из начала раздела снова открыт. У типа date переводить в UTC просто нечего.
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, и постоянно вставала таблица соответствий: «веб-API прислал Asia/Tokyo, а в FindSystemTimeZoneById это не передать». С .NET 6 TimeZoneInfo.FindSystemTimeZoneById принимает оба вида идентификаторов: если переданного ID в системе нет, он автоматически преобразуется и разрешается. Для явного преобразования добавлены TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId.5
// .NET 6+: идентификатор IANA проходит как есть (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. Их одинаково понимают зарубежный SaaS, Linux-контейнеры и другие языки, а .NET с версии 6 как правило принимает их как есть. Если часть системы всё ещё на .NET Framework, переводите в Windows ID только на этой границе. FindSystemTimeZoneById при неизвестном ID выбрасывает TimeZoneNotFoundException — отсекайте это рано: на экране регистрации идентификатора площадки или при проверке во время запуска.
Есть важное условие. На Windows разрешение идентификатора IANA зависит от библиотеки ICU. Приложения в режиме NLS или в режиме инвариантной глобализации (InvariantGlobalization=true) IANA ID не разрешают, и TryConvertIanaIdToWindowsId тоже завершается неудачей.1 То же ограничение при запуске .NET 6 на старой ОС без ICU в поставке (Windows Server 2019, Windows 10 1809 и старше и т. п.), если только вы не кладёте ICU рядом с приложением (с .NET 7 это изменено: ICU используется и на этих ОС9). Иначе говоря, ситуация «на машине разработки (Windows 11) Asia/Tokyo проходит, а на Server 2019 у заказчика — TimeZoneNotFoundException» вполне реальна. Если берёте IANA ID в справочник, делайте три вещи комплектом: (1) проверьте, что разрешение IANA работает на той ОС и версии .NET, где код будет выполняться; (2) не включайте InvariantGlobalization в контейнере только ради меньшего образа; (3) на проверке при запуске оставьте запасной путь: TryConvertIanaIdToWindowsId и повторная попытка уже с идентификатором Windows.
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 — от выбора между Планировщиком заданий и службой до превращения BackgroundService в службу». Какой бы способ вы ни выбрали, связь DST и расписания нужно одинаково прописать в спецификации.
6. Граница с базой данных — SQL Server / SQLite / ORM
Из всех границ больше всего сбоев даёт БД. У многих типов даты и времени в БД нет сведений, относительно какого пояса задано время, поэтому информация о Kind и смещении пропадает в момент сохранения.
6.1 Типы даты и времени в SQL Server
| Тип | Диапазон и точность | Система отсчёта | Ориентир для нового кода |
|---|---|---|---|
datetime |
С 1753 года, точность около 1/300 секунды | Нет | Избегать (официально не рекомендуется для новой работы)10 |
datetime2 |
С 0001 года, точность до 100 наносекунд | Нет | Основной выбор для столбца в UTC10 |
datetimeoffset |
Как datetime2 плюс смещение |
Хранит смещение | Для столбцов, где нужно воспроизвести местное время10 |
datetime — старый тип с грубым округлением и узким диапазоном. Документация прямо говорит: в новой работе его избегать и брать datetime2 / datetimeoffset.10 Насильно мигрировать существующую схему с datetime не нужно, но выбирать его для новой таблицы незачем.
Выбор между UTC в datetime2 и datetimeoffset сводится к вопросу: нужно ли потом воспроизвести смещение на момент ввода? Если аудит или комплаенс требуют помнить, «который был час по местному времени пользователя», берите datetimeoffset; если достаточно зафиксировать момент — UTC в datetime2. Как и у DateTimeOffset, у datetimeoffset есть только смещение, а не сам пояс (правила перехода). Если нужен ещё и пояс, храните идентификатор IANA в отдельном столбце.
6.2 В SQLite нет типа даты и времени
В SQLite нет отдельного типа хранения для даты и времени, и Microsoft.Data.Sqlite сохраняет DateTime / DateTimeOffset как TEXT.11 TEXT в стиле ISO 8601 даёт «сортировка строк = сортировка по времени» — если формат и пояс единообразны. Стоит смешать UTC и местное время в одном столбце — и сортировка, и поиск по диапазону молча ломаются. Для SQLite остаётся зафиксировать в соглашении приложения: «этот столбец — UTC, формат такой-то». Практика SQLite, включая соединения и транзакции, собрана в «SQLite в бизнес-приложениях на C# — режим WAL, блокировки записи, защита от повреждений и выбор EF Core».
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 БизнесДатаКорректноПереключаетсяПриПереходеГода()
{
// Фиксируем начало на 23:30 JST 31 декабря
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. Работает это только если целевой код идёт через provider.GetLocalNow() или provider.LocalTimeZone. Подменяется пояс, который отдаёт этот провайдер, а не TimeZoneInfo.Local всего процесса,7 поэтому до прямых DateTime.Now и ToLocalTime() подмена не достаёт (раздел 2.1). Код, который не идёт через внедрённые часы, из теста не подменить — очевидный вывод. Если проект на старом .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; несуществующее и неоднозначное время DST ловите валидацией ввода и идемпотентными регулярными заданиями. Внедряйте TimeProvider, чтобы и границу года, и день перехода DST можно было воспроизвести в тестах. Сделав это, вы перестанете быть теми, кого вызывают после смены окружения, и станете теми, кто указывает на проблему заранее.
KomuraSoft LLC занимается разбором причин сдвига времени при переносе серверов и уходе в облако, ревью проектных решений по дате и времени, доработкой под зарубежные офисы (пояса и DST). Если в уже сохранённых данных системы отсчёта перемешались, можем помочь и с инвентаризацией, и с планом миграции.
Похожие статьи
- Планировщик заданий не запускается или завершается с 0x1 — диагностика и надёжная эксплуатация
- SQLite в бизнес-приложениях на C# — режим WAL, блокировки записи, защита от повреждений и выбор EF Core
- Как создать и эксплуатировать службу Windows — от выбора между Планировщиком заданий и службой до превращения BackgroundService в службу
- Кодировки текста и переводы строк в Windows — основы искажения текста и CRLF/LF
Смежные направления консультирования
KomuraSoft LLC (合同会社小村ソフト) занимается ревью проектных решений по дате, времени и часовым поясам в бизнес-приложениях, разбором причин сдвига времени и даты при переносе серверов и уходе в облако, а также доработкой обработки даты и времени для зарубежных офисов и существующих активов.
- Техническая консультация и ревью проектных решений
- Разработка Windows-приложений
- Использование и миграция существующих активов
- Контакты
Справочные ссылки
-
Microsoft Learn, .NET globalization and ICU. О том, что разрешение идентификаторов IANA на Windows и API взаимного преобразования зависят от ICU, что в режиме NLS и в режиме инвариантной глобализации они недоступны, и что на ОС без встроенной ICU можно положить ICU рядом с приложением. ↩ ↩2 ↩3 ↩4
-
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, 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. TimeProvider.LocalTimeZone — свойство, которое возвращает локальный часовой пояс в модели времени этого
TimeProvider; реализация по умолчанию отдаётTimeZoneInfo.Local. То естьFakeTimeProviderподменяет только преобразования через этот провайдер, аTimeZoneInfo.Localвсего процесса (то, на что смотрятDateTime.ToLocalTime()и подобные методы) не меняется. ↩ ↩2 ↩3 ↩4 -
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 — почему «к DPI должен быть готов», а на деле всё размывается
WPF по умолчанию System DPI Aware, но при переносе окна на монитор с другим DPI всё изображение размывается, а растр теряет резкость. Раз...
Поддержка высокого DPI в WinForms — почему на 4K размывается или ломается макет
Разбираем, почему WinForms-приложение на 4K-мониторе размывается или ломается: виртуализация DPI и режимы осведомлённости о DPI (System A...
SQLite в бизнес-приложениях на C# — режим WAL, блокировки записи, защита от повреждений и выбор EF Core
Практические сведения о встраивании SQLite в бизнес-приложение через Microsoft.Data.Sqlite: режим WAL, SQLITE_BUSY и сведение записи к од...
Как создать и эксплуатировать службу Windows — от выбора между Планировщиком заданий и службой до превращения BackgroundService в службу
Стоит ли держать постоянно работающую обработку как службу Windows или хватит Планировщика заданий. Практический разбор: таблица выбора, ...
Окончание драйверов принтера Windows ── как готовить печать форм и этикеток в бизнес-приложениях
Microsoft поэтапно прекращает сопровождение драйверов принтера v3/v4; с июля 2026 IPP class driver предпочтут. Что исчезает в Windows pro...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Почему после переноса сервера время сдвигается на 9 часов?
- Корень проблемы в том, что через границу — БД, API, файл — проходит значение без сведений, относительно какого пояса задано время. У DateTime в .NET по умолчанию Kind = Unspecified. ToLocalTime() молча считает такое значение временем UTC и прибавляет 9 часов, а ToUniversalTime() считает его местным и вычитает 9 часов. Поведение зависит от часового пояса машины, поэтому на машине разработки с японским временем сбоя не видно, а в день переноса на облачную VM с UTC он проявляется сразу.
- Что брать в новом коде — DateTime или DateTimeOffset?
- Для нового кода по умолчанию берите DateTimeOffset. У него всегда есть смещение от UTC, поэтому одно значение однозначно задаёт момент времени, и ошибки из-за неявной трактовки Kind структурно не возникают. Официальное руководство прямо рекомендует рассматривать DateTimeOffset как тип даты и времени по умолчанию для приложений. Смещение — это ещё не часовой пояс: если нужны правила перехода на летнее время, сочетайте тип с TimeZoneInfo. Если DateTime в проекте уже много, реалистичный компромисс — внутри и при хранении унифицировать DateTime с Kind=Utc, а DateTimeOffset оставить только на границах.
- Нужна ли поддержка летнего времени (DST), если приложение работает только в Японии?
- Нужна, если выполняется хотя бы одно условие: приложение работает на устройствах в зарубежных офисах или у сотрудников в командировке, оно обменивается данными с зарубежным SaaS или веб-API, либо пакетные задания идут на серверах в зарубежном регионе. В поясах с DST в день перехода появляются «несуществующее время» и «неоднозначное время»; API преобразования TimeZoneInfo при несуществующем времени выбрасывает ArgumentException. Задание на 02:30 по местному времени в день начала DST могут пропустить, а в день окончания — запустить дважды. Отсюда расписание от UTC и идемпотентная обработка.
- Как писать тесты для логики даты и времени?
- Уберите прямые вызовы DateTime.Now и внедряйте TimeProvider — в .NET 8 он входит в состав платформы, поэтому текущее время можно подменять. С .NET Framework 4.6.2 тот же тип даёт пакет Microsoft.Bcl.TimeProvider. В тестах FakeTimeProvider фиксирует время, продвигает его вперёд и позволяет подменить локальный часовой пояс, так что ошибки на границе года и в дни DST воспроизводятся на CI. Минимум, который стоит покрыть: новый год, конец месяца, 29 февраля високосного года, дни перехода DST и полночь.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.