我们有一个服务有这种场景:
- Web 服务提供查询接口返回一个很大的数据集给客户端
这个数据集使用 Sets
结构进行保存,读取这个数据集使用 sscan
命令每次返回
100
条记录,直到读完为止。
- 后台计算脚本计算并更新数据集
计算结果先放到一个临时 key
中,等到计算完再通过 rename
覆盖正式的 key
。
最近,客户方反映获取的数据集偏小,正常应该是 20 多万条记录,结果只取到几万条记录。
我们怀疑是 Scan 过程中如果数据集被 rename
覆盖,结果可能是未定义的,见以下测试程序:
redis_rename_when_scan.js
"use strict"; const Redis = require('ioredis'); const async = require('async'); const _ = require('lodash'); const crypto = require('crypto'); const key_ = "test:set_"; const key = "test:set"; const min = 0; const max = 100000; const prefix_length = 20; let scan_round = 0; let write_round = 0; function add(client, value, callback) { if (value > max) { return callback(null); } crypto.randomBytes(parseInt(prefix_length / 2, 10), function(err, buffer) { if (err) { return callback(err); } // Test full random dataset //let prefix = buffer.toString('hex'); // Test fixed dataset //let prefix = _.pad('', prefix_length, '0'); // Test partial random dataset let prefix = _.pad('', prefix_length - 1, '0') + buffer.toString('hex')[0]; return client.sadd(key_, prefix + value, function(err) { if (err) { return callback(err); } value += 1; return add(client, value, callback); }); }); } function write(client) { if (!client) { client = new Redis(); } client.del(key_, function(err) { if (err) { throw err; } add(client, min, function(err) { if (err) { throw err; } client.rename(key_, key, function(err) { if (err) { throw err; } console.log("write#" + write_round + " DONE"); ++write_round; write(client); }); }); }); } function ready(client, callback) { if (!client) { client = new Redis(); } client.exists(key, function(err, exists) { if (err) { callback(err); } else if (exists) { callback(null); } else { setTimeout(function() { ready(client, callback); }, 100); } }); } function init(callback) { let client = new Redis(); client.del(key_, function(err) { if (err) { throw err; } client.del(key, function(err) { if (err) { throw err; } callback(); }); }); } function scan(client) { if (!client) { client = new Redis(); } ready(null, function() { let results = []; var cursor = 0; async.doWhilst(function(done) { client.sscan(key, cursor, 'COUNT', 100, function(err, values) { if (err) { throw err; } cursor = +values[0]; values[1].forEach(function(value) { results.push(value); }); // console.log("scan#" + scan_round + " scan " + results.length + " values"); setTimeout(done, 5); }); }, function() { return cursor > 0; }, function(err) { if (err) { throw err; } let count_error = false; if (results.length != (max - min + 1)) { if (results.length < (max - min + 1)) { console.error("scan#" + scan_round + " Expected " + (max - min + 1) + " values, Got " + results.length); count_error = true; } else { console.warn("scan#" + scan_round + " Expected " + (max - min + 1) + " values, Got " + results.length); } } if (!count_error) { results.sort((a, b) => parseInt(a.substring(prefix_length), 10) - parseInt(b.substring(prefix_length), 10)); let uniq_results = _.sortedUniq(results); if (uniq_results.length != results.length) { console.warn("scan#" + scan_round + " Expected 0 duplicated values, Got " + (results.length - uniq_results.length)); } let result_error = false; for (let i = 0; i <= (max - min); ++i) { if (parseInt(results[i].substring(prefix_length), 10) != (min + i)) { //console.log("results: " + results.toString()); console.error("scan#" + scan_round + " value#" + i + " Expected " + results[i].substring(0, prefix_length) + (min + i) + ", Got " + results[i]); result_error = true; break; } } if (!result_error) { console.log("scan#" + scan_round + " OK"); } } ++scan_round; scan(client); }); }); } init(function() { write(null); scan(null); });
测试表明,取决于数据集变化的幅度,变化越大的数据集,获取到的结果偏差越大。但是获取到的记录数终始相差不大, 10
万条记录偏差 +-200
左右,前面遇到的数据记录数急剧减少可能是其它问题引起,如:计算出的记录数确实是有剧减。
记录数偏差不大可能是因为 Redis
在存储 Sets
数据时是有排序的, scan
使用的
cursor
类似 offset
,所以上一个数据集的 cursor
用于查询下一个数据集时偏差也不会太大。
但是这始终是一个问题,一个稳健的系统不应该有未定义行为。一个可行的解决方案是不要使用 rename
,而是在正式的 key
中保存临时 key
,查询时先从正式的 key
取到真正保存数据的临时 key
,再从临时 key
中取数据,只要确保每次计算都使用不同的临时 key
就可以避免数据访问冲突导致的未定义行为。这样子改动后,Redis 中会保存多份临时数据,需要设置合理的过期的时间,避免占用大量内存。