redis에 만약 200 ~ 300개가 넘는 캐시 정보를 계속 request를 날리면 레이턴시가 발생할 가능성이 크기 때문에 이런경우에 mget, mset, pipeline 등 멀티키 명령어를 사용한다.

하지만 redis가 single mode일 때는 아무 상관이 없지만 cluster mode인경우에는 다음과 같은 오류를 발생 시킨다.

"CROSSSLOT Keys in request don't hash to the same slot"

무슨 오류일까?? 처음에 redis가 싱글모드로 돌고 있던 stage에서 테스트를 해서 정상적으로 멀티키 명령어가 잘 되는줄 알고 배포 했다가 라이브에서 위와 같은 오류가 발생했다...

너무 당황해서 바로 수정했다.

무엇이 문제 였을까? 하면서 문서를 찾아보니 redis에는 16384의 키 슬롯이 있는데 클러스터 모드인 경우 이 키 슬롯들이 각 node에 분할되기 때문에 멀티키 명령어를 사용할 수 없다고 한다.

만약 멀티키 명령어를 사용하고 싶다면 그 키들은 모두 같은 키 슬롯에 들어있어야 한다.

방법은 다음과 같다.

 

같은 키 슬롯에 데이터 넣기


동일한 키 슬롯에 저장하고 싶으면 강제로 지정하는 해야한다. 이때 저장하고자 하는 키들 앞에 {..}의 값을 붙여주고 저장하는 것이다. 예를 들어 저장하고자 하는 데이터에 {product}.products1, {product}.products2로 저장하면 이 두개는 동일한 키 슬롯에 포함되게 된다.

이는 aws elasticcache페이지에 상세히 나와있으니 참고하면 된다.

https://aws.amazon.com/ko/premiumsupport/knowledge-center/elasticache-crossslot-keys-error-redis/

 

ElastiCache 오류 "CROSSSLOT Keys in request don't hash to the same slot" 해결

이 오류는 키가 동일한 노드가 아니라, 동일한 해시 슬롯에 있어야 하기 때문에 발생합니다. 샤딩된 Redis ElastiCache 클러스터(클러스터 모드 활성화됨)에서 다중 키 작업을 구현하려면 키는 동일한 해시 슬롯으로 해시되어야 합니다. 해시 태그를 사용하여 키를 동일한 해시 슬롯에 강제로 배치할 수 있습니다. 이 예제에서는 "myset2" 및 "myset" 세트가 동일한 노드에 있습니다. 이 오류를 해결하려면 해시 태그를 사용하여 키를 동일한 해시

aws.amazon.com

하지만 여기서 중요한건, 우리가 클러스터 모드를 사용하는 이유는 한군데이 집중시키지 않고 값을 분산시켜서 부하를 막기 위해서인데 이를 하나의 키 슬롯에 데이터를 모두 집어 넣을 경우 의미가 없어진다.

그럼 키를 각 노드마다 분산시키면서 멀티키 명령어를 사용할 수 있는 방법이 있을까?

 

클러스터 모드에서 각 노드별 key slot에 데이터 삽입하여 멀티 명령어 사용하기


생각을 바꿔보았다. 싱글모드에서는 키 슬롯 16384개가 존재하고 mset, mget, pipeline등의 명령어가 사용 가능했다. 

그럼 각 노드를 하나의 싱글모드의 레디스라고 생각하면 어떨까? 하는 생각으로 전략을 바꿔봤다.

처음에는 가능할지 모르고 작업을 진행했는데 정상적으로 동작해서 기분이 좋았다.

#작업 순서 (명령어는 ioredis를 기준으로 작성하였다.)

1. 클러스터 모드에서 동작중인 master node에 대한 정보를 가져온다. (cluster mode에는 master 노드와 slave노드가 있는데 slave node는 단순 readonly이고 마스터 노드 값을 가지고 있기 때문에 가져올 필요 없다.)

const masterNodes = redis.nodes('master')

2. 각 노드별 key slot 현황을 확인하고 masterNode와 매핑한다. key slot 현황을 확인하기 위해서는 cluster slots 사용한다. ioredis에서는 redis.cluster('slots')

127.0.0.1:7001> cluster slots
1) 1) (integer) 0
   2) (integer) 4095
   3) 1) "127.0.0.1"
      2) (integer) 7000
   4) 1) "127.0.0.1"
      2) (integer) 7004
2) 1) (integer) 12288
   2) (integer) 16383
   3) 1) "127.0.0.1"
      2) (integer) 7003
   4) 1) "127.0.0.1"
      2) (integer) 7007
3) 1) (integer) 4096
   2) (integer) 8191
   3) 1) "127.0.0.1"
      2) (integer) 7001
   4) 1) "127.0.0.1"
      2) (integer) 7005
4) 1) (integer) 8192
   2) (integer) 12287
   3) 1) "127.0.0.1"
      2) (integer) 7002
   4) 1) "127.0.0.1"
      2) (integer) 7006
const slots = await redis.cluster('slots');

