负时间戳怎样分桶:floorDiv 与整数除法的差别
用毫秒时间戳除以1000得到秒桶,时间戳为-1时却得到0。它应该落在[-1000,0)这一秒,而不是[0,1000)。Java整数除法向零截断,恰好把零以前的短尾推到了错误一侧。
只要输入跨过零,这个差别就不再是数学细节。相对时间轴、坐标网格和以纪元为基准的时间桶都可能出现负数。若业务根本不允许负偏移,例如分页offset,应直接拒绝;不能借floorDiv把无效分页参数“修成合理值”。
下面只讨论正桶宽度,固定半开区间:桶k覆盖[k乘width,(k+1)乘width)。这样的定义让边界属于右侧新桶,零前后可以使用同一条规则。
import java.util.*;
public class NegativeBuckets {
static long bucket(long value, long width) {
if (width <= 0) throw new IllegalArgumentException("positive width required");
return Math.floorDiv(value, width);
}
static long start(long value, long width) { return Math.multiplyExact(bucket(value, width), width); }
public static void main(String[] args) {
System.out.println("ordinary=" + (-1L / 1000));
System.out.println("floor=" + bucket(-1, 1000));
System.out.println("ordinary_remainder=" + (-1L % 1000));
System.out.println("floor_offset=" + Math.floorMod(-1L, 1000L));
Map<Long,Integer> counts = new TreeMap<>();
for (long time : new long[]{-1001, -1000, -1, 0, 999, 1000}) counts.merge(bucket(time, 1000), 1, Integer::sum);
System.out.println("buckets=" + counts);
System.out.println("start_minus_one=" + start(-1, 1000));
try { start(Long.MIN_VALUE, 1000); throw new AssertionError(); }
catch (ArithmeticException expected) { System.out.println("extreme_start=OVERFLOW"); }
}
}
保存为 NegativeBuckets.java,用JDK 17执行 java NegativeBuckets.java:
ordinary=0
floor=-1
ordinary_remainder=-1
floor_offset=999
buckets={-2=1, -1=2, 0=2, 1=1}
start_minus_one=-1000
extreme_start=OVERFLOW
-1 应该离哪一侧边界更近
向零截断给出商0,普通余数为-1;floorDiv给出商-1,正宽度下的floorMod给出999。后一组值可以解释为“从桶起点-1000向后999毫秒”,正好得到原始-1。
这比只记住一个公式更容易检查。无论输入是-1001、-1000还是-1,得到的桶都应该包含原值;恰好等于-1000的样本处在边界,不能被错误推到-2号桶。代码同时给出这几项计数,避免只有一个特殊负数通过。
不要为了得到正余数写 Math.abs(value) % width。绝对值改变了原坐标方向,最小负数的绝对值还有表示范围问题。商和余数应采用同一套规则,才能让桶编号、桶内偏移和原坐标互相对应。
桶编号能表示,边界乘法仍可能溢出
floorDiv返回合法long,不代表把它乘回width也一定在long范围内。Long.MIN_VALUE不是所有宽度的整倍数,所属数学区间的起点可能比最小long还小。
示例的start使用multiplyExact,将不可表示的边界明确拒绝。它保留了问题,而不是让普通乘法回绕出一个很大的正时间。若系统确实需要处理这类极端范围,可以用更大整数表示边界,或只返回桶编号与桶内偏移;不要假设换一种除法就替所有后续运算提供了溢出保证。
宽度也必须大于零。零没有定义,负宽度会改变floorMod的符号语义,无法直接继续套用本例“偏移在0到width之间”的解释。入口先限制正值,可以让后续不变量保持清楚。
日志窗口不是从零开始时,先处理基点
假设设备日志以某次采样起点为基准,每一秒形成一个窗口,起点恰好位于纪元后的500毫秒。此时不能直接用原时间戳除以1000,应先计算相对基点的偏移。时间戳-1属于相对编号-1的桶,绝对区间是[-500,500),桶内偏移499。
基点转换又带来一项独立溢出:value - origin 可能已经超出long,根本还没走到floorDiv。下面的 OriginBuckets.java 用BigInteger完整表示数学区间,并手动把它默认向零的商余数调整为向下的规则。保存后运行 java OriginBuckets.java:
import java.math.BigInteger;
public class OriginBuckets {
record Span(BigInteger index, BigInteger start, BigInteger end, BigInteger offset) {}
static Span locate(long value, long origin, long width) {
if (width <= 0) throw new IllegalArgumentException("positive width required");
BigInteger v = BigInteger.valueOf(value), o = BigInteger.valueOf(origin);
BigInteger w = BigInteger.valueOf(width);
BigInteger[] qr = v.subtract(o).divideAndRemainder(w);
if (qr[1].signum() < 0) { qr[0] = qr[0].subtract(BigInteger.ONE); qr[1] = qr[1].add(w); }
BigInteger start = o.add(qr[0].multiply(w));
return new Span(qr[0], start, start.add(w), qr[1]);
}
static void verify(long value, Span s) {
BigInteger v = BigInteger.valueOf(value);
if (v.compareTo(s.start()) < 0 || v.compareTo(s.end()) >= 0
|| !s.start().add(s.offset()).equals(v)) throw new AssertionError("bucket invariant");
}
public static void main(String[] args) {
Span shifted = locate(-1,500,1000);
verify(-1,shifted);
System.out.println("shifted=" + shifted);
Span extreme = locate(Long.MIN_VALUE,Long.MAX_VALUE,1000);
verify(Long.MIN_VALUE,extreme);
System.out.println("extreme=" + extreme);
try { Math.subtractExact(Long.MIN_VALUE,Long.MAX_VALUE); }
catch (ArithmeticException expected) { System.out.println("long origin subtraction=OVERFLOW"); }
try { locate(1,0,0); }
catch (IllegalArgumentException expected) { System.out.println("zero width=REJECTED"); }
for (long value : new long[]{-501,-500,-1,0,499,500,1499,1500}) verify(value,locate(value,500,1000));
System.out.println("shifted boundaries=verified");
}
}
shifted=Span[index=-1, start=-500, end=500, offset=499]
extreme=Span[index=-18446744073709552, start=-9223372036854776193, end=-9223372036854775193, offset=385]
long origin subtraction=OVERFLOW
zero width=REJECTED
shifted boundaries=verified
普通移位样本核对编号、绝对起止和偏移,极端样本则验证区间仍然包含原值。它没有用普通减法回绕出的偏移继续计算,也没有要求数学起点一定还落在long范围内。若业务只支持正常历史日期,更合适的选择可能是先收窄日期范围,再用受检long运算;不必为了一个不会出现的领域值把所有计数都换成大整数。
BigInteger的divideAndRemainder与Math.floorDiv并不具有相同的默认负数规则,所以代码在余数为负时将商减一、余数加正宽度。只替换数值类型而不检查除法语义,会在新的实现里重新制造零前分桶错误。verify直接断言start不大于原值、end严格大于原值,且start加offset能还原原值。
日志聚合与查询窗口还必须采用相同基点。写入端从500毫秒起算,查询端却从零起算,即便双方都使用floorDiv,也会把同一事件归到不同标签。配置中应一起保存基点、宽度与单位,修改基点时决定是否需要重算历史桶,不能只发布一个新的除法表达式。
若跨服务只传桶编号,接收端缺少基点就无法恢复绝对区间;只传展示文字,又可能被时区格式化改变含义。可以在内部协议中保留明确数值字段,在展示层再生成日期标签。这个例子处理的是毫秒轴上的固定宽度,仍然没有把营业日、自然月或当地时钟变化折成同一种整数模型。
这不是本地日历日期的分桶算法
毫秒固定宽度适合绝对时间轴中的持续区间。业务里的某时区自然日、自然月或营业日不一定是固定毫秒长度,不能把一天硬写成86400000,再期待跨夏令时仍与当地日期一致。
如果输入是Instant而需求是某地自然日,应先按明确ZoneId转换成当地日期,再按日期聚合。若需求就是从某固定基点开始的每一秒,本例的整数分桶才更直接。两种算法都能返回一个数字,语义却不能互换。
跨语言实现也要核对除法规则。有些语言的整数除法默认向下,有些向零,直接复制 timestamp / width 可能只有正值样本一致。协议应该定义区间边界,并提供负值样本,而不是把某一种语言表达式当规格。
最后,展示桶区间时应继续用同一边界规则。计算使用floorDiv,展示却用普通除法或未经检查的乘法,报告仍会出现“编号正确但标签错误”。从输入、聚合到输出分别核对-1、0和整倍数,才能让零两边遵守同一个时间轴。