Split View: Redis 8.8의 Array 타입 — 스트림 이후 처음 늘어난 코어 자료구조, 그리고 Valkey에는 없는 것
Redis 8.8의 Array 타입 — 스트림 이후 처음 늘어난 코어 자료구조, 그리고 Valkey에는 없는 것
- 들어가며 — 엿새 간격으로 나온 두 릴리스
- 스트림 이후 처음 늘어난 코어 타입
- Array가 실제로 무엇인가
- 내부 표현 — 슬라이스, 포인터 태깅, 슈퍼 디렉터리
- 명령어 18개
- ARLEN과 ARCOUNT는 다른 것을 센다
- 벤더 벤치마크가 말하는 것과 말하지 않는 것
- 문서는 아직 preview라고 적어 두었다
- Valkey에는 없다
- 8.8의 나머지 — INCREX가 더 실용적일 수 있다
- AI로 만든 자료구조라는 사실
- 그래서, 당신은 써야 하나
- 마치며
- 참고 자료
들어가며 — 엿새 간격으로 나온 두 릴리스
2026년 5월, 같은 뿌리에서 갈라진 두 프로젝트가 거의 동시에 릴리스를 냈습니다. Valkey 9.1.0이 5월 19일, Redis 8.8.0이 5월 25일. 엿새 차이입니다.
두 릴리스 노트를 나란히 놓고 읽으면 온도 차가 분명합니다. Valkey 9.1.0은 스스로 "Upgrade urgency LOW"라고 적은 첫 9.1 안정 릴리스이고, 새 기능은 클러스터 버스 트래픽 메트릭 추가와 리해싱 중 지연 스파이크 완화 두 건, 나머지는 CVE 세 건과 버그 픽스입니다. Redis 8.8.0의 목록은 다른 성격입니다 — 그 맨 위에 이렇게 적혀 있습니다: "New data structure: Array (@antirez)".
포크 드라마 얘기를 하려는 게 아닙니다. 소스 트리에서 확인되는 구체적인 차이를 보려는 것입니다. 그리고 이 차이는 실제로 src/server.h 안에 한 줄로 존재합니다.
스트림 이후 처음 늘어난 코어 타입
Redis의 코어 객체 타입은 server.h에 상수로 박혀 있습니다. Redis 8.8.0 태그의 해당 부분은 이렇습니다.
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
#define OBJ_HASH 4 /* Hash object. */
#define OBJ_MODULE 5 /* Module object. */
#define OBJ_STREAM 6 /* Stream object. */
#define OBJ_ARRAY 7 /* Array object. */
OBJ_ARRAY 7이 새로 생긴 줄입니다. 그 앞의 OBJ_STREAM 6은 스트림이고, XADD의 커맨드 정의를 보면 since가 5.0.0입니다. 즉 코어 객체 타입이 늘어난 것은 2018년 Redis 5.0의 스트림 이후 이번이 처음입니다.
여기서 흔한 오해를 하나 짚고 갑니다. "벡터 셋이 8.0에 들어갔잖아?" — 맞지만 벡터 셋은 코어 타입이 아닙니다. VADD의 정의는 modules/vector-sets/commands.json에 있고 그룹은 vector_set이며, 구현은 modules/vector-sets/hnsw.c입니다. 즉 Redis 트리 안에 함께 배포되는 모듈이지 t_*.c 코어 타입이 아닙니다. 반면 Array는 src/t_array.c(약 73KB)와 src/sparsearray.c(약 80KB)로 코어에 직접 들어갔습니다.
Array가 실제로 무엇인가
공식 문서의 정의는 이렇습니다 — Array는 0부터 2⁶⁴−1 범위의 정수 인덱스를 문자열 값에 매핑하는 희소(sparse) 인덱스 주소 자료구조입니다. 리스트처럼 시퀀스상의 위치로 접근하는 게 아니라 인덱스로 직접 접근하고, 사이의 빈 칸을 할당하지 않고도 아무 인덱스나 세팅할 수 있습니다.
왜 필요한지는 antirez가 PR #15162의 본문에 직접 적어 뒀습니다. 그의 표현을 그대로 옮기면, 기존 타입들은 "위치 자체가 비즈니스 의미를 갖는" 경우에 다 어딘가 어긋납니다 — 해시는 랜덤 조회를 주지만 인덱스를 키로 따로 저장해야 하고 범위 가시성이 없으며, 리스트는 추가와 트리밍을 주지만 중간에 있는 것에 접근하기 어렵고, 스트림은 append-only 이벤트라는 또 다른 물건입니다. 슬롯 37번, 스텝 4번, 행 18552번, 파일 11번째 줄 — 이런 것들이 그가 든 예입니다.
정리하면 Array가 겨냥하는 자리는 이렇습니다.
- 인덱스가 곧 도메인 키인 데이터 (시간 슬롯, 좌석 번호, 행 번호, 스텝 번호)
- 값이 듬성듬성 존재하는 희소 데이터 (센서 결측치, 일부 행에만 붙는 주석)
- 최근 N개만 유지하는 링 버퍼
내부 표현 — 슬라이스, 포인터 태깅, 슈퍼 디렉터리
antirez가 PR 본문에 적은 인코딩 설명은 네 단계입니다.
- 조밀(dense)할 때 Array는 사실상 조금 더 화려한 C 배열입니다. 인덱스를 저장하는 비용을 따로 내지 않습니다.
- 다만 완전히 평평하게 가는 대신 4096개 원소 단위의 슬라이스로 쪼개고, 각 슬라이스는 원소가 몇 개 없을 때 특수한 희소 인코딩을 씁니다. 슬라이스가 비면 디렉터리에
NULL만 남습니다. - 작은 정수, 부동소수, 짧은 문자열은 포인터 태깅되어 포인터 슬롯 자체를 넘는 추가 메모리를 쓰지 않습니다.
- 아주 희소해지면 윈도잉된 디렉터리들의 슈퍼 디렉터리를 씁니다. 이 표현은 원소가 800만 개를 넘거나 아주 높은 인덱스가 세팅됐을 때만 발동합니다.
4번이 왜 있는지는 antirez의 개발기에 나옵니다. 그는 사용자가 ARSET myarray 293842948324 foo를 해도 거대한 할당 없이 동작하기를 원했는데, 처음 잡은 2단(디렉터리 + 슬라이스) 구조로는 부족하다는 걸 구현 중에 깨달았다고 씁니다. 그래서 특정 조건에서 자료구조가 내부적으로 모양을 바꿔 슬라이스된 조밀 디렉터리들의 슈퍼 디렉터리가 되도록 다시 설계했고, 그 목적은 ARSCAN이 구간 폭이 아니라 존재하는 원소 수에 비례하는 시간에 돌게 하는 것이었습니다.
여기서 정직하게 짚을 게 있습니다. 그 목표는 "전형적인 경우"에 대한 것이지 보장이 아닙니다. ARSCAN과 AROP의 공식 복잡도 표기는 이렇습니다 — "O(P) where P is visited positions in touched slices (dense scanned slots + sparse entries), with worst-case O(|end-start|+1) and typical case close to O(N)". 최악의 경우는 구간 폭에 비례합니다. 문서가 이 캐비엇을 스스로 적어 뒀다는 점이 오히려 신뢰할 만한 신호입니다.
명령어 18개
Redis 8.8.0 태그의 src/commands/에 있는 AR* 명령어는 정확히 18개입니다.
ARSET ARGET ARMSET ARMGET ARGETRANGE ARSCAN
ARDEL ARDELRANGE ARLEN ARCOUNT ARINFO
ARINSERT ARNEXT ARSEEK ARRING ARLASTITEMS
AROP ARGREP
(antirez의 개발기에는 ARPOP이 언급되지만 8.8.0의 최종 명령어 목록에는 없습니다. 글에 나온 이름을 그대로 믿지 말고 태그된 소스를 보는 편이 안전합니다.)
성격별로 묶으면 이렇습니다.
기본 읽기/쓰기. ARSET은 주어진 인덱스부터 연속된 값 여러 개를 씁니다. ARGET은 한 인덱스를 읽고, 세팅되지 않은 인덱스는 nil을 돌려줍니다. ARMSET과 ARMGET은 인덱스-값 쌍 여러 개를 한 번에 다룹니다. 여기까지는 모두 @fast입니다.
커서 계열. Array는 대체로 무상태지만 마지막으로 추가된 항목의 인덱스 하나만 기억합니다. ARINSERT는 그 커서 자리에 넣고 커서를 전진시키고, ARNEXT는 다음에 쓰일 인덱스를 알려 주고, ARSEEK는 커서를 원하는 자리로 옮깁니다.
링 버퍼. ARRING은 크기를 지정해 넣으면 넘칠 때 알아서 감고 잘라 냅니다. ARLASTITEMS는 최근에 들어온 N개를 돌려주고 REV로 순서를 뒤집습니다.
서버 사이드 집계. AROP은 구간에 대해 SUM, MIN, MAX, AND, OR, XOR, 그리고 MATCH와 USED를 수행합니다.
검색. ARGREP은 구간의 원소를 텍스트 술어로 훑습니다. 술어는 EXACT, MATCH, GLOB, RE 네 가지이고, 여러 술어를 AND 또는 OR로 묶을 수 있으며 LIMIT, WITHVALUES, NOCASE 옵션이 붙습니다.
문서에 있는 실제 동작 예를 옮기면 이렇습니다.
ARSET events:1 0 "login" "click" "purchase" -> 3
ARGET events:1 0 -> "login"
ARGET events:1 999 -> nil
ARRING readings 3 "v0" -> 0
ARRING readings 3 "v1" -> 1
ARRING readings 3 "v2" -> 2
ARRING readings 3 "v3" -> 0 # 크기 3을 넘겨 0번으로 되감김
ARGET readings 0 -> "v3" # 가장 오래된 v0을 덮어씀
ARLASTITEMS readings 3 -> ["v1", "v2", "v3"]
ARLASTITEMS readings 3 REV -> ["v3", "v2", "v1"]
ARLEN과 ARCOUNT는 다른 것을 센다
실무에서 제일 먼저 밟을 지뢰는 이것입니다. 문서의 예를 그대로 옮깁니다.
ARSET sparse 0 "a" -> 1
ARSET sparse 1000000 "b" -> 1
ARLEN sparse -> 1000001
ARCOUNT sparse -> 2
ARLEN은 "최대 인덱스 + 1"이고 ARCOUNT는 "비어 있지 않은 원소 수"입니다. 둘 다 O(1)이지만 의미가 전혀 다릅니다. 리스트의 LLEN 감각으로 ARLEN을 쓰면 원소 두 개짜리 Array에서 백만이 나옵니다. 희소 자료구조에서는 당연한 정의지만, 당연한 정의가 새벽 세 시에 사람을 잡습니다.
ARGETRANGE와 ARSCAN의 차이도 같은 결입니다 — ARGETRANGE seq 0 3은 빈 칸을 nil로 채워 ['a', 'b', None, 'd']를 주고, ARSCAN seq 0 3은 존재하는 것만 인덱스와 함께 줍니다.
벤더 벤치마크가 말하는 것과 말하지 않는 것
Redis가 8.8 발표 글에 올린 수치입니다. 전부 벤더 자체 측정이고, 조건은 Redis 8.8 단일 인스턴스, Intel Sapphire Rapids m7i.metal-24xl, 원소 10만 개입니다.
| 연산 (10만 원소, 1KB 값) | Array | List | Hash |
|---|---|---|---|
| 랜덤 원소 읽기 | 675K ops/sec | 133K ops/sec | 626K ops/sec |
| 랜덤 원소 쓰기 | 757K ops/sec | 137K ops/sec | 689K ops/sec |
| 랜덤 원소 삭제 | 841K ops/sec | — | 730K ops/sec |
Redis의 요약은 "랜덤 원소 연산에서 Array는 해시보다 8~15% 나은 처리량을 주고 리스트보다 최소 5배 빠르다"입니다. 링 버퍼 비교는 이렇습니다.
| 링 크기 / 원소 크기 | Array (ARRING) | List (RPUSH+LTRIM) |
|---|---|---|
| 1K / 100B | 1.11M inserts/sec | 512K inserts/sec |
| 100K / 100B | 1.12M inserts/sec | 528K inserts/sec |
| 1K / 1KB | 840K inserts/sec | 424K inserts/sec |
| 100K / 1KB | 837K inserts/sec | 413K inserts/sec |
이 표를 읽는 법을 정직하게 말하면 이렇습니다.
해시 대비 이득은 크지 않습니다. 8~15%는 벤더가 자기 신기능을 홍보하며 자기 하드웨어에서 잰 수치입니다. 이미 해시를 잘 쓰고 있다면 이 숫자만으로 마이그레이션할 이유가 못 됩니다.
리스트 대비 5배는 비교 대상이 불리한 것입니다. 리스트에서 "랜덤 원소 읽기"는 원래 O(N) 연결 리스트 순회입니다. 리스트가 애초에 하면 안 되는 일을 시켜서 5배가 나온 것이지, 리스트가 느린 게 아닙니다. ARRING의 2배는 그보다 공정한 비교입니다 — RPUSH+LTRIM 두 왕복을 한 원자적 명령으로 줄인 것이니 실제로 쓰는 패턴이 맞습니다.
메모리는 Array가 더 씁니다. 같은 글의 표에 따르면 100바이트 원소에서 원소당 Array 122바이트, List 104바이트, Hash 151바이트이고, 1KB 원소에서는 각각 1290 / 1035 / 1337바이트입니다. Redis 자신의 표현으로 Array는 리스트보다 원소당 약 18% 더 많은 메모리를 씁니다. 링 버퍼를 LPUSH+LTRIM에서 ARRING으로 바꾸면 처리량 2배를 얻는 대신 메모리를 그만큼 더 냅니다.
그리고 이 표에 없는 것들이 있습니다 — 희소한 경우의 메모리 수치, 클러스터 환경, 복제/AOF 부하, ARM에서의 수치. 발표 글은 x86 단일 인스턴스만 보여 줍니다.
문서는 아직 preview라고 적어 두었다
이게 가장 중요한 캐비엇인데 발표 글에는 없고 문서에만 있습니다. 공식 Array 문서 첫 줄은 이렇습니다.
Array is a new data type that is currently in preview and may be subject to change.
GA 릴리스에 실려 나왔지만 문서는 여전히 "미리보기이며 변경될 수 있음"이라고 못 박고 있습니다. 명령어 정의의 since는 전부 8.8.0이지만, 그것이 API 안정성 약속은 아니라는 뜻입니다. 프로덕션 데이터 모델을 여기에 얹기 전에 이 한 줄을 반드시 계산에 넣어야 합니다.
캐비엇이 하나 더 있습니다. ARGREP의 정규식을 위해 TRE 라이브러리가 deps/tre로 벤더링됐습니다(8.8.0의 deps/ 목록에서 확인됩니다). antirez가 TRE를 고른 이유는 병리적 패턴에서 시간/공간이 폭발하지 않는다는 보장 때문이고, 그는 foo|bar|zap 형태의 교대(alternation)가 비효율적이던 것을 직접 최적화하고 잠재적 보안 이슈 몇 개를 고쳤다고 적었습니다. 그래도 순수한 사실은 남습니다 — 서버에 새 네이티브 C 정규식 엔진이 들어왔고, 사용자 입력 패턴이 그 위에서 돕니다. PR에 붙은 자동 리뷰 봇도 이 지점을 "새로운 네이티브 코드 표면적"으로 지목했습니다.
Valkey에는 없다
이 글의 출발점으로 돌아갑니다. Valkey 9.1.0 태그의 src/server.h를 보면 객체 타입은 OBJ_STREAM 6에서 끝납니다. OBJ_ARRAY가 없습니다. src/에는 t_hash.c, t_list.c, t_set.c, t_stream.c, t_string.c, t_zset.c 여섯 개만 있고 t_array.c가 없습니다. src/commands/에 AR로 시작하는 명령어도 없습니다.
즉 2026년 5월 기준으로 Redis는 코어 타입 7개, Valkey는 6개입니다. 같은 코드베이스에서 갈라진 두 서버가 이제 다른 자료구조 집합을 갖습니다. 이것은 성능 튜닝이나 설정 차이가 아니라 데이터 모델의 차이이고, 방향이 한쪽입니다 — Array로 모델링한 데이터는 Valkey로 옮길 수 없습니다.
여기에 라이선스가 겹칩니다. Redis 8.8의 LICENSE.txt는 RSALv2 / SSPLv1 / AGPLv3 삼중 라이선스이고, Valkey의 COPYING은 SPDX-License-Identifier: BSD-3-Clause입니다. 이 배경은 오픈소스 라이선스 지형 변화 편에서 따로 다뤘습니다. 요점만 말하면, BSD로 남기 위해 Valkey를 골랐던 조직이라면 Array는 애초에 선택지에 없고, Array가 매력적이라면 그 라이선스를 받아들이는 결정을 같이 하는 것입니다. 기능 선택이 아니라 묶음 선택입니다.
8.8의 나머지 — INCREX가 더 실용적일 수 있다
Array가 헤드라인이지만, 많은 팀에게 8.8에서 당장 쓸모 있는 건 INCREX일 수 있습니다. 레이트 리미터는 지금까지 대부분 Lua 스크립트로 구현해 왔는데, 8.8이 이걸 명령어로 만들었습니다(구현자는 raffertyyu와 Redis 팀).
INCREX key
[<BYFLOAT|BYINT> increment]
[LBOUND lowerbound] [UBOUND upperbound] [SATURATE]
[EX sec | PX msec | EXAT unix-time-sec | PXAT unix-time-msec | PERSIST]
[ENX]
발표 글이 설명하는 핵심은 세 가지입니다. 첫째, 새 카운터 값과 실제 적용된 증가분을 함께 돌려주므로 호출자가 즉시 허용/거부를 판단할 수 있습니다. 둘째, ENX를 주면 키에 만료가 없을 때만 만료를 설정하므로 윈도의 TTL이 창 생성 시에만 잡히고 이후 요청에 밀려 연장되지 않습니다 — Lua 없이 하려다 다들 한 번씩 틀리는 그 부분입니다. 셋째, 경계를 넘기면 거부하고 SATURATE를 주면 경계에 붙여 부분 수용합니다. 레이트 리미팅 알고리즘 자체는 토큰 버킷과 슬라이딩 윈도 편에 정리해 뒀습니다.
이 밖에 8.8에는 스트림 컨슈머가 메시지를 명시적으로 반납하는 XNACK(SILENT/FAIL/FATAL 모드), 해시 필드 단위 알림, ZUNION/ZINTER의 COUNT 집계자, JSON.SET의 FPHA(부동소수 배열을 BF16/FP16/FP32/FP64 중 무엇으로 저장할지 지정)가 들어갔습니다.
AI로 만든 자료구조라는 사실
antirez는 개발기에서 이 타입을 어떻게 만들었는지 숨기지 않고 적었습니다. 1월 첫 주에 시작해 첫 달은 사양 문서만 손으로 썼고, 처음에는 Opus와 페어링하다가 GPT-5.3이 나온 뒤 설계와 개발을 Codex로 옮겼다고 합니다. 둘째 달부터 자동 코딩으로 구현하며 계속 리뷰했고, 앞서 말한 인디렉션 재설계가 이때 나왔습니다. 셋째 달은 스트레스 테스트였습니다.
그의 결론을 그대로 옮기면, 고품질 시스템 프로그래밍에서는 여전히 본인이 완전히 관여해야 하지만, AI 덕분에 평소라면 건너뛰었을 복잡도 수준까지 갈 수 있었다는 것입니다. 그리고 ARGREP은 계획에 없던 기능이었습니다 — 자료구조를 이리저리 써 보다가 마크다운 파일을 Array에 넣게 됐고, 에이전트용 스킬 지식 베이스가 필요하던 자기 상황과 맞아떨어져서 만들었다고 합니다.
PR 자체의 규모는 확인 가능한 숫자로 남아 있습니다 — 커밋 18개, 86개 파일, +22,258 / −39 라인. 5월 4일에 열려 5월 13일에 머지됐고, 12일 뒤 8.8.0 GA에 실렸습니다. 이걸 어떻게 평가할지는 각자의 몫이지만, 최소한 "AI로 만든 코드가 프로덕션 데이터베이스의 코어 자료구조로 들어간" 구체적이고 문서화된 사례라는 점은 기록해 둘 만합니다. 문서의 preview 딱지를 볼 때 이 맥락을 함께 놓고 보는 것도 합리적입니다.
그래서, 당신은 써야 하나
써서 값을 하는 경우
- 인덱스가 진짜 도메인 키다 — 시간 슬롯, 좌석/칸 번호, 행 번호, 워크플로 스텝. 지금 해시에 인덱스를 문자열 필드로 박아 쓰고 있다면 정확히 이 자리입니다.
- 링 버퍼를
RPUSH+LTRIM으로 돌리고 있고, 그 왕복과 비원자성이 실제로 아프다.ARRING이 한 명령으로 줄여 줍니다. - 구간 집계를 클라이언트로 다 끌어와서 하고 있다.
AROP이 서버에서 끝냅니다.
과잉이거나 위험한 경우
- push/pop이 필요하거나 원소 사이에 삽입해야 한다 → Redis 자신이 리스트를 쓰라고 말합니다.
- 숫자 인덱스가 아니라 필드 이름으로 접근한다 → 해시를 쓰라고 말합니다.
- 메모리가 빠듯하다 → 리스트보다 원소당 약 18% 더 냅니다.
- Valkey를 쓰거나 쓸 가능성이 있다 → 존재하지 않는 타입입니다.
- API 안정성이 필요하다 → 문서가 preview라고 적어 뒀습니다.
- 이미 잘 돌아가는 해시 기반 설계가 있다 → 벤더 벤치마크의 8~15%라는 숫자는 재작성 비용을 정당화하지 못합니다.
마치며
정리하면 이렇습니다. Redis 8.8은 스트림 이후 처음으로 코어 객체 타입을 하나 늘렸고, 그 타입은 인덱스가 의미를 갖는 데이터를 위한 희소 컨테이너입니다. 4096개 슬라이스와 포인터 태깅으로 조밀/희소 양쪽을 감당하고, 명령어 18개로 집계와 grep까지 서버에서 끝냅니다. 같은 달 나온 Valkey 9.1의 소스에는 이 타입이 없고, 앞으로도 데이터 모델 수준에서 두 서버는 갈라진 채로 갑니다.
동시에 이건 GA에 실렸지만 문서가 preview라고 적어 둔 타입이고, 벤더 벤치마크의 이득 대부분은 리스트에게 리스트가 못하는 일을 시켜서 나온 것이며, 해시 대비 실질 이득은 한 자릿수~십몇 퍼센트에 메모리를 더 내는 거래입니다.
그래서 결론은 "Redis에 좋은 게 생겼으니 쓰자"가 아닙니다. 지금 인덱스를 해시 키에 문자열로 박아 넣고 있거나 LPUSH+LTRIM 왕복을 감수하고 있다면, Array는 그 특정한 불편을 정확히 겨냥한 물건입니다. 그런 불편이 없다면 8.8에서 당신에게 중요한 건 Array가 아니라 INCREX이거나, 아무것도 아닐 수 있습니다. 새 자료구조는 새 망치이고, 못이 있는지부터 확인하는 게 순서입니다.
참고 자료
- Redis 8.8.0 릴리스 노트 (GitHub)
- Valkey 9.1.0 릴리스 노트 (GitHub)
- Implement the new Redis Array type — PR #15162 (antirez)
- Redis array type: short story of a long development — antirez 개발기
- Announcing Redis 8.8 — 벤더 발표 글, 벤치마크 수치 출처
- Redis arrays 공식 문서 — preview 표기와 명령어 18개
- TRE — ARGREP의 정규식 엔진
- 인메모리 캐싱 & KV 스토어 2026 비교 (관련 글)
- 오픈소스 라이선스 지형 변화 2026 (관련 글)
- 레이트 리미팅 알고리즘 — 토큰 버킷과 슬라이딩 윈도 (관련 글)
Redis 8.8's Array Type — The First New Core Data Structure Since Streams, and What Valkey Doesn't Have
- Introduction — Two Releases, Six Days Apart
- The First New Core Type Since Streams
- What Array Actually Is
- Internal Representation — Slices, Pointer Tagging, Super-Directories
- 18 Commands
- ARLEN and ARCOUNT Count Different Things
- What the Vendor Benchmark Says — and Doesn't
- The Docs Still Call It Preview
- Valkey Doesn't Have It
- The Rest of 8.8 — INCREX May Be More Practical
- Built by AI, on the Record
- So, Should You Use It?
- Closing
- References
Introduction — Two Releases, Six Days Apart
In May 2026, two projects that split from the same root shipped releases almost at the same time. Valkey 9.1.0 on May 19, Redis 8.8.0 on May 25. Six days apart.
Read the two release notes side by side and the difference in temperature is obvious. Valkey 9.1.0 is the first stable 9.1 release, and it describes itself as "Upgrade urgency LOW" — the new features are a cluster-bus traffic metric and a fix for a latency spike during rehashing, and the rest is three CVEs and bug fixes. Redis 8.8.0's list is a different animal — right at the top it reads: "New data structure: Array (@antirez)".
This isn't about fork drama. It's about a concrete difference you can confirm in the source tree. And that difference exists as a single line inside src/server.h.
The First New Core Type Since Streams
Redis's core object types are baked into server.h as constants. Here's the relevant section as of the Redis 8.8.0 tag.
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
#define OBJ_HASH 4 /* Hash object. */
#define OBJ_MODULE 5 /* Module object. */
#define OBJ_STREAM 6 /* Stream object. */
#define OBJ_ARRAY 7 /* Array object. */
OBJ_ARRAY 7 is the newly added line. The one above it, OBJ_STREAM 6, is the stream, and if you look at XADD's command definition, its since is 5.0.0. In other words, this is the first time the core object type count has grown since Streams in Redis 5.0, back in 2018.
There's a common misconception worth addressing here. "Didn't vector sets land in 8.0?" — true, but vector sets aren't a core type. VADD's definition lives in modules/vector-sets/commands.json, its group is vector_set, and the implementation is modules/vector-sets/hnsw.c. That is, it's a module shipped inside the Redis tree, not a t_*.c core type. Array, by contrast, went straight into the core as src/t_array.c (about 73KB) and src/sparsearray.c (about 80KB).
What Array Actually Is
The official docs define it this way — Array is a sparse index-addressed data structure that maps integer indices in the range 0 to 2⁶⁴−1 to string values. Unlike a list, you don't access it by sequential position; you access it directly by index, and you can set any index without allocating the empty slots in between.
antirez writes out the "why" himself in the body of PR #15162. In his own words, every existing type falls a bit short whenever "the position itself carries business meaning" — hashes give you random lookup but you have to store the index separately as a key, with no range visibility; lists give you appends and trimming but make it hard to reach into the middle; streams are a different animal entirely, append-only events. Slot 37, step 4, row 18552, line 11 of a file — those are the examples he gives.
Put together, here's the territory Array is aimed at:
- Data where the index is itself the domain key (time slots, seat numbers, row numbers, step numbers)
- Sparse data where values exist only here and there (missing sensor readings, annotations attached to only some rows)
- Ring buffers that keep only the most recent N items
Internal Representation — Slices, Pointer Tagging, Super-Directories
antirez's encoding write-up in the PR body breaks down into four layers.
- When dense, Array is essentially a fancier C array. It doesn't pay any separate cost to store the index itself.
- Rather than going fully flat, though, it's split into slices of 4096 elements each, and each slice uses a special sparse encoding when it holds few elements. When a slice is empty, only a
NULLremains in the directory. - Small integers, floats, and short strings are pointer-tagged, so they consume no extra memory beyond the pointer slot itself.
- When it gets very sparse, it uses a super-directory of windowed directories. This representation only kicks in once the element count crosses 8 million, or once a very high index has been set.
Why layer 4 exists is explained in antirez's dev log. He writes that he wanted ARSET myarray 293842948324 foo to work without a giant allocation even from a user, and that he realized mid-implementation that his original two-tier structure (directory + slices) wasn't enough. So he redesigned it so that, under certain conditions, the structure internally reshapes itself into a super-directory of sliced dense directories, with the goal of making ARSCAN run in time proportional to the number of elements that actually exist, not the width of the range.
There's something worth being honest about here. That goal is about the "typical case," not a guarantee. The official complexity notation for ARSCAN and AROP reads — "O(P) where P is visited positions in touched slices (dense scanned slots + sparse entries), with worst-case O(|end-start|+1) and typical case close to O(N)". The worst case is proportional to the width of the range. The fact that the docs write this caveat out themselves is, if anything, a signal you can trust.
18 Commands
There are exactly 18 AR* commands in src/commands/ as of the Redis 8.8.0 tag.
ARSET ARGET ARMSET ARMGET ARGETRANGE ARSCAN
ARDEL ARDELRANGE ARLEN ARCOUNT ARINFO
ARINSERT ARNEXT ARSEEK ARRING ARLASTITEMS
AROP ARGREP
(antirez's dev log mentions an ARPOP, but it isn't in the final command list for 8.8.0. Don't take a name mentioned in a blog post at face value — checking the tagged source is the safer move.)
Grouped by character, they break down like this.
Basic read/write. ARSET writes a run of consecutive values starting at a given index. ARGET reads one index, returning nil for an unset index. ARMSET and ARMGET handle multiple index-value pairs in one call. All of these are @fast.
The cursor family. Array is mostly stateless, but it remembers exactly one thing: the index of the last item appended. ARINSERT writes at that cursor position and advances the cursor, ARNEXT reports the index that will be used next, and ARSEEK moves the cursor to a chosen position.
Ring buffer. ARRING takes a size and automatically wraps and trims once it overflows. ARLASTITEMS returns the most recently added N items, and REV reverses the order.
Server-side aggregation. AROP runs SUM, MIN, MAX, AND, OR, XOR, plus MATCH and USED over a range.
Search. ARGREP scans elements in a range against a text predicate. There are four predicate kinds — EXACT, MATCH, GLOB, RE — multiple predicates can be combined with AND or OR, and LIMIT, WITHVALUES, and NOCASE options are available.
Here's an actual worked example from the docs.
ARSET events:1 0 "login" "click" "purchase" -> 3
ARGET events:1 0 -> "login"
ARGET events:1 999 -> nil
ARRING readings 3 "v0" -> 0
ARRING readings 3 "v1" -> 1
ARRING readings 3 "v2" -> 2
ARRING readings 3 "v3" -> 0 # exceeds size 3, wraps back to index 0
ARGET readings 0 -> "v3" # overwrites the oldest entry, v0
ARLASTITEMS readings 3 -> ["v1", "v2", "v3"]
ARLASTITEMS readings 3 REV -> ["v3", "v2", "v1"]
ARLEN and ARCOUNT Count Different Things
This is the first landmine you'll step on in practice. Here's the docs' own example, carried over as-is.
ARSET sparse 0 "a" -> 1
ARSET sparse 1000000 "b" -> 1
ARLEN sparse -> 1000001
ARCOUNT sparse -> 2
ARLEN is "max index + 1"; ARCOUNT is "number of non-empty elements." Both are O(1), but they mean entirely different things. Reach for ARLEN with the mental model of a list's LLEN and you'll get a million out of an Array holding two elements. It's an entirely reasonable definition for a sparse data structure — but a reasonable definition is exactly the kind of thing that catches people at three in the morning.
ARGETRANGE versus ARSCAN follows the same shape — ARGETRANGE seq 0 3 fills the gaps with nil, giving you ['a', 'b', None, 'd'], while ARSCAN seq 0 3 returns only what exists, paired with its index.
What the Vendor Benchmark Says — and Doesn't
These are the numbers Redis published in its 8.8 announcement post. All of them are vendor self-measured, on a single Redis 8.8 instance, Intel Sapphire Rapids m7i.metal-24xl, with 100,000 elements.
| Operation (100,000 elements, 1KB value) | Array | List | Hash |
|---|---|---|---|
| Random element read | 675K ops/sec | 133K ops/sec | 626K ops/sec |
| Random element write | 757K ops/sec | 137K ops/sec | 689K ops/sec |
| Random element delete | 841K ops/sec | — | 730K ops/sec |
Redis's own summary is "for random-element operations, Array delivers 8–15% better throughput than Hash and is at least 5x faster than List." Here's the ring-buffer comparison.
| Ring size / element size | Array (ARRING) | List (RPUSH+LTRIM) |
|---|---|---|
| 1K / 100B | 1.11M inserts/sec | 512K inserts/sec |
| 100K / 100B | 1.12M inserts/sec | 528K inserts/sec |
| 1K / 1KB | 840K inserts/sec | 424K inserts/sec |
| 100K / 1KB | 837K inserts/sec | 413K inserts/sec |
An honest reading of this table goes like this.
The gain over Hash isn't large. 8–15% is a number a vendor measured on its own hardware while promoting its own new feature. If you're already using Hash and it's working, that number alone isn't a reason to migrate.
5x over List is a lopsided comparison. "Random element read" on a List is, by nature, an O(N) linked-list traversal. That 5x comes from asking a List to do something it was never meant to do — it doesn't mean List is slow. The 2x on ARRING is a fairer comparison — it collapses two round trips of RPUSH+LTRIM into a single atomic command, which matches how people actually use this pattern.
Array uses more memory. According to the table in the same post, at 100-byte elements it's 122 bytes per element for Array, 104 for List, and 151 for Hash; at 1KB elements it's 1290 / 1035 / 1337 bytes respectively. In Redis's own words, Array uses about 18% more memory per element than List. Switch a ring buffer from LPUSH+LTRIM to ARRING and you get 2x the throughput in exchange for paying that much more memory.
And there are things missing from this table — memory figures for the sparse case, cluster environments, replication/AOF load, and numbers on ARM. The announcement post only shows a single x86 instance.
The Docs Still Call It Preview
This is the most important caveat, and it isn't in the announcement post — only in the docs. The first line of the official Array documentation reads:
Array is a new data type that is currently in preview and may be subject to change.
It shipped in a GA release, but the docs still flatly say "preview, and may be subject to change." Every command definition's since says 8.8.0, but that isn't a promise of API stability. Factor that one line into your calculus before you put a production data model on top of this.
There's one more caveat. To power ARGREP's regex, the TRE library has been vendored in as deps/tre (confirmable in 8.8.0's deps/ listing). antirez writes that he chose TRE for its guarantee against pathological time/space blowups on adversarial patterns, and that he personally optimized inefficient alternation (patterns like foo|bar|zap) and fixed a few potential security issues. Even so, the plain fact remains — a new native C regex engine has entered the server, and it runs directly on user-supplied patterns. The automated review bot attached to the PR flagged exactly this as "new native code surface."
Valkey Doesn't Have It
Back to where this post started. In Valkey 9.1.0's tagged src/server.h, the object types end at OBJ_STREAM 6. There is no OBJ_ARRAY. src/ has exactly six files — t_hash.c, t_list.c, t_set.c, t_stream.c, t_string.c, t_zset.c — and no t_array.c. There are no commands starting with AR in src/commands/ either.
Which means, as of May 2026, Redis has 7 core types and Valkey has 6. Two servers that forked from the same codebase now have different sets of data structures. This isn't a performance-tuning or configuration difference — it's a difference in the data model, and it runs one way: data modeled with Array cannot be moved to Valkey.
Licensing overlaps with this. Redis 8.8's LICENSE.txt is a triple license — RSALv2 / SSPLv1 / AGPLv3 — while Valkey's COPYING reads SPDX-License-Identifier: BSD-3-Clause. This background is covered separately in The 2026 Open Source License Shift. The short version: if your org picked Valkey to stay on BSD, Array was never on the table to begin with; and if Array is appealing to you, you're also signing up to accept that license. It isn't a feature choice — it's a bundle choice.
The Rest of 8.8 — INCREX May Be More Practical
Array is the headline, but for many teams, the thing in 8.8 that's immediately useful might be INCREX. Rate limiters have mostly been implemented with Lua scripts up to now, and 8.8 turns this into a command (implemented by raffertyyu and the Redis team).
INCREX key
[<BYFLOAT|BYINT> increment]
[LBOUND lowerbound] [UBOUND upperbound] [SATURATE]
[EX sec | PX msec | EXAT unix-time-sec | PXAT unix-time-msec | PERSIST]
[ENX]
The announcement post highlights three key points. First, it returns both the new counter value and the increment actually applied, so the caller can decide allow/deny immediately. Second, ENX sets an expiry only if the key doesn't already have one, so a window's TTL is pinned at window creation and doesn't keep getting pushed out by later requests — exactly the part everyone gets wrong at least once trying to do this in Lua by hand. Third, it rejects the increment if it would cross a bound, and with SATURATE it clamps to the bound and partially accepts instead. The rate-limiting algorithms themselves are covered in Rate Limiting Algorithms — Token Bucket and Sliding Window.
Beyond that, 8.8 also adds XNACK (with SILENT/FAIL/FATAL modes) for stream consumers to explicitly return a message, per-field notifications for hash fields, a COUNT aggregator for ZUNION/ZINTER, and FPHA for JSON.SET (specifying whether a float array is stored as BF16, FP16, FP32, or FP64).
Built by AI, on the Record
antirez doesn't hide how this type was built — he writes it out in his dev log. He started in the first week of January; the first month was spent hand-writing the spec document alone. He initially paired with Opus, then, once GPT-5.3 shipped, moved design and development to Codex. From the second month on, he implemented via automated coding while continuously reviewing, and the indirection redesign mentioned earlier came out of this period. The third month was stress testing.
His conclusion, in his own words, is that high-quality systems programming still requires him to be fully engaged, but that AI let him reach a level of complexity he would ordinarily have skipped. And ARGREP was never on the plan — it came from playing around with the data structure, stuffing markdown files into an Array, and it happened to line up with his own need for an agent skill knowledge base.
The scale of the PR itself is a matter of record — 18 commits, 86 files, +22,258 / −39 lines. It opened May 4 and merged May 13, and shipped in the 8.8.0 GA release 12 days after that. How you weigh this is up to you, but at minimum it's worth noting as a concrete, documented case of "AI-written code entering a production database as a core data structure." It's reasonable to keep this context in mind alongside the docs' preview label.
So, Should You Use It?
Here's how the decision breaks down.
When it earns its cost
- The index is genuinely a domain key — time slots, seat/berth numbers, row numbers, workflow steps. If you're currently stuffing the index into a Hash as a string field, this is exactly its spot.
- You're running a ring buffer with
RPUSH+LTRIM, and the round trips and non-atomicity actually hurt.ARRINGcollapses that into one command. - You're pulling range aggregation all the way to the client to do it there.
AROPfinishes it on the server.
When it's overkill or risky
- You need push/pop, or need to insert between elements → Redis itself tells you to use a list.
- You're accessing by field name rather than a numeric index → it tells you to use a hash.
- Memory is tight → it costs roughly 18% more per element than a list.
- You're using Valkey, or might → the type doesn't exist there.
- You need API stability → the docs say preview.
- You already have a hash-based design that works well → the vendor benchmark's 8–15% figure doesn't justify a rewrite.
Closing
To sum up: Redis 8.8 grew a core object type for the first time since Streams, and that type is a sparse container for data where the index carries meaning. It handles both dense and sparse cases through 4096-element slices and pointer tagging, and its 18 commands push aggregation and grep down onto the server itself. Valkey 9.1's source, released the same month, has no such type, and going forward the two servers stay split at the data-model level.
At the same time, this is a type that shipped in GA while its docs still call it preview, most of the vendor benchmark's gains come from asking a list to do something a list can't do well, and the real gain over Hash is a single-digit-to-teens percentage paid for with more memory.
So the conclusion isn't "Redis got something nice, let's use it." If you're currently stuffing an index into a hash key as a string, or living with LPUSH+LTRIM round trips, Array targets that exact discomfort. If you don't have that discomfort, what matters to you in 8.8 might be INCREX instead of Array — or nothing at all. A new data structure is a new hammer, and the first step is checking whether you actually have a nail.
References
- Redis 8.8.0 release notes (GitHub)
- Valkey 9.1.0 release notes (GitHub)
- Implement the new Redis Array type — PR #15162 (antirez)
- Redis array type: short story of a long development — antirez's dev log
- Announcing Redis 8.8 — vendor announcement post, source of the benchmark figures
- Redis arrays official docs — the preview label and the 18 commands
- TRE — the regex engine behind ARGREP
- In-memory Caching & KV Stores in 2026 (related post)
- The 2026 Open Source License Shift (related post)
- Rate Limiting Algorithms — Token Bucket and Sliding Window (related post)