会员生日提前了一天

背景

有一天,收到反馈,某些用户的生日提前了一天(变成了前一天的23:00:00), 比如填写生日"1988-08-20",数据库中变成了"1988-08-19 23:00:00"

创建容器的时候,指定了系统时区为Asia/Shanghai

ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 

数据库连接指定了连接时区为GMT+8(原本也是Asia/Shanghai,这是为了修复另外一个问题)

spring.datasource.url=jdbc:mysql://192.168.1.10:3344/user?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8

原因探查

一般这种情况是时区原因导致,直接搜索一下Asia/Shanghai,发现Asia/Shanghai在1986~1991年使用了夏令时。

中国夏令时

1986年4月,中国中央有关部门发出“在全国范围内实行夏时制的通知”,具体做法是:每年从四月中旬第一个星期日的凌晨2时整(北京时间),将时钟拨快一小时,即将表针由2时拨至3时,夏令时开始;到九月中旬第一个星期日的凌晨2时整(北京夏令时),再将时钟拨回一小时,即将表针由2时拨至1时,夏令时结束。从1986年到1991年的六个年度,除1986年因是实行夏时制的第一年,从5月4日开始到9月14日结束外,其它年份均按规定的时段施行。在夏令时开始和结束前几天,新闻媒体均刊登有关部门的通告。1992年起,夏令时暂停实行。

PS:据说是为了省电,但实际毛线也没有省,却给我们程序员挖了坑。

解析生日输入的代码如下

public static Date parseBirthdayDate(String date) {
     return new SimpleDateFormat("yyyy-MM-dd").parse(date);

这里使用Asia/Shanghai解析时间,由于夏令时的原因,这里得到的时间戳会少一个小时

那么是不是保存到数据库中后,数据库(时区为UTC+08:00)将时间转换为自己时区的时间导致出现问题呢?
数据库使用了datetime类型来保存时间
mysql datetime:

A date and time combination. The supported range is ‘1000-01-01 00:00:00.000000’ to ‘9999-12-31 23:59:59.999999’. MySQL displays DATETIME values in ‘YYYY-MM-DD hh:mm:ss[.fraction]’ format, but permits assignment of values to DATETIME columns using either strings or numbers.
MySQL converts TIMESTAMP values from the current time zone to UTC for storage, and back from UTC to the current time zone for retrieval. (This does not occur for other types such as DATETIME.)
由此可见datetime不会因为时区而进行转换,问题不是在这里。

程序使用了mybatis框架保存数据到数据库,生日是转换为Date类型后保存的,看一下类型转换器:
SqlDateTypeHandler:

public class SqlDateTypeHandler extends BaseTypeHandler<Date> {

  @Override
  public void setNonNullParameter(PreparedStatement ps, int i, Date parameter, JdbcType jdbcType)
      throws SQLException {
    ps.setDate(i, parameter);
  }
  //....
}

public class PreparedStatement {
    public void setDate(int parameterIndex, java.sql.Date x) throws java.sql.SQLException {
        synchronized (checkClosed().getConnectionMutex()) {
            setDateInternal(parameterIndex, x, this.session.getDefaultTimeZone());
        }
    }

    private void setDateInternal(int parameterIndex, Date x, TimeZone tz) throws SQLException {
        if (x == null) {
            setNull(parameterIndex, MysqlType.DATE);
        } else {
            if (this.ddf == null) {
                this.ddf = new SimpleDateFormat("''yyyy-MM-dd''", Locale.US);
            }

            this.ddf.setTimeZone(tz);

            setInternal(parameterIndex, this.ddf.format(x));
        }
    }
}

这里可以看到,Date数据在转换时,是调用了new SimpleDateFormat(“‘‘yyyy-MM-dd’’”), 并且使用时区this.session.getDefaultTimeZone()格式化成字符串后保存的。
this.session.getDefaultTimeZone() 可以由连接参数serverTimezone指定。
因为上面数据库连接串指定了使用GMT+8, 所以在转换后,字符串时间将会少一个小时!

解决问题

大部分时候夏令时只会带来问题,因此解决方案是将系统时间修改为GMT+8,不再采用Asia/Shanghai时区。
然后发现,时区数据库tzdata中没有Asia/Beijing, 我国位于东8时区的地区还有Asia/Hong_Kong和Asia/Taipei,但这两个一样有夏令时的问题。
最后确定采用Etc/GMT-8时区来作为系统时区,为什么是GMT-8而不是GMT+8,可以参考维基百科说明:

The special area of “Etc” is used for some administrative zones, particularly for “Etc/UTC” which represents Coordinated Universal Time. In order to conform with the POSIX style, those zone names beginning with “Etc/GMT” have their sign reversed from the standard ISO 8601 convention. In the “Etc” area, zones west of GMT have a positive sign and those east have a negative sign in their name (e.g “Etc/GMT-14” is 14 hours ahead of GMT).

修改Dockerfile为

ENV TZ=Etc/GMT-8
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 

参考文章

[1] 夏令时
[2] The DATE, DATETIME, and TIMESTAMP Types
[3] tz database

你可能感兴趣的:(Spring,数据库,mybatis,mysql,数据库)