第一章:SLO驱动的顺序写文件设计哲学
在高吞吐、低延迟的存储系统中,顺序写是性能与可靠性的关键杠杆。SLO(Service Level Objective)并非抽象指标,而是直接约束文件写入路径的设计原点——例如,当SLO要求“99%的1MB写入延迟 ≤ 5ms”时,设计必须规避随机寻道、减少锁竞争、杜绝内存拷贝放大,并将磁盘I/O模式严格锚定在连续物理块上。
为什么顺序写天然适配SLO保障
- 避免磁盘寻道开销:SSD/NVMe虽弱化寻道影响,但顺序写仍能最大化带宽利用率(实测NVMe设备顺序写吞吐达随机写的3.2×)
- 简化落盘一致性:仅需追加日志式写入+单次fsync,无需复杂WAL合并或页级刷脏逻辑
- 可预测延迟分布:固定块大小+预分配文件空间后,p99延迟标准差可控制在±0.3ms内
文件布局强制对齐SLO目标
采用固定分片(Shard)+ 循环追加(Round-robin Append)策略:
- 初始化时预分配1GB稀疏文件(
truncate -s 1G /data/log/shard_001.bin) - 每个写请求按SLO容忍的最小单元(如128KB)对齐写入,避免跨块撕裂
- 使用
posix_fadvise(fd, offset, len, POSIX_FADV_DONTNEED)主动释放page cache,防止写缓存抖动影响延迟
# 示例:创建SLO对齐的写入环境(Linux)
mkdir -p /data/log && cd /data/log
# 创建16个1GB预分配分片,启用direct I/O绕过page cache
for i in $(seq -w 1 16); do
truncate -s 1G shard_${i}.bin
# 设置ext4挂载选项:noatime,barrier=0,data=writeback(生产环境需权衡持久性)
done
SLO验证闭环机制
| 每次发布新写入模块前,必须通过以下验证: | 验证项 | 工具/方法 | 合格阈值 |
|---|---|---|---|
| 延迟P99 | fio --name=seqwrite --ioengine=sync --rw=write --bs=128k --runtime=60 |
≤ 5ms | |
| 吞吐稳定性 | 连续10轮测试,标准差/均值 | ✅ | |
| 故障注入恢复时间 | kill -STOP进程后10秒内恢复写入 | ≤ 200ms |
真正的SLO驱动设计拒绝“先实现再调优”,而是将延迟预算反向分解为文件系统调用链路:write() → fsync() → storage controller queue depth → physical block layout,每一层都必须有可测量、可约束的执行契约。
第二章:底层系统调用与内核写行为剖析
2.1 syscall.Write系统调用的原子性与EINTR重试实践
原子性边界:什么情况下write()真正“不可中断”
syscall.Write 在内核中对同一文件描述符、小尺寸(≤PIPE_BUF)写入具有POSIX原子性保证——即多个线程/进程并发写入不会交错字节。但该保证不延伸至用户态缓冲区拷贝阶段,更不覆盖信号中断场景。
EINTR:被信号打断的合法返回
当写入被信号中断时,内核返回 -1 并置 errno = EINTR,此时:
- 已写入字节数可能 > 0(部分成功)
- 也可能为 0(未写入任何字节)
- 必须由用户态显式重试,而非忽略
n, err := syscall.Write(fd, buf)
for err == syscall.EINTR {
n, err = syscall.Write(fd, buf[n:]) // 仅重试剩余数据
}
if err != nil {
return err
}
逻辑分析:
buf[n:]确保跳过已成功写入的n字节;syscall.EINTR是唯一需重试的 errno;直接忽略将导致数据截断。
典型重试策略对比
| 策略 | 安全性 | 性能开销 | 适用场景 |
|---|---|---|---|
| 无条件重试 | ✅ | ⚠️ 高 | 实时日志、关键IO |
| 限定次数重试 | ✅✅ | ✅ 中 | 生产服务通用方案 |
| 放弃并报错 | ❌ | ✅ 低 | 仅调试用途 |
graph TD
A[调用 syscall.Write] --> B{返回值 n}
B -->|n > 0| C[成功,结束]
B -->|n == -1| D{errno == EINTR?}
D -->|是| E[更新 buf = buf[n:], 循环重试]
D -->|否| F[返回错误]
2.2 page cache、writeback与fsync语义的精确控制实验
数据同步机制
Linux内核通过page cache缓存文件I/O,writeback线程异步刷脏页,而fsync()强制同步元数据+数据到磁盘。三者协同决定持久性语义。
实验控制要点
- 使用
echo 3 > /proc/sys/vm/drop_caches清理缓存 - 调整
/proc/sys/vm/dirty_ratio和dirty_expire_centisecs控制writeback时机 O_SYNCvsO_DSYNCvs 普通写+fsync()对比
关键验证代码
int fd = open("test.dat", O_WRONLY | O_CREAT, 0644);
write(fd, buf, 4096);
fsync(fd); // 强制落盘:数据+inode mtime/size
close(fd);
fsync()触发VFS层file_operations->fsync,经generic_file_fsync()调用底层fs的sync_fs或write_inode,确保块设备提交完成。
| 语义级别 | 数据落盘 | 元数据落盘 | 延迟典型值 |
|---|---|---|---|
write() |
❌ | ❌ | |
fsync() |
✅ | ✅ | 5–50ms |
fdatasync() |
✅ | ❌(仅mtime/size) | 3–30ms |
graph TD
A[用户write] --> B[Page Cache Dirty Page]
B --> C{writeback线程触发?}
C -->|dirty_expire| D[回写至块层bio]
C -->|fsync调用| E[同步等待IO完成]
E --> F[返回成功]
2.3 O_DIRECT/O_SYNC/O_DSYNC在高吞吐场景下的选型验证
数据同步机制
Linux 提供三种关键 I/O 标志控制数据落盘行为:
O_DIRECT:绕过页缓存,直接与块设备交互,降低内存拷贝开销;O_SYNC:写入时强制等待数据 和元数据(如 inode、mtime)持久化;O_DSYNC:仅确保数据及必要元数据(如文件长度)落盘,忽略时间戳等非关键字段。
性能对比实测(4K 随机写,NVMe SSD)
| 标志 | 吞吐量 (MB/s) | 平均延迟 (μs) | CPU 占用率 |
|---|---|---|---|
O_DIRECT |
1850 | 42 | 12% |
O_SYNC |
310 | 1280 | 39% |
O_DSYNC |
760 | 310 | 21% |
典型调用示例
int fd = open("/data/log.bin", O_WRONLY | O_DIRECT | O_CLOEXEC);
// 注意:O_DIRECT 要求 buffer 对齐(如 posix_memalign(4096))、offset/len 为 512B 倍数
该配置规避 page cache 竞争,适合日志系统或数据库 WAL 场景,但需应用层严格管理对齐与生命周期。
写入路径差异
graph TD
A[write()] --> B{flags}
B -->|O_DIRECT| C[Kernel → Block Layer]
B -->|O_SYNC| D[Page Cache → fsync→ device]
B -->|O_DSYNC| E[Page Cache → sync data+size only]
2.4 文件描述符生命周期管理与fd泄漏的生产级防御策略
fd泄漏的典型诱因
open()/socket()后未配对close()fork()后子进程继承fd但未显式关闭- 异常路径(如panic、early return)跳过资源清理
生产级防护三支柱
- RAII式封装:用
defer close(fd)或io.Closer接口确保释放 - fd限额监控:
cat /proc/<pid>/limits | grep "Max open files" - 运行时检测:
lsof -p <pid> | wc -l+ 告警阈值
自动化泄漏检测代码示例
// 检测当前进程fd数量是否超阈值
func checkFDLeak(threshold int) error {
files, err := os.ReadDir("/proc/self/fd") // Linux特有路径
if err != nil { return err }
if len(files) > threshold {
return fmt.Errorf("fd leak detected: %d > %d", len(files), threshold)
}
return nil
}
os.ReadDir("/proc/self/fd")遍历符号链接目录,每个条目对应一个打开fd;len(files)即当前fd总数。阈值需根据服务QPS和连接模型预设(如HTTP服务建议≤80% ulimit)。
防御策略效果对比
| 策略 | 检测时机 | 覆盖场景 | 运维成本 |
|---|---|---|---|
ulimit -n硬限制 |
进程启动时 | 完全阻断 | 低 |
lsof定时巡检 |
运行时 | 已泄漏,可定位 | 中 |
Go runtime.SetFinalizer |
GC时 | 仅覆盖非逃逸对象 | 高 |
graph TD
A[fd申请] --> B{是否显式close?}
B -->|是| C[正常释放]
B -->|否| D[进入finalizer队列]
D --> E[GC触发Finalizer]
E --> F[调用close并记录告警]
2.5 mmap写模式与传统write链路的延迟/一致性权衡实测分析
数据同步机制
mmap(MAP_SHARED) 写入直接落至页缓存,依赖 msync() 或内核回写策略;write() 则经 VFS → page cache → block layer,受 dirty_ratio 和 dirty_expire_centisecs 控制。
延迟对比实测(单位:μs,1KB随机写)
| 场景 | 平均延迟 | P99延迟 | 是否立即持久化 |
|---|---|---|---|
mmap + msync |
8.2 | 42 | ✅ |
write + fsync |
136 | 310 | ✅ |
mmap(无msync) |
0.7 | 1.3 | ❌(仅内存可见) |
// mmap写后强制刷盘:避免脏页延迟回写
if (msync(addr, len, MS_SYNC) == -1) {
perror("msync failed"); // MS_SYNC阻塞直至落盘,MS_ASYNC仅提交IO队列
}
msync() 的 MS_SYNC 标志确保数据与元数据同步落盘,但引入显著延迟;而 MS_ASYNC 仅触发回写,不保证完成时机。
一致性路径差异
graph TD
A[用户写入] --> B{mmap路径}
A --> C{write路径}
B --> D[修改页缓存页]
B --> E[msync→block layer]
C --> F[copy_to_iter→page cache]
C --> G[fsync→submit_bio]
mmap避免用户态拷贝,但需显式同步;write系统调用开销大,但fsync语义更明确。
第三章:Go标准库io.Writer抽象层稳定性治理
3.1 bufio.Writer缓冲区溢出与panic边界条件的注入测试
缓冲区溢出触发机制
bufio.Writer 在 Flush() 或内部自动刷写时,若底层 io.Writer 返回 err != nil 且缓冲区未清空,可能因重复写入引发 panic。关键边界在于:写入字节数 > w.Available() 且 w.Buffered() == 0。
复现 panic 的最小代码
package main
import (
"bufio"
"bytes"
"fmt"
)
func main() {
// 构造极小缓冲区(仅1字节),强制溢出
buf := make([]byte, 1)
w := bufio.NewWriterSize(&bytes.Buffer{}, 1)
// 写入2字节 → 超出可用空间,但未触发panic(缓冲中)
w.Write([]byte("ab")) // Buffered()==2
// 此时Available()==0,再Write将直接panic
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic caught:", r)
}
}()
w.Write([]byte("c")) // 💥 panic: bufio: tried to write more than 1 byte in one Write call
}
逻辑分析:
bufio.Writer.Write要求单次写入 ≤Available()(当前空闲容量)。当缓冲区已满(Available()==0)且写入长度 > 0,立即 panic —— 这是设计的硬性安全边界,非错误处理路径。
常见触发场景对比
| 场景 | Available() | 写入长度 | 是否panic |
|---|---|---|---|
| 初始状态(缓冲区空) | 1 | 2 | ❌(拆分写入,不panic) |
缓冲区满后调用 Write |
0 | 1 | ✅(直接panic) |
Flush() 失败后再次 Write |
0 | 1 | ✅(同上) |
graph TD
A[Write call] --> B{len(p) <= w.Available?}
B -->|Yes| C[Copy to buffer]
B -->|No| D[Check w.Buffered == 0]
D -->|Yes| E[Panic: write too large]
D -->|No| F[Flush then retry]
3.2 io.MultiWriter与io.TeeReader在日志双写链路中的SLO保障实践
在高可用日志系统中,需同时写入本地磁盘与远程Kafka以满足99.9%写入成功率SLO。io.MultiWriter天然支持并发写入多目标,而io.TeeReader则确保读取流的同时无侵入式旁路记录。
数据同步机制
使用MultiWriter聚合本地文件与网络Writer:
logWriter := io.MultiWriter(
os.Stdout, // 实时控制台(调试)
localFile, // 本地持久化(主路径)
kafkaWriter, // 远程投递(备份路径)
)
MultiWriter按顺序同步写入各writer,任一失败将返回error——这要求下游具备幂等性与重试能力,否则影响整体SLO。本地写入延迟
流量镜像与可观测性
对HTTP请求体做零拷贝镜像:
teeReader := io.TeeReader(req.Body, mirrorWriter) // mirrorWriter写入审计日志
body, _ := io.ReadAll(teeReader) // 原始逻辑不变
TeeReader在读取时自动复制字节到mirrorWriter,不阻塞主流程;mirrorWriter需异步缓冲,避免拖慢主链路。
| 组件 | SLO指标 | 实测P99延迟 | 备注 |
|---|---|---|---|
| MultiWriter写入 | 写入成功率≥99.9% | 42ms | Kafka临时不可用时降级至本地 |
| TeeReader读取 | 额外开销≤1ms | 0.7ms | 基于内存buffer,无syscall |
graph TD
A[HTTP请求体] --> B[TeeReader]
B --> C[业务Handler]
B --> D[审计日志Writer]
C --> E[MultiWriter]
E --> F[本地文件]
E --> G[Kafka]
3.3 context.Context集成写操作超时与取消的可观测性增强方案
在高并发写入场景中,单纯依赖 context.WithTimeout 无法暴露操作中断的真实原因。需将上下文生命周期与指标、日志、追踪三者深度耦合。
数据同步机制
写操作封装为可观测函数,自动注入 ctx 并注册取消钩子:
func WriteWithObservability(ctx context.Context, data []byte) error {
// 记录开始时间与 traceID
start := time.Now()
traceID := ctx.Value("trace_id").(string)
// 启动 cancel-aware metric observer
defer func() {
if errors.Is(ctx.Err(), context.Canceled) {
writeCancelCounter.WithLabelValues(traceID).Inc()
}
writeLatencyHist.WithLabelValues(traceID).Observe(time.Since(start).Seconds())
}()
return db.Write(ctx, data) // 透传 ctx,底层支持 cancel
}
逻辑分析:ctx.Value("trace_id") 提供链路标识;defer 中通过 ctx.Err() 判定取消类型(Canceled vs DeadlineExceeded),驱动不同监控维度;writeCancelCounter 统计主动取消频次,辅助识别客户端异常重试模式。
可观测性维度对齐表
| 维度 | 指标示例 | 触发条件 |
|---|---|---|
| 超时 | write_timeout_total |
ctx.Err() == DeadlineExceeded |
| 主动取消 | write_cancel_total |
ctx.Err() == Canceled |
| 成功写入 | write_success_duration |
err == nil |
生命周期协同流程
graph TD
A[Client Init Context] --> B[WithTimeout/WithCancel]
B --> C[WriteWithObservability]
C --> D{db.Write ctx-aware}
D -->|Success| E[Report Latency & Success]
D -->|Canceled| F[Increment Cancel Counter]
D -->|DeadlineExceeded| G[Increment Timeout Counter]
第四章:生产级顺序写链路工程化落地
4.1 基于ring buffer的预分配日志文件写入器实现与压测报告
核心设计动机
避免频繁系统调用与磁盘碎片,通过预分配固定大小日志文件(如256MB)+ 无锁环形缓冲区(RingBuffer)解耦写入与刷盘。
RingBuffer 写入逻辑(Java伪代码)
public boolean tryWrite(LogEntry entry) {
long seq = ringBuffer.tryNext(); // 非阻塞申请槽位
if (seq == -1) return false; // 缓冲区满
LogEvent event = ringBuffer.get(seq);
event.copyFrom(entry); // 零拷贝序列化
ringBuffer.publish(seq); // 发布可见序号
return true;
}
tryNext() 原子获取下一个可写序号;publish() 触发消费者(刷盘线程)感知。缓冲区大小需为2的幂以支持位运算取模。
压测关键指标(16核/64GB,SSD)
| 并发线程 | 吞吐量(MB/s) | P99延迟(μs) | CPU占用率 |
|---|---|---|---|
| 8 | 182 | 43 | 31% |
| 64 | 207 | 112 | 68% |
数据同步机制
刷盘线程监听RingBuffer游标,批量聚合日志后调用 FileChannel.write() + force(false),跳过元数据刷新以提升吞吐。
4.2 WAL格式化写入器:checksum校验、record边界对齐与CRC32加速实践
WAL(Write-Ahead Logging)格式化写入器是数据库持久化可靠性的核心组件,其设计需兼顾完整性、解析效率与硬件友好性。
校验与对齐的协同设计
每个WAL record以8字节对齐为前提,确保跨平台内存访问安全;同时在record尾部嵌入4字节CRC32校验值(IEEE 802.3标准),覆盖length + data + type字段:
// 计算并追加CRC32校验码(小端序存储)
uint32_t crc = crc32c_calculate(buf, len); // buf含length(4B)+type(1B)+data
memcpy(buf + len, &crc, sizeof(crc)); // 追加至末尾
crc32c_calculate()采用查表法+SSE4.2指令加速,在Intel CPU上吞吐达12 GB/s;len不含校验字段本身,避免循环依赖。
关键参数对照表
| 字段 | 长度 | 说明 |
|---|---|---|
| Record Length | 4B | 含type、data,不含CRC |
| Record Type | 1B | INSERT/UPDATE/COMMIT等 |
| Data Payload | N×8B | 自动pad至8字节倍数 |
| CRC32 Checksum | 4B | 小端序,覆盖前N字节 |
数据流校验流程
graph TD
A[Record组装] --> B[8字节边界对齐填充]
B --> C[CRC32计算:length+type+data]
C --> D[追加4B校验码]
D --> E[原子写入page-aligned buffer]
4.3 写失败自动降级机制:本地磁盘满时切换到临时内存buffer+异步flush
降级触发条件
当写入本地磁盘失败且 errno == ENOSPC(磁盘空间不足)时,系统立即启用降级路径,避免写操作阻塞或丢数据。
核心流程设计
# 降级写入逻辑(简化示意)
def write_with_fallback(data):
try:
disk_writer.write(data) # 主路径:直接落盘
except OSError as e:
if e.errno == errno.ENOSPC:
mem_buffer.append(data) # 切换至内存缓冲区(LRU淘汰策略)
asyncio.create_task(async_flush_to_disk()) # 异步刷盘任务
else:
raise
逻辑分析:
mem_buffer采用固定大小的deque(maxlen=1024)实现,避免内存无限增长;async_flush_to_disk()在后台轮询可用磁盘空间,成功后清空缓冲区。参数maxlen需根据平均消息大小与峰值吞吐预估,防止OOM。
状态流转示意
graph TD
A[正常写入磁盘] -->|ENOSPC| B[切换至内存buffer]
B --> C[异步检测磁盘空间]
C -->|空间恢复| D[批量flush并清空buffer]
C -->|持续满载| E[触发告警并限流]
关键参数对照表
| 参数 | 默认值 | 说明 |
|---|---|---|
mem_buffer_size |
64MB | 内存缓冲上限,超限触发LRU淘汰 |
flush_interval_ms |
500 | 异步刷盘检查周期 |
max_retry_on_flush |
3 | 单次flush失败重试次数 |
4.4 Prometheus指标埋点设计:write latency p99、sync duration、retry count三维度监控看板
数据同步机制
系统采用异步双写+幂等校验模式,关键路径需暴露三类核心时序指标:写入延迟(write_latency_seconds)、同步耗时(sync_duration_seconds)和重试次数(retry_count_total)。
指标定义与埋点示例
// 初始化指标(注册到默认Registry)
var (
writeLatency = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "write_latency_seconds",
Help: "P99 write latency for data ingestion",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 10), // 1ms~512ms
},
[]string{"topic", "status"}, // 多维标签便于下钻
)
syncDuration = prometheus.NewSummaryVec(
prometheus.SummaryOpts{
Name: "sync_duration_seconds",
Help: "Sync duration per batch commit",
Objectives: map[float64]float64{0.99: 0.001}, // 显式支持P99计算
},
[]string{"pipeline"},
)
retryCount = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "retry_count_total",
Help: "Total number of retries across all operations",
},
[]string{"stage", "error_type"},
)
)
该埋点结构确保:write_latency_seconds 使用 Histogram 支持服务端直出 P99;sync_duration_seconds 用 Summary 精确采集分位数;retry_count_total 以 Counter 记录失败归因。
监控看板维度联动
| 维度 | 关联指标 | 排查价值 |
|---|---|---|
高 retry_count_total + 高 write_latency_seconds |
网络抖动或下游限流 | 触发熔断策略 |
sync_duration_seconds{quantile="0.99"} 持续上升 |
批处理积压或序列化瓶颈 | 定位 Kafka Producer 配置问题 |
埋点调用链路
graph TD
A[Write Request] --> B[Record writeLatency start]
B --> C[Execute DB Write]
C --> D{Success?}
D -->|Yes| E[Observe writeLatency end]
D -->|No| F[Inc retryCount with error_type=“db_timeout”]
E --> G[Trigger sync job]
G --> H[Observe syncDuration]
三指标协同构成可观测性闭环:延迟异常触发重试,重试频次反哺同步耗时分析。
第五章:从SLO到SLI:构建可度量的文件写入可靠性体系
为什么文件写入需要SLO驱动的可靠性治理
在某金融级日志平台升级中,团队曾遭遇批量写入失败率突增至0.8%的故障——表面看是磁盘IO瓶颈,但因缺乏明确的SLO定义,运维与开发团队反复争论“是否算故障”。最终回溯发现:该平台承诺“99.95%的文件写入请求应在200ms内成功完成”,而实际P99延迟达312ms,失败率在高峰时段突破阈值。这暴露了无SLO约束的可靠性建设如同盲人摸象。
将业务契约翻译为可采集的SLI
针对文件写入场景,我们定义三个核心SLI:
- 成功率:
success_count / (success_count + failure_count),按文件路径+写入模式(同步/异步)维度聚合; - 延迟达标率:
count(write_duration_ms ≤ 200) / total_count,使用Prometheus直方图桶统计; - 数据完整性SLI:
sha256_checksum_match_count / total_written_files,通过写后立即校验并上报。
| SLI名称 | 数据来源 | 采集频率 | 标签维度 |
|---|---|---|---|
| 写入成功率 | 应用层埋点(Log4j Appender拦截器) | 每15秒 | path="/logs/audit", mode="sync" |
| P99写入延迟 | eBPF跟踪sys_write系统调用返回时间 |
每分钟 | fs_type="xfs", device="/dev/nvme0n1" |
构建端到端可观测流水线
采用OpenTelemetry Collector统一采集,关键配置片段如下:
processors:
metrics_transform:
transforms:
- include: "file_write_success_total"
action: update_label
new_label: "slo_target"
new_value: "99.95%"
SLO违约自动归因工作流
当连续3个窗口(每个窗口5分钟)SLO达标率低于99.95%时,触发以下mermaid流程:
flowchart LR
A[SLO违约告警] --> B{检查磁盘队列深度}
B -->|>128| C[触发IO调度器分析]
B -->|≤128| D[检查文件系统inode可用率]
C --> E[采集blktrace生成I/O pattern热力图]
D --> F[执行df -i && find /var/log -xdev -type f | wc -l]
E --> G[定位高延迟写入路径:/logs/transaction]
F --> H[发现inode耗尽:98.7%]
实战案例:电商订单快照写入可靠性攻坚
某大促期间订单快照写入失败率升至1.2%,通过SLI下钻发现:
/snapshots/order/2024Q3/路径成功率仅98.3%,但其他路径均>99.9%;- 追踪该路径的
write_duration_ms直方图,发现16KB以上写入的le="200"桶占比骤降42%; - 结合eBPF捕获的
ext4_file_write_iter调用栈,定位到该目录启用了data=ordered挂载选项,在并发写入时引发journal锁竞争。
最终将该目录迁移至data=writeback挂载的独立SSD分区,并增加预分配inode策略,SLO达标率恢复至99.98%。
SLI指标的存储与长期趋势分析
所有SLI原始数据以TimescaleDB分片表存储,按tenant_id + file_path_hash哈希分片,支持TB级时序查询。关键查询示例:
SELECT time_bucket('1h', time) AS hour,
avg(success_rate) AS avg_success,
percentile_cont(0.99) WITHIN GROUP (ORDER BY p99_latency_ms)
FROM file_write_sli
WHERE time > now() - INTERVAL '30 days'
AND file_path LIKE '/snapshots/%'
GROUP BY hour ORDER BY hour; 