业务系统-全球化多时区的解决思路

时间:2024-03-04 21:27:49

本人前段时间经历了一个全球化的报表项目(java+mysql),刚开始业务只在国内开展,所有报表用户都是中国人,涉及时间/日期的数据,统一用北京时间即可。后来业务逐渐扩大到海外市场,很多国外用户也会使用该系统,这样默认用北京时间来显示就不太友好了。

 仔细分析一下,主要是几个关键点:

一、数据查询

当中国用户来查看报表时,通常是在国内,查询某张报表时,传入的查询日期参数 :比如 2020-04-06 00:00:00 ~ 2020-04-07 00:00:00,这2个字符串传到服务端,应该理解为北京时间(GMT+08:00)。

而当海外用户,比如"东京"的用户来查看时,同样还是 2020-04-06 00:00:00 ~ 2020-04-07 00:00:00,服务端收到这2个字符串时,应该理解为东京时间(GMT+09:00)时间。

所以,首先要改造的地方在于"查询参数",必须新增一个额外的时区参数,类似 timeZone:"GMT+08:00"之类,这样服务端才能知道用户所在时区。

 

二、数据存储

大多数公司的业务系统都是存储在mysql之类的关系型数据库中,通常在项目初期,全球化问题暂时不会考虑,部署在中国区的mysql实例,默认就是北京的东8区,即:GMT+08:00。

业务扩展到海外后,如果db性能还跟得上,仍然建议集中存储到原来的实例上,即数据存储仍然还是采用默认的GMT+08:00的北京时间存储。海外用户如果要访问加速,可以在当地部署数据副本,把主库的数据同步过去(方案有很多,大家可以自行网上查阅)。

这样的好处是,数据写入部分不用作任何修改。

 

三、时间的匹配及展示

有了前面2个前提,后面的事情就好做了,先来看日期字段的sql where 匹配:

3.1 根据查询参数中的timeZone,把传入的日期字符串,视为当地时间,统一转换成北京时间(在java层做转换即可,文章最后会给出转换代码),这样就跟db中的时区一致,原来的sql语句不用任何调整.

3.2 在数据展示时,把db中查出来的时间(默认北京时间),根据timeZone转换成当地时间显示,仍然只需要在java层输出数据时做转换 。

 

四、一些按天汇总的job调整

有些报表,是按“自然天”跑定时job汇总统计,比如每天统计 当地时间0点到23:59:59的订单总数。在只有中国业务的时期,这个统计的时间段范围就是北京时间的每天00:00:00 ~ 23:59:59,但是有海外业务后,当地的自然天,就不再是北京时间的00:00:00 ~ 23:59:59了,思路还是类似的:先将当地自然天的00:00:00 ~ 23:59:59,转换成北京时间对应的时间段.

比如:对于东京地区而言,2020-04-06 00:00:00 ~ 2020-04-06 23:59:59,其实对应北京时间的2020-04-05 23:00:00 ~ 2020-04-06 22:59:59. 仍然只需要在job计算的入口,统一换成北京时间的24小时区间段,再计算即可。

该方案理论上没问题,但实际落地时会有些复杂,比如:原来的job,每天0点后,算前1天的即可,只要跑一次,现在海外用户加进来后,比如有3个海外地区,job就要在额外的3个时间点,分别计算各个地区的自然天汇总数据。可能需要把原来的job部署多份(或配置多个启动的时间点),然后在每个不同的时间点,要有各自的逻辑,计算指定地区的数据。

 

所以,还有另一个思路:把按天计算的报表,汇总的时间颗粒度细化,变成按小时计算,每个小时汇总前1个小时的数据,1个小时一条记录,然后不同时区的用户在查看时,根据当地自然天,查询出对应匹配的24条记录,最后做个简单的sum即可。这样job就不用区别对待各个地区,逻辑是统一的,对所有地区,只算上1个小时数据。

 

最后贴一段时区转换的工具代码:

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Date;

public class DateTest {

    public static void main(String[] args) {
        Date now = new Date();//中国部署的服务器,通常时间即为北京时间GMT+08:00
        String pattern = "yyyy-MM-dd HH:mm:ss.SSS";

        System.out.println("北京时间(GMT+08:00):");
        System.out.println(now);

        System.out.println("转换成东京时间(GMT+09:00)字符串:");
        System.out.println(toTargetDateTimeString(now, "GMT+9", pattern));
        System.out.println("转换成东京时间(GMT+09:00):");
        System.out.println(toTargetDate(now, "GMT+9"));

        System.out.println("\n东京时间(GMT+09:00)字符串:");
        String gmt9DateTimeString = "2020-04-06 14:32:52.534";
        System.out.println(gmt9DateTimeString);
        System.out.println("转换成北京时间(GMT+08:00)字符串:");
        System.out.println(toTargetDateTimeString(gmt9DateTimeString, "GMT+9", pattern, "GMT+8"));
        System.out.println("转换成北京时间(GMT+08:00):");
        System.out.println(toTargetDate(gmt9DateTimeString, "GMT+9", pattern, "GMT+8"));
    }