// 각 슬롯정보에 있는 ip, port정보를 확인하여 각 master와 슬롯의 범위를 정리한다.
{
 'ip' : ,
 'port' : ,
 'node' : masterNode[0],
 'slotStart' : 0
 'slotEnd': 4460
},
{
 'ip' : ,
 'port' : ,
 'node' : masterNode[1],
 'slotStart' : 4461
 'slotEnd': 8845
},
.
..
...
....

3. 찾고자하는 키들의 slot 정보를 정리하고 slot 범위에 맞는 오브젝트에 키 값을 배열에 정리한다. slot 정보를 뽑는 알고리즘이 CRC16이기 때문에 이를 이용해서 구할수도 있고 npm에 모듈로 나와있는게 있으니 활용해도 된다. (https://www.npmjs.com/package/cluster-key-slot)

{
 'ip' : ,
 'port' : ,
 'node' : masterNode[0],
 'slotStart' : 0
 'slotEnd': 4460,
 'keys': ['product::1', 'product::2', 'product::5'] // 범위가 0 ~ 4460에 속하고 masterNode[0]에 존재하는 키 슬롯에 저장되는 키들
},
{
 'ip' : ,
 'port' : ,
 'node' : masterNode[1],
 'slotStart' : 4461
 'slotEnd': 8845,
 'keys': ['product::4', 'product::3', 'product::6'] // 범위가 4461 ~ 8845에 속하고 masterNode[1]에 존재하는 키 슬롯에 저장되는 키들
},
.
..
...
....

4. 그리고 정리된 Object를 순회하면서 각 키에 있는 데이터를 조회하기 위한 pipeline을 생성하고 get명령어를 매핑하고 실행시킨다. 없는 데이터는 pipeline을 set으로 설정하고 exec 실행시킨다.

const objs = [{
 'ip' : ,
 'port' : ,
 'node' : masterNode[0],
 'slotStart' : 0
 'slotEnd': 4460,
 'keys': ['product::1', 'product::2', 'product::5']
},
{
 'ip' : ,
 'port' : ,
 'node' : masterNode[1],
 'slotStart' : 4461
 'slotEnd': 8845,
 'keys': ['product::4', 'product::3', 'product::6']
}];

for(const obj of objs) {
 const pipeline = obj.node.pipeline();
 
 obj.keys.forEach(keySlot => pipeline.get(key => pipeline.get);
 
 // 결과
 const ret = await keys.exec();
 
 //..... 없으면 없는 것들은 set로 pipeline 열어서 설정(예를 들어서 keys 배열에 1, 2번째 값이 없다고 가정)
 const setPipeline = obj.node.pipeline();
 
 pipeline.set(obj.keys[1]);
 pipeline.set(obj.keys[2]);
 
 await setPipeline.exec();
}

 

소스를 모두 다 적기에는 양도 많기 때문에 간단하게 내가 진행한 방식에 대한 설명만 적었다.

이 글을 보고 방법을 찾고자 하는 사람들에게 도움이 되지 않을수도 있지만  클러스터 모드에서 레디스의 멀티키 사용법에 대해 자료가 없어서 고민을 많이 했었기 때문에 작은 힌트라도 되었으면 좋겠다.

sequelize에서 조인을 할때는 include를 사용해서 다음과 같이 한다.


1
2
3
4
5
6
await This.User.findOne({
  attributes: ['id', ['name''userName']],
  include: [
    { model: this.Dept}
  ]
});
cs

물론 기존에 Model을 define할 때 연관관계를 설정을 해놓은 상태여야 하고 이렇게 할 경우에 나는 left join이 아니라 inner join으로 다음과 같이 되었다.


SELECT id, name as userName FROM User u inner join Dept d on u.userId = d.userId;

그래서 검색해서 알아보다보니 required 옵션을 부여하게 되면 정상적으로 left join이 된다고 알게되었다. 

그래서 붙이니 정상적으로 되었다.


1
2
3
4
5
6
await This.User.findOne({
  attributes: ['id', ['name''userName']],
  include: [
    { model: this.Dept, required: false}
  ]
});
cs


별개로 조인을 하려고 하는데 계속 서브쿼리가 만들어진다면 subquery:false 옵션을 주면 문제를 해결 할 수 있다.

sequelize는 마찬가지로 ORM을 사용하다보니 직접적으로 쿼리를 사용하는 것보다 정확하게 알지못하면 역시 개발속도도 늦어지고 문제가 많아지는 단점이 있다.


이번에는 sequelize를 사용하는데 조인할 때 테이블 이름이 갑자기 User에서 Users로 바뀌는 이슈가 발생했다.


이 이슈를 해결하기 위해서 sequelize Document를 검색했고 거기서 freeTableName 옵션을 발견했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const Bar = sequelize.define('bar', { /* bla */ }, {
  // don't add the timestamp attributes (updatedAt, createdAt)
  timestamps: false,
  // don't delete database entries but set the newly added attribute deletedAt
  // to the current date (when deletion was done). paranoid will only work if
  // timestamps are enabled
  paranoid: true,
 
  // don't use camelcase for automatically added attributes but underscore style
  // so updatedAt will be updated_at
  underscored: true,
  // disable the modification of table names; By default, sequelize will automatically
  // transform all passed model names (first parameter of define) into plural.
  // if you don't want that, set the following
  freezeTableName: true,
 
  // define the table's name
  tableName: 'my_very_custom_table_name',
  // Enable optimistic locking.  When enabled, sequelize will add a version count attribute
  // to the model and throw an OptimisticLockingError error when stale instances are saved.
  // Set to true or a string with the attribute name you want to use to enable.
  version: true
})
cs

설명 그대로 이름이 plural로 바뀌는 것을 방지 하고 singular로 사용할 수 있게 해주는 옵션이다.



node js에서 데이터를 stream을 사용하여 처리하고 pipe를 사용해서 계속해서 stream을 가지고 작업을 이어나갈 수 있다. 그런데 pipe를 통해서 작업을 진행하다 보니까 중간에 오류가 발생했을 때 try / catch 로는 정상적으로 처리하지 못하는 경우가 발생했다. 

나에 경우에는 에러가 발생했을 때 try / catch에서 잡히지 않아서 프로그램이 Unhandled Promise Rejections를 출력 하며 죽어버렸다.

그 예는 다음과 같이 request를 통해서 받은 이미지를 sharp 라이브러리를 통해서 이미지 크기를 변경하려고 할 때 발생했다.

1
try {
await request('https://image.toast.com/aaaaab/ticketlink/TKL_3/ion_main08061242.jpg').pipe(transformer).pipe(writeStream);
} catch (e) {
console.error(e);
}
cs


그래서 이를 처리하기 위해서 알아봤는데 각 파이프라인에서 발생하는 에러를 처리하기 위해서는 try/catch로만 잡을 수가 없다. 그래서 이를 해결하기 위해서 각 파이프 앞단에서 error 이벤트를 잡는 설정을 해줘야한다.


1
2
3
4
5
6
7
8
// 파일로 쓰기
await request('https://image.toast.com/aaaaab/ticketlink/TKL_3/ion_main08061242.jpg').on('error', function (e) {
  console.error(e);
}).pipe(transformer).on('error', function (e) {
  console.error(e);
}).pipe(writeStream).on('error', function (e) {
  console.error(e);
});
cs


하지만 이렇게만 하면 에러는 잡을 수 있어도 pipe에서 행이 걸리는 경우가 발생된다. 그래서 에러가 발생했을 때 행 걸리지 않고 다음 로직으로 정상적으로 처리되도록 하기 위해서는 this.emit('end')를 넣어줘야 한다.


1
2
3
4
5
6
7
8
9
10
11
// 파일로 쓰기
await request('https://image.toast.com/aaaaab/ticketlink/TKL_3/ion_main08061242.jpg').on('error', function (e) {
  console.error(e);
  this.emit('end');
}).pipe(transformer).on('error', function (e) {
  console.error(e);
  this.emit('end');
}).pipe(writeStream).on('error', function (e) {
  console.error(e);
  this.emit('end');
});
cs


참고

https://stackoverflow.com/questions/21771220/error-handling-with-node-js-streams


elasticsearch에서 query_string로 데이터 조회시에 쿼리문으로 ) 특수문자가 포함하여 조회했다. 하지만 다음과 같이 문제가 발생했다.


1
2
3
4
5
6
7
8
9
10
11
{
  "error": {
    "root_cause": [
      {
        "type": "parse_exception",
        "reason": "parse_exception: Encountered \" \")\" \") \"\" at line 1, column 11.\nWas expecting one of:\n    <EOF> \n    <AND> ...\n    <OR> ...\n    <NOT> ...\n    \"+\" ...\n    \"-\" ...\n    <BAREOPER> ...\n    \"(\" ...\n    \"*\" ...\n    \"^\" ...\n    <QUOTED> ...\n    <TERM> ...\n    <FUZZY_SLOP> ...\n    <PREFIXTERM> ...\n    <WILDTERM> ...\n    <REGEXPTERM> ...\n    \"[\" ...\n    \"{\" ...\n    <NUMBER> ...\n    "
      }
    ],
    "type": "search_phase_execution_exception",
    "reason": "all shards failed",
    "phase": "query",
cs


확인해보니 + - = && || > < ! ( ) { } [ ] ^ " ~ * ? : \ / 포함된 문장을 query_string 통해서 조회하려고 하면 에러를 발생시킨다. 그래서 이를 해결하기 위해서 위에 reserved character들이 들어간 단어는 \\를 붙여주어야 한다.


이를 위한 자바스크립트는 다음과 같다.

1
2
3
async escapeReservedCharacter(query) {
  return query.replace(/([!*+&|()<>[\]{}^~?:\-="/\\])/g, '\\$1');
}
cs


이를 해결해서 query_string을 사용하면 문제가 해결된다.


참고 : https://stackoverflow.com/questions/26431958/escaping-lucene-characters-using-javascript-regex




+ Recent posts