定時工作與時區:午夜排程如何造成日期錯位
Scheduled Jobs and Time Zones: The Midnight Bug I Keep Seeing
Cron 預設以 UTC 執行,若按伺服器時間計算業務日期,午夜排程可能把資料歸入錯誤的本地日期或月份。文章建議以 IANA 時區名稱定義業務日,在該時區計算報表窗口,再轉成 UTC 查詢;Cron 觸發仍使用 UTC。四類固定時鐘測試涵蓋午夜、月界、春季跳時和秋季回撥,以核對日期窗口邊界。
Cron runs in UTC by default, so a "midnight" job can fire on the wrong local day
Define the business day explicitly with a timezone string, never a UTC offset
DST shifts your offset twice a year, which silently moves job times by an hour
Test the four edge cases: midnight, month boundary, spring-forward, fall-back
A report that was supposed to cover Monday landed with Sunday's data. The job ran at midnight, the clock just disagreed about which midnight. I've now seen this exact bug in four different codebases, and it's always the same root cause.
Cron thinks in UTC. Your users think in their own kitchen clock. Those two things drift apart in ways that only show up at the edges.
Why Midnight Is the Worst Possible Time to Run a Job
Here's the setup that bites everyone. You want a daily summary of yesterday's orders. You schedule it for "midnight." On a server set to UTC, that's 0 0 * * *. It looks fine in review and ships.
Then someone in Berlin opens the report.
In summer, Berlin is UTC+2. Your midnight UTC job fires at 02:00 Berlin time. So far harmless. But the logic inside the job usually does something like "give me everything from yesterday," and "yesterday" gets computed from the server clock. If the server is UTC and the business lives in Berlin, the window is off by two hours on both ends. Orders placed between 22:00 and 00:00 Berlin time on Monday get filed under Tuesday.
Now flip it. A customer in Los Angeles (UTC-8 in winter) places an order at 23:30 their time on the last day of the month. On the UTC server, that's already 07:30 the next day, next month. Your monthly rollup drops it into the wrong month entirely. Nobody notices until accounting asks why the totals don't match.
The reason midnight is the villain is that it's the one time where a small offset flips the calendar date. A 15-minute delay at 14:00 changes nothing you care about. The same delay at 23:58 changes the day, and sometimes the month, and twice a year it changes by a whole hour on its own.
I've started treating any job scheduled near 00:00 as a code smell.
Not wrong, but worth a second look. Nine times out of ten the person wrote 0 0 because it felt tidy, not because midnight meant anything to the business.
If your scheduled job also talks to an external service that might retry it, the date confusion compounds. I wrote up the safe pattern for that in Writing Idempotent Webhooks, and the same discipline applies here: assume the job can run twice and land on two different calendar days if you're not careful.
Define the Business Day on Purpose, Not by Accident
The fix starts with a decision, not with code. What timezone is the business day measured in? Write it down. Put it in config as a string.
Use an IANA timezone name like Europe/Berlin or America/Los_Angeles. Don't use UTC+2. A fixed offset is a lie for half the year, because the offset changes when daylight saving kicks in. The name encodes all the rules; the offset encodes a single frozen moment.
Once you have that string, stop asking the server what time it is. Compute your reporting window in the business timezone, then convert to UTC only when you query the database.
In practice that looks like this. You want "yesterday in Berlin." You take now, convert it to Europe/Berlin, subtract a day, snap to the start of that day, and snap to the start of the next day. Those two instants are your window boundaries. Then you convert both to UTC for the actual query, because your timestamps are almost certainly stored in UTC. If they're not, that's a separate fire to put out first.
The key move is that the window is defined in local time and the query runs in UTC. People keep collapsing those two steps into one and that's where the bug lives.
I keep a tiny helper for this so I never hand-roll the math. Something that takes a timezone string and a date and returns { startUtc, endUtc }. One function, one test file, reused everywhere. The alternative is scattering new Date() and manual hour arithmetic across six jobs, and each copy drifts slightly until they all disagree.
Schedule the cron trigger in UTC still.
That part's fine. The trigger time and the data window are different concerns. Run the trigger a safe distance from midnight (I like 03:00 or 04:00 local, converted to UTC), and let the window logic figure out which day it's reporting on. A late trigger then can't change the answer, because the answer doesn't depend on when the job actually fired. It depends on the explicit window you computed.
This is the same instinct as validating inputs before you trust them. I go deeper on drawing those boundaries in Request Validation at the Edge.
Daylight Saving Is the Bug That Hides for Six Months
Your job runs fine for months. Then on a Sunday in late March or late October, something's an hour off. Welcome to DST.
Two things happen. In spring, clocks jump forward, so 02:00 to 03:00 doesn't exist. In autumn, clocks fall back, so 02:00 to 03:00 happens twice. If you scheduled a local-time job inside that window, it either skips or doubles.
That's exactly why you should schedule the cron trigger in UTC, which has no DST, and compute the window in local time. UTC marches forward one second at a time, forever, no jumps. Your trigger is stable. The local window shifts by an hour across the DST boundary, which is correct, because the business day genuinely is an hour shorter or longer on those two days.
Here's the part people get wrong even after they've fixed the first bug. They store a UTC offset for the business, say +01:00, and reuse it all year. From late March to late October in Berlin the real offset is +02:00. So for about seven months, every window is an hour off. It looks fine in winter when they test it, and goes quietly wrong in summer.
The name Europe/Berlin carries the rule that the offset is +01:00 in winter and +02:00 in summer, and it knows the exact instants where it flips. Any decent date library with timezone support will apply that for you. You just have to feed it the name, not the number.
One more trap. The hour that occurs twice in autumn is ambiguous. "01:30 local" maps to two different UTC instants on that night. If your window boundary lands there, pick a convention (most libraries default to the earlier instant) and make sure your test asserts which one you got. Don't leave it to chance.
I'll be honest, I don't trust myself to reason about these by hand.
I write a test that pins a fake clock to a specific UTC instant on a DST night and checks the window boundaries come out right. The test is the only thing I trust when the next DST change rolls around and I've forgotten all of this.
The Four Tests That Catch Everything
You don't need a hundred cases. You need four, each pinned to a fixed instant so the test gives the same answer forever.
Test one is plain midnight. Set the clock to 00:30 local time and check that "yesterday" is actually the previous calendar day. This catches the off-by-a-day error where a late-firing job or a careless window calculation rolls into the wrong date.
Test two is the month boundary. Set the clock to 00:30 local on the first of the month and confirm "yesterday" lands in the previous month, and that a monthly rollup includes the right 28, 30, or 31 days. This is where the Los Angeles customer at 23:30 gets lost if your window is computed in UTC.
Test three is spring-forward. Pin the clock to the DST gap night and check that the window still spans a full local day even though that day is 23 hours long in real time. The start and end boundaries should be correct instants, not naive hour math.
Test four is fall-back. Pin to the night with the repeated hour and check your window handles the 25-hour day and picks a consistent instant for the ambiguous boundary.
Run these with a frozen clock.
Real new Date() makes tests that pass today and fail next October. Inject the current time as a parameter, or use a library that lets you set a fake system time. Then assert on exact UTC instants, not on strings, because string formatting adds its own timezone confusion on top.
I keep these four as a template and copy them into any project that has scheduled jobs. The edge cases don't change between projects. Only the timezone string and the window function do. Writing them once and reusing them is the whole point, the same way I reuse a structure for articles so I don't rebuild it each time, which I described in The Blog Post Structure I Use.
If you also schedule social posts off the back of these jobs, the same timezone logic applies to when they publish. A tool like Buffer handles the posting side, but the "which day's content" decision still has to be made correctly upstream.
Bottom Line
Pick the business timezone, store it as an IANA name, and compute your reporting window in that zone before converting to UTC for the query. Trigger the cron in UTC and keep it away from midnight so a late run can't change which day you report. Then write the four tests with a frozen clock and stop reasoning about DST in your head.
If I were starting a new scheduled job today, I'd write the window function and its tests before the job logic. The logic is the easy part. The dates are where the money goes missing.
For how I wire up the agent that scaffolds this kind of helper and its tests in one pass, the Claude Blueprint walks through the setup I use. Start there, then drop in the four tests above, and the midnight bug stops being a thing you ship.
This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)
來源:DEV Community · dev.to