    /**
     * @param date
     * @param targetGMT
     * @return
     */
    public static Date toTargetDate(Date date, String targetGMT) {
        return toDate(LocalDateTime.ofInstant(date.toInstant(), ZoneId.of(targetGMT)));
    }

    /**
     * date -> 目标GMT时区字符串
     *
     * @param date
     * @param targetGMT
     * @param pattern
     * @return
     */
    public static String toTargetDateTimeString(Date date, String targetGMT, String pattern) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
        return LocalDateTime.ofInstant(date.toInstant(), ZoneId.of(targetGMT)).format(formatter);
    }

    /**
     * 将原GMT时区的日期字符串->目标GMT时区的日期字符串
     *
     * @param srcDateTimeString
     * @param srcGMT
     * @param pattern
     * @param targetGMT
     * @return
     */
    public static String toTargetDateTimeString(String srcDateTimeString, String srcGMT, String pattern, String targetGMT) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
        LocalDateTime srcLocalDateTime = LocalDateTime.parse(srcDateTimeString, formatter);
        ZonedDateTime srcZonedDateTime = srcLocalDateTime.atZone(ZoneId.of(srcGMT));
        LocalDateTime targetLocalDateTime = LocalDateTime.ofInstant(srcZonedDateTime.toInstant(), ZoneId.of(targetGMT));
        return targetLocalDateTime.format(formatter);
    }

    /**
     * 将原GMT时区的日期字符串->目标GMT时区的Date
     *
     * @param srcDateTimeString
     * @param srcGMT
     * @param pattern
     * @param targetGMT
     * @return
     */
    public static Date toTargetDate(String srcDateTimeString, String srcGMT, String pattern, String targetGMT) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
        LocalDateTime srcLocalDateTime = LocalDateTime.parse(srcDateTimeString, formatter);
        ZonedDateTime srcZonedDateTime = srcLocalDateTime.atZone(ZoneId.of(srcGMT));
        LocalDateTime targetLocalDateTime = LocalDateTime.ofInstant(srcZonedDateTime.toInstant(), ZoneId.of(targetGMT));
        return toDate(targetLocalDateTime);
    }

    /**
     * Date -> LocalDateTime
     *
     * @param date
     * @return
     */
    public static LocalDateTime toLocalDateTime(Date date) {
        Instant instant = date.toInstant();
        ZoneId zone = ZoneId.systemDefault();
        return LocalDateTime.ofInstant(instant, zone);
    }

    /**
     * Date -> LocalDate
     *
     * @param date
     * @return
     */
    public static LocalDate toLocalDate(Date date) {
        Instant instant = date.toInstant();
        ZoneId zone = ZoneId.systemDefault();
        LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, zone);
        return localDateTime.toLocalDate();
    }

    /**
     * Date -> LocalTime
     *
     * @param date
     * @return
     */
    public static LocalTime DateToLocalTime(Date date) {
        Instant instant = date.toInstant();
        ZoneId zone = ZoneId.systemDefault();
        LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, zone);
        return localDateTime.toLocalTime();
    }


    /**
     * LocalDateTime -> Date
     *
     * @param localDateTime
     * @return
     */
    public static Date toDate(LocalDateTime localDateTime) {
        ZoneId zone = ZoneId.systemDefault();
        Instant instant = localDateTime.atZone(zone).toInstant();
        return Date.from(instant);
    }

    /**
     * ZonedDateTime -> Date
     *
     * @param zonedDateTime
     * @return
     */
    public static Date toDate(ZonedDateTime zonedDateTime) {
        Instant instant = zonedDateTime.toInstant();
        return Date.from(instant);
    }


    /**
     * LocalDate -> Date
     *
     * @param localDate
     * @return
     */
    public static Date toDate(LocalDate localDate) {
        ZoneId zone = ZoneId.systemDefault();
        Instant instant = localDate.atStartOfDay().atZone(zone).toInstant();
        return Date.from(instant);
    }

    /**
     * LocalDate,LocalTime -> LocalTimeToDate
     *
     * @param localDate
     * @param localTime
     */
    public static Date toDate(LocalDate localDate, LocalTime localTime) {
        LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime);
        ZoneId zone = ZoneId.systemDefault();
        Instant instant = localDateTime.atZone(zone).toInstant();
        return Date.from(instant);
    }

}

测试输出结果 :

北京时间(GMT+08:00):
Mon Apr 06 15:27:56 CST 2020
转换成东京时间(GMT+09:00)字符串:
2020-04-06 16:27:56.467
转换成东京时间(GMT+09:00):
Mon Apr 06 16:27:56 CST 2020

东京时间(GMT+09:00)字符串:
2020-04-06 14:32:52.534
转换成北京时间(GMT+08:00)字符串:
2020-04-06 13:32:52.534
转换成北京时间(GMT+08:00):
Mon Apr 06 13:32:52 CST 2020