Redis 浮點(diǎn)數(shù)累計(jì)實(shí)現(xiàn)

Redis 浮點(diǎn)數(shù)累計(jì)主要是有兩個(gè)命令
- INCRBYFLOAT 是 SET 指令的浮點(diǎn)數(shù)累計(jì)
- HINCRBYFLOAT 是 HASH 類(lèi)型的浮點(diǎn)數(shù)累計(jì)
在內(nèi)部 HINCRBYFLOAT 和 INCRBYFLOAT 自增實(shí)現(xiàn)相同。所以我們分析 INCRBYFLOAT 即可。
基本使用
直接使用指令。
INCRBYFLOAT mykey 0.1
INCRBYFLOAT mykey 1.111
INCRBYFLOAT mykey 1.111111使用 lua 腳本的方式,因?yàn)?redis 可以通過(guò) lua 腳本來(lái)保證操作的原子性,所以當(dāng)我們同時(shí)操作多個(gè) key 的時(shí)候一般使用 lua 腳本的方式。
eval "return redis.call('INCRBYFLOAT', KEYS[1], ARGV[1])" 1 mykey1 "1.11"
eval "return redis.call('INCRBYFLOAT', KEYS[1], ARGV[1])" 1 mykey1 "1.11111"
eval "return redis.call('INCRBYFLOAT', KEYS[1], ARGV[1])" 1 mykey1 "1.11111"INCRBYFLOAT 可表示范圍
按照官方文檔的說(shuō)法 INCRBYFLOAT 可以表示小數(shù)位 17 位。比如按照 jedis 的 api 來(lái)說(shuō),我們能夠使用的就是在 double 的精度范圍內(nèi),也就是 15-16位。這里我也看了 redis 的源碼,他在底層實(shí)現(xiàn)是通過(guò) c 語(yǔ)言的 long double 類(lèi)型來(lái)進(jìn)行計(jì)算的。
void incrbyfloatCommand(client *c) {
long double incr, value;
robj *o, *new;
o = lookupKeyWrite(c->db,c->argv[1]);
if (checkType(c,o,OBJ_STRING)) return;
if (getLongDoubleFromObjectOrReply(c,o,&value,NULL) != C_OK ||
getLongDoubleFromObjectOrReply(c,c->argv[2],&incr,NULL) != C_OK)
return;
value += incr;
if (isnan(value) || isinf(value)) {
addReplyError(c,"increment would produce NaN or Infinity");
return;
}
new = createStringObjectFromLongDouble(value,1);
if (o)
dbReplaceValue(c->db,c->argv[1],new);
else
dbAdd(c->db,c->argv[1],new);
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_STRING,"incrbyfloat",c->argv[1],c->db->id);
server.dirty++;
addReplyBulk(c,new);
/* Always replicate INCRBYFLOAT as a SET command with the final value
* in order to make sure that differences in float precision or formatting
* will not create differences in replicas or after an AOF restart. */
rewriteClientCommandArgument(c,0,shared.set);
rewriteClientCommandArgument(c,2,new);
rewriteClientCommandArgument(c,3,shared.keepttl);
}源碼地址:https://github.com/redis/redis/blob/unstable/src/t_string.c long double 是 c 語(yǔ)言的長(zhǎng)雙精度浮點(diǎn)型,在 x86 的 64 位操作系統(tǒng)上占通常占用 16 字節(jié)(128 位),相較于 8 字節(jié)的 double 類(lèi)型具有更大的范圍和更高的精度。(這部分來(lái)源于 chatgpt) 因?yàn)?redis 采用的 long double 類(lèi)型來(lái)做浮點(diǎn)數(shù)計(jì)算, 所以 redis 就可以保證到小數(shù)點(diǎn)后 17 位的精度。 整數(shù)位也可以表示 17 位 redis 的浮點(diǎn)數(shù)計(jì)算通常情況下會(huì)丟失精度嗎? 通常情況下是不會(huì)的,但是不能保證一定不會(huì)。
浮點(diǎn)數(shù)范圍測(cè)試
測(cè)試代碼如下:
public class RedisIncrByFloatTest {
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
BigDecimal decimalIncr = java.math.BigDecimal.ZERO;
String key = "IncrFloat:Digit100";
//測(cè)試精度
test_accuracy(jedis, decimalIncr, key);
//測(cè)試正浮點(diǎn)數(shù)最大值
test_max_positive_float(jedis, decimalIncr, key);
jedis.disconnect();
jedis.close();
}
private static void test_max_positive_float(Jedis jedis, BigDecimal decimalIncr, String key) {
jedis.del(key);
String value = "99999999999999999.00000000000000003";
List<String> evalKeys = Collections.singletonList(key);
List<String> evalArgs = Collections.singletonList(value);
String luaStr = "redis.call('INCRBYFLOAT', KEYS[1], ARGV[1]) return redis.call('GET', KEYS[1])";
Object result = jedis.eval(luaStr, evalKeys, evalArgs);
decimalIncr = decimalIncr.add(new BigDecimal(value));
BigDecimal redisIncr = new BigDecimal(String.valueOf(result));
value = "0.99999999999999996";
evalKeys = Collections.singletonList(key);
evalArgs = Collections.singletonList(value);
luaStr = "redis.call('INCRBYFLOAT', KEYS[1], ARGV[1]) return redis.call('GET', KEYS[1])";
result = jedis.eval(luaStr, evalKeys, evalArgs);
decimalIncr = decimalIncr.add(new BigDecimal(value));
redisIncr = new BigDecimal(String.valueOf(result));
boolean eq = comparteNumber(redisIncr, decimalIncr);
if (eq) {
System.out.println("累計(jì)結(jié)果正確, 整數(shù)位: " + 17 + "位, 結(jié)果期望值: decimalIncr " + decimalIncr.toPlainString() + ", 目標(biāo)值(redis):" + redisIncr.toPlainString());
} else {
System.out.println("累計(jì)結(jié)果不正確, 整數(shù)位: " + 17 + "位, 期望值: decimalIncr " + decimalIncr.toPlainString() + ", 目標(biāo)值(redis):" + redisIncr.toPlainString());
}
}
private static void test_accuracy(Jedis jedis, BigDecimal decimalIncr, String key) {
jedis.del(key);
for (int i = 16; i < 30; i++) {
String value = createValue(i);
final List<String> evalKeys = Collections.singletonList(key);
final List<String> evalArgs = Collections.singletonList(value);
String luaStr = "redis.call('INCRBYFLOAT', KEYS[1], ARGV[1]) return redis.call('GET', KEYS[1])";
Object result = jedis.eval(luaStr, evalKeys, evalArgs);
decimalIncr = decimalIncr.add(new BigDecimal(value));
BigDecimal redisIncr = new BigDecimal(String.valueOf(result));
boolean eq = comparteNumber(redisIncr, decimalIncr);
if (eq) {
System.out.println("累計(jì)結(jié)果正確, 整數(shù)位: " + i + "位, 結(jié)果期望值: decimalIncr " + decimalIncr.toPlainString() + ", 目標(biāo)值(redis):" + redisIncr.toPlainString());
} else {
System.out.println("累計(jì)結(jié)果不正確, 整數(shù)位: " + i + "位, 期望值: decimalIncr " + decimalIncr.toPlainString() + ", 目標(biāo)值(redis):" + redisIncr.toPlainString());
break;
}
}
}
private static String createValue(int i) {
String result = "9" + "0".repeat(Math.max(0, i - 1));
return result + ".00000000000000003";
}
private static boolean comparteNumber(BigDecimal redisIncr, BigDecimal decimalIncr) {
return decimalIncr.compareTo(redisIncr) == 0;
}
}輸出結(jié)果:
累計(jì)結(jié)果正確, 整數(shù)位: 16位, 結(jié)果期望值: decimalIncr 9000000000000000.00000000000000003, 目標(biāo)值(redis):9000000000000000.00000000000000003
累計(jì)結(jié)果正確, 整數(shù)位: 17位, 結(jié)果期望值: decimalIncr 99000000000000000.00000000000000006, 目標(biāo)值(redis):99000000000000000.00000000000000006
累計(jì)結(jié)果不正確, 整數(shù)位: 18位, 期望值: decimalIncr 999000000000000000.00000000000000009, 目標(biāo)值(redis):999000000000000000
累計(jì)結(jié)果正確, 整數(shù)位: 17位, 結(jié)果期望值: decimalIncr 99999999999999999.99999999999999999, 目標(biāo)值(redis):99999999999999999.99999999999999999INCRBYFLOAT 導(dǎo)致精度丟失
INCRBYFLOAT 導(dǎo)致精度丟失有兩種情況:
- 累計(jì)的范圍值超過(guò) INCRBYFLOAT 所能表示的最大精度范圍,在 double 范圍內(nèi)。
INCRBYFLOAT 底層計(jì)算是通過(guò)long double 來(lái)計(jì)算的在 C語(yǔ)言中 long double占用128 位,其范圍為: 最小值: ±5.4×10^-4951 最大值: ±1.1×10^4932 能表示的有效數(shù)字在34~35位之間。
- 我們使用類(lèi)似 jedis 的 api 提供的是 double 類(lèi)型的參數(shù),可能在調(diào)用之前,參數(shù)轉(zhuǎn)換的過(guò)程就發(fā)生了精度問(wèn)題。比如
StringRedisTemplate template = new StringRedisTemplate();
template.opsForValue().increment("v1", 1.3D);在 RedisTemplate 的這個(gè) increment 接受的參數(shù)類(lèi)型就是一個(gè) double 所以會(huì)發(fā)生精度問(wèn)題
C 語(yǔ)言長(zhǎng)雙精度類(lèi)型
因?yàn)?redis 底層采用的是long double 計(jì)算,所以這個(gè)問(wèn)題轉(zhuǎn)化為長(zhǎng)雙精度(long double)為什么沒(méi)有精度問(wèn)題? 這是因?yàn)?long double 具有更大的范圍和更高的精度。long double 的范圍和精度高于 double 類(lèi)型:
- 范圍更大:long double 可以表示更大和更小的數(shù)字
- 精度更高:long double 可以表示的有效數(shù)字多于 double 類(lèi)型這意味著,對(duì)于同樣的浮點(diǎn)計(jì)算,long double 具有更少的舍入誤差。
具體來(lái)說(shuō),幾點(diǎn)原因造成 long double 沒(méi)有精度問(wèn)題:
- long double 使用更多的bit位來(lái)表示浮點(diǎn)數(shù)。
- long double 使用四舍五入(rounding to nearest)而不是銀行家舍入(bankers' rounding),導(dǎo)致更少的誤差累加。
- 許多編譯器及 CPU 針對(duì) long double 具有優(yōu)化, 會(huì)生成精度更高的機(jī)器碼來(lái)執(zhí)行 long double 計(jì)算。
- long double 內(nèi)部采用更大的指數(shù)域, 能更準(zhǔn)確地表示相同范圍內(nèi)的數(shù)字。
綜上,long double 的更廣范圍和更高精度,讓它在相同的浮點(diǎn)計(jì)算中具有更少的舍入誤差。這也就解釋了為什么 long double 沒(méi)有明顯的精度問(wèn)題,因?yàn)樗焐褪菫榱颂峁└呔榷O(shè)計(jì)的。相比之下,double 使用的位數(shù)相對(duì)有限,即使采用折中舍入法,在一些場(chǎng)景下它的誤差也可能累加顯著。所以總的來(lái)說(shuō),long double 之所以沒(méi)有精度問(wèn)題,主要還是源于其更大的范圍和更高的內(nèi)在精度。
問(wèn)題總結(jié)
- Redis 浮點(diǎn)數(shù)累計(jì)操作 INCRBYFLOAT 不適合精度要求比較高的金額計(jì)算。
- Redis 浮點(diǎn)數(shù)累計(jì)操作 INCRBYFLOAT 也不能平替 BigDecimal 計(jì)算,如果一定需要存儲(chǔ)可以考慮通過(guò) lua 腳本實(shí)現(xiàn) CAS 進(jìn)行修改,最終存儲(chǔ)為 String 類(lèi)型的一個(gè)結(jié)果。
- Redis 的浮點(diǎn)數(shù)雖然做了比較好的優(yōu)化,但是沒(méi)有從根本解決計(jì)算精度問(wèn)題。
參考文檔
- https://redis.io/commands/incrbyfloat/。
- https://wiki.c2.com/?BankersRounding。
- https://www.wikihow.com/Round-to-the-Nearest-Tenth。
- https://learn.microsoft.com/zh-cn/cpp/c-language/type-long-double?view=msvc-170。
- https://learn.microsoft.com/zh-cn/cpp/c-runtime-library/reference/strtold-strtold-l-wcstold-wcstold-l?view=msvc-170。




























