由Docker时区设置引发的问题

老实说,在以往的开发中一般很少考虑时区问题。一来因为以往的项目不论用户还是服务器都在国内,不存在时差;二来服务器一般都设定了正确的时区,所以在整个项目中直接使用Local时间是没有问题的。但这次有些不同:项目部署到云服务器的Docker后,发现存入MySql数据库中的时间比本地时间慢8小时。

貌似是很简单的问题——MySQL存入了UTC时间,但究竟什么原因?如何解决?还是费了不少功夫。

1 问题排查

①首先怀疑是MySQL设定问题,然而Golang后端与MySQL的连接字符串已经设定了时间存取方式,如下
"root:123456@tcp(127.0.0.1:3306)/wxshop_dev?charset=utf8&parseTime=True&loc=Local"
而且在本地测试是不存在时差问题的,那么初步判断应该是部署环境的设定问题。

②通过SSH连接到阿里云,执行date命令,显示当前时间如下:
Thu May 05 11:05:28 CST 201
没有问题,再使用命令进入docker,查看当前时间

1
2
# docker exec -ti container bash
# date

显示如下:Thu May 05 03:05:35 UTC 2016
注意那个UTC,o(╯□╰)o
也就是说,此时Docker中的本地时区是UTC时区

2 使用Dockerfile设定Docker时区

①在Dockerfile中添加如下

1
2
RUN echo "Asia/shanghai" > /etc/timezone
RUN dpkg-reconfigure -f noninteractive tzdata

并没有什么卵用,Jenkins的ConsoleOutput显示如下:
图1

②通过Github上的一篇讨论https://github.com/gliderlabs/docker-alpine/issues/136
推测可能是由于Docker中的tzdata没有正确安装导致,尝试在Dockerfile中使用命令:

1
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

再次进入Docker,终于显示了正确的本地时区。

注意:请勿在运营服务器对外可用的情况下更改时区,否则将导致严重的数据一致性问题。
{: .notice–danger}

为什么这么说?上文提到,数据库存的是本地时间,但没有时区信息,如图所示
图2

简言之,我们无法得知当时存到数据库中的时间是哪个时区的!
也就是说,如果有一天云服务器从国内迁移到国外,很可能出现同样的问题。由此可见,数据库中的时间最好以UTC存取,这样在遇到服务器时区设置错误或者服务器迁移到国外时也不会有任何影响。

另外,MySQL的两种数据格式——datetime和timestamp、SQLServer中的datetime类型,都不存储时区信息。

3 延伸

3.1 前后端交互应该注意时区问题

我的建议是:后台向前台传时间,带上时区信息,前台接到后用Date函数转换为时间格式再做其他处理。例如:

1
2
var inDateTime = new Date(data.InDateTime).Format(dateFormat);
alert(inDateTime);

前台向后台传时间,先转换为UTC时间。建议将以下方法添加到前台的公共JavaScript中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 格式化为Local日期字符串
Date.prototype.FormatLocal = function (fmt) {
var o = {
"M+": this.getMonth() + 1, //月份
"d+": this.getDate(), //日
"h+": this.getHours(), //小时
"m+": this.getMinutes(), //分
"s+": this.getSeconds(), //秒
"q+": Math.floor((this.getMonth() + 3) / 3), //季度
"S": this.getMilliseconds() //毫秒
};
if (/(y+)/.test(fmt))
fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length));
for (var k in o)
if (new RegExp("(" + k + ")").test(fmt))
fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length)));
return fmt;
}

// 格式化为UTC日期字符串
Date.prototype.FormatUTC = function (fmt) {
var o = {
"M+": this.getUTCMonth() + 1, //月份
"d+": this.getUTCDate(), //日
"h+": this.getUTCHours(), //小时
"m+": this.getUTCMinutes(), //分
"s+": this.getUTCSeconds(), //秒
"q+": Math.floor((this.getUTCMonth() + 3) / 3), //季度
"S": this.getUTCMilliseconds() //毫秒
};
if (/(y+)/.test(fmt))
fmt = fmt.replace(RegExp.$1, (this.getUTCFullYear() + "").substr(4 - RegExp.$1.length));
for (var k in o)
if (new RegExp("(" + k + ")").test(fmt))
fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length)));
return fmt;
}

这样,当用户在国外使用我们的网站,也不会出现时差问题。

3.2 Golang时间相关函数用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 获取当前(本地)时间
time.Now()
// 获取当前(UTC)时间
time.Now().UTC()
// string转为time
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
fmt.Println("LoadLocation error", err)
}
realTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeStr, location)
if err != nil {
fmt.Println("Date convert error", err)
}
// 此处两个注意点:
// ①LoadLocation参数是时区字符串,读取自$GOROOT/lib/time/zoneinfo.zip,也就是说,如果文件不存在或没有读到指定时区,此方法报错
// ②ParseInLocation第一个参数是转换的格式,奇葩的Golang不是用诸如"yyyy-MM-dd"这样的写法,而是用Golang的生日。。。

// time转为(本地时间)string
timeStr := realTime.Local().Format("2006-01-02")
// time转为(UTC时间)string
timeStr := realTime.UTC().Format("2006-01-02")

// 时间比较(After/Before)
if time1.After(time2) {
fmt.Println("time1 > time2")
}

// 时间加一天(Add/AddDate/Sub)
realTime = realTime.Add(24 * time.Hour)
realTime = realTime.AddDate(0, 0, 1)

// 判断是否为0时间实例 January 1, year 1, 00:00:00 UTC
var timeZero time.Time
if timeZero.IsZero() {
fmt.Println("timeZero是0时间实例")
}

// 获取时间戳
time.Unix()

4 总结

什么时候会出现本文情况?
答:数据库存的是本地时间,且满足一下任意条件
①服务器时区设置错误
②服务器迁移后时区有变化
③分布式系统中的各个服务器时区不一致

如何避免时区问题?
答:①数据库尽量使用UTC时间格式,特别是使用云服务器的项目
②前台与后台交互尽量使用UTC时间格式,特别是可能有不同国家/地区的用户的项目