Mapeamento e Identificação de Arquivos Duplicados em Discos Externos e SSD Secundário

0. O Caso

Meu problema era que tenho nos HDs externos da máquina listada abaixo uma quantidade gigantesca de backups sobrepostos e arquivos repetidos várias vezes. A necessidade era criar uma estratégia para estudar, identificar/mapear e depois consolidar esses backups, eliminando as redundâncias o máximo possível, ou seja: identificar arquivos duplicados nos discos de backup e no SSD secundário (/home/rubens/SSD1TB), calcular o espaço desperdiçado e preparar dados para limpeza seletiva.

Sistema: Linux Mint 22.2

Interface: Cinnamon

Hostname: NitroX

Data: 28/10/2025

(pra obter os dados da máquina rode o neofetch, para os discos listados abaixo rode: lsblk -o NAME,SIZE,TYPE,MOUNTPOINT)

NAME SIZE TYPE MOUNTPOINT

sda 931,5G disk
└─sda1 931,5G part /media/rubens/MIDIAS
sdb 931,5G disk
└─sdb1 931,5G part /media/rubens/CA2B-4277
sdc 465,8G disk
└─sdc1 465,8G part
nvme0n1 476,9G disk
├─nvme0n1p1 512M part /boot/efi
└─nvme0n1p2 476,4G part /
nvme1n1 931,5G disk
└─nvme1n1p1 931,5G part /home/rubens/SSD1TB

1. Instalação das ferramentas de apoio

Provavelmente você não vai ter algumas das ferramentas (eu não tinha, sabe Deus por quê, mas as distros não trazem mais essas coisas por padrão), então foram instalados utilitários para detecção e análise de duplicatas:

sudo apt install fdupes rdfind pv

fdupes: lista arquivos duplicados comparando hashes e tamanhos.

rdfind: encontra duplicatas com heurística similar, útil em grandes volumes.

pv: exibe barra de progresso em pipelines longos. (Isso aqui é muito importante, eu só me liguei depois de 10 horas rodando o find)

2. Geração do mapa de hashes

Como eu tenho MUITO backup repetido e um medo do cão de apagar alguma coisa importante, o único jeito de ter certeza das coisas é pelo hash dos arquivos. O primeiro passo foi gerar um mapa completo dos arquivos (SHA1 + caminho):

find /media/rubens /home/rubens/SSD1TB -type f -exec sha1sum "{}" + > ~/mapa_hashes.txt

2.1 Mas tá rodando?

Claro que eu sabia que esse find podia levar várias horas, mas acho que era o melhor jeito de gerar uma “assinatura” única de cada arquivo. Eu coloquei pra rodar e fui dormir, quando eu acordei ainda estava rodando, daí bate aquela insegurança sobre o que está mesmo acontecendo, aí eu abri o Btop++, programinha lindo que gera essa tela aqui:

No detalhe dá pra ver...

... que o find já estava rodando a 7h08m58s, tinha lido 113 Gb e gravado 95.8 Mb. Uma thread só, rodando bem na moral.

2.2 Tá rodando. Mas dá pra acompanhar em tempo real?

Dá! :-D

Tem que instalar iotop, dstat e sysstat, que provavelmente não estão na sua máquina.

sudo apt update
sudo apt install iotop dstat sysstat

(O pacote sysstat inclui o comando iostat, útil para medições médias e logs históricos.)

sudo iotop -oPa

Onde:

-o: mostra apenas processos com I/O ativo.

-P: mostra processos agregados (não threads).

-a: acumula estatísticas de leitura e escrita.

Ideal para acompanhar se o find continua lendo arquivos.

sudo dstat -cdngy 5

Onde:

-c: CPU

-d: disco

-n: rede

-g: page cache

-y: system stats

Exemplo das minhas saídas pra este comando:

---total-usage---- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai stl| read writ| recv send| in out | int csw
4 1 89 6 0| 65M 133k|1349B 1192B| 0 0 |7395 14k
5 1 88 6 0| 65M 109k|3641B 3794B| 0 0 |7843 16k
5 1 88 6 0| 65M 55k|2017B 2686B| 0 0 |8338 18k
3 1 90 6 0| 65M 595k| 20k 26k|0.20 0 |6566 13k
3 1 90 6 0| 65M 57k|1319B 1595B| 0 0 |7436 16k
2 1 90 6 0| 66M 25k|2338B 2944B| 0 0 |7877 17k
2 1 90 6 0| 70M 454k|1360B 1238B| 0 0 |7287 15k
3 1 90 6 0| 51M 120k| 262B 277B| 0 0 |8242 18k
3 1 90 6 0| 31M 31k|1207B 1877B| 0 0 |7628 17k
2 1 90 6 0| 30M 58k|4378B 8241B| 0 0 |6375 15k
1 1 92 6 0| 30M 574k|2627B 3976B|0.80 0 |4502 8858

Onde:

----total-usage---- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai stl| read writ| recv send| in out | int csw

usr/sys → uso de CPU em modo usuário e sistema.

idl → percentual de CPU ocioso.

wai → tempo que a CPU está esperando o disco (I/O wait).

read/writ → taxa de leitura e escrita em disco.

recv/send → tráfego de rede.

paging → uso de swap/paginação.

int/csw → interrupções e trocas de contexto (normal em operações intensivas de I/O).

2 1 91 6 0| 26M 38k| 387B 423B| 0 0 |6728 15k

Onde:

CPU:

2 % em modo usuário, 1 % em modo sistema → apenas 3 % de uso total.

91 % ocioso.

6 % em I/O wait, ou seja, a CPU está esperando o disco — exatamente o comportamento desejado para operações de leitura em massa.

Disco:

read variando entre 25 MB/s e 70 MB/s — taxa muito boa e estável para HDs externos mecânicos via USB 3.0.

write baixíssimo (poucos KB/s), porque o sha1sum só escreve hashes no terminal.

Rede:

recv/send sempre abaixo de 10 KB/s → quase nada.

Confirma que o trabalho é local, sem nenhuma comunicação remota.

Paging e system:

in/out = 0 → sem swap.

int (interrupções) e csw (context switches) normais e estáveis (~7 000–15 000/s), o que é o comportamento típico de I/O intenso.

Mas o qye importa é que se read estiver variando (ex: 90M–120M), o find está ativo e lendo os HDs corretamente.

iostat -xz 5

Eu deixei rodando um:

sudo iostat -xz 10

Num segundo terminal para ir acompanhando.

2.3 Arquivo gerado!

No final o processo levou 10h36m10s, leu 735Gb e escreveu um arquivo de 160Mb. O arquivo resultante (mapa_hashes.txt) contém linhas no formato:

<hash> <caminho/do/arquivo>

O comando abaixo vai dizer o tamanho e mostrar o head (início) do arquivo. Mude o número pra ver mais que cinco linhas.

ls -lh ~/mapa_hashes.txt
head -n 5 ~/mapa_hashes.txt

3. Ordenação e identificação de duplicatas

Aí eu achei que era uma boa ideia padronizar o arquivo e identificar repetições. Em retrospectiva isso foi meio besta, mas...

Para otimizar comparações:

sort ~/mapa_hashes.txt -o ~/mapa_hashes_sorted.txt

Em seguida, extraí apenas as linhas com hashes repetidos:

uniq -w40 -D ~/mapa_hashes_sorted.txt > ~/mapa_hashes_duplicados.txt

Onde a opção -w40 diz ao uniq para comparar apenas os primeiros 40 caracteres de cada linha.

Ou seja, se duas linhas forem idênticas nos primeiros 40 caracteres, serão consideradas iguais, mesmo que o restante da linha seja diferente. No meu caso, como o arquivo contém hashes longos não tem problema comparar só uma parte (os 40 primeiros caracteres). Não é nenhuma aplicação de missão crítica,eu sobrevivo se tiver colisões e é mais rápido que verificar os hashes inteiros.

Verificação da quantidade de duplicatas detectadas:

wc -l ~/mapa_hashes_duplicados.txt

No final eu tinha 668.735 ocorrências de arquivos duplicados listadas e nenhuma linha inválida no relatório, ou seja, integridade OK. Agora vem a parte de medir o impacto e escolher como remover as cópias com segurança.

4. Agrupamento por hash e cálculo de espaço desperdiçado

Para agrupar as duplicatas por hash e gerar um relatório legível:

awk '{print $1}' ~/mapa_hashes_duplicados.txt | uniq | while read hash; do
echo "==== $hash ====" >> ~/duplicatas_por_hash.txt
grep "$hash" ~/mapa_hashes_duplicados.txt >> ~/duplicatas_por_hash.txt
echo "" >> ~/duplicatas_por_hash.txt

O arquivo duplicatas_por_hash.txt mostra cada grupo duplicado, separado por \==== <hash> ====. (Essa contra barra, antes dos três iguais, está ali como caracter de escape, porque estou usando o Obsidian e não quero disparar o Dataview)

Esse comando foi outro que levou um tempão pra rodar, como dá pra ver na tela no Btop++ abaixo:

Eu sei, meu loop está ineficiente e poderia ser otimizado bastante. Por isso eu fiquei me coçando pra estimar quanto tempo ia demorar pra acabar.

4.1 E eu podia pelo menos ter usado o pv

Isso não ia resolver a ineficiência do meu loop, mas me daria um ETA (Estimated Time of Arrival) ;-)

total=$(awk '{print $1}' ~/mapa_hashes_duplicados.txt | uniq | wc -l)
awk '{print $1}' ~/mapa_hashes_duplicados.txt | uniq | \
pv -l -s "$total" | \
while read -r hash; do
echo "==== $hash ====" >> ~/duplicatas_por_hash.txt
grep "$hash" ~/mapa_hashes_duplicados.txt >> ~/duplicatas_por_hash.txt
echo "" >> ~/duplicatas_por_hash.txt
done

Ainda é o mesmo loop Bash que processa o arquivo ~/mapa_hashes_duplicados.txt e cria um relatório organizado por hash duplicado, registrando em ~/duplicatas_por_hash.txt, mas aqui temos:

Conta quantos hashes únicos duplicados existem: awk, uniq, wc

Cria uma barra de progresso durante o processamento: pv (real motivo pra usar esse código)

Para cada hash, escreve as linhas correspondentes agrupadas: grep, echo

Gera um relatório final agrupado e legível: > ~/duplicatas_por_hash.txt

4.2 Podia. Mas eu não usei. E nem interrompi o processo.

Eu podia ter interrompido o processo com um CRTL+C, mas eu sou muito muquirana com o tempo que eu já gastei pra jogar tudo fora. E eu já tinha o mapa_hashes_duplicados.txt que me dava o total de linhas, e o arquivo duplicatas_por_hash.txt ainda não estava pronto, mas já estava acessível no HD, então eu podia medir uma coisa contra a outra e ver se valia a pena interromper o processo e pensar numa coisa mais eficiente.

Ou seja, dava pra meter um one-liner pra fazer uma verificação de progresso final, mostrando quantos grupos de hashes já tinham sido processados em relação ao total esperado, desse jeito:

proc=$(grep -c '^==== ' ~/duplicatas_por_hash.txt 2>/dev/null || echo 0)
echo "$proc processados"

Meu resultado foi:

rubens@NitroX:~$ proc=$(grep -c '^==== ' ~/duplicatas_por_hash.txt) total=$(awk '{print $1}' ~/mapa_hashes_duplicados.txt | uniq | wc -l) echo "Processados: $proc de $total"
Processados: 140861 de 154663

Minha conclusão: Já tinha rodado mais de 96%, melhor esperar a conclusão, apesar da ineficiência, não?

5. Estimativa de espaço total desperdiçado

Depois eu percebi que com as informações que eu já tinha, dava pra fazer uma estimativa de espaço total que eu estou desperdiçando com duplicatas de backups. O cálculo está baseado no tamanho da primeira cópia válida de cada grupo.

Esse scriptzinho em awk (feito com a ajuda do ChatGPT, porque sinceramente eu tive a ideia mas não fazia ideia de como executar) resolveu minha curiosidade:

awk '
/^==== / {
if (count>1 && size>0) wasted += (count-1)*size
count=0; size=0; next
}
NF==2 && match($1,/^[0-9a-f]{40}$/) {
cmd="stat -c%s \"" $2 "\" 2>/dev/null"
cmd | getline sz
close(cmd)
if (sz>0 && size==0) size=sz
count++
}
END {
printf "Espaço total desperdiçado: %.2f GB\n", wasted/1024/1024/1024
}
' ~/duplicatas_por_hash.txt

RESULTADO:

Espaço total desperdiçado: 181.44 GB

DISCLAIMER: E eu acho que está errado. Acho que é bem mais. Esse é o problema de usar o ChatGPT se você não sabe o que você está fazendo, você fica vendido e qualquer loucura que ele falar você pode acabar acreditando. Foda.

6. Versão otimizada com barra de progresso

Eu pedi pra ele uns ajustes e tentei conferir, dessa vez, pensei em processar o grande volume de dados de uma forma visual, incluindo o pv no pipeline:

total_lines=$(wc -l < ~/mapa_hashes_sorted.txt)

pv -l -s "$total_lines" ~/mapa_hashes_sorted.txt | awk '
BEGIN{prev=""; n=0; wasted=0}
{
h=substr($0,1,40); path=substr($0,43)
if (h!=prev && prev!="") {
cmd="stat -c%s \"" first "\" 2>/dev/null"
cmd | getline sz; close(cmd)
if (sz>0 && n>1) wasted += (n-1)*sz
n=0
}
if (n==0) first=path
prev=h; buf[++n]=path
}
END{
if (prev!="") {
cmd="stat -c%s \"" first "\" 2>/dev/null"
cmd | getline sz; close(cmd)
if (sz>0 && n>1) wasted += (n-1)*sz
}
printf "Espaço total desperdiçado: %.2f GB\n", wasted/1024/1024/1024
}'

A barra de progresso (pv) exibe ETA, percentual e taxa de leitura. (Isso é muito legal)

O resultado manteve o mesmo valor final (~181 GB desperdiçados).

Pena que eu ainda não entendi tudo o que ele está fazendo. Minha compreensão aqui é parcial.

7. Preparação para ranking por pastas

Já que meu objetivo é remover as duplicatas, eu tive como próximo passo gerar um ranking de pastas mais “carregadas” de duplicatas. De novo fiz um script "vibe-code" usando o awk. O script foi iniciado com nível configurável de profundidade (LEVEL):

LEVEL=4
total_lines=$(wc -l < ~/mapa_hashes_duplicados_sorted.txt)

pv -l -s "$total_lines" ~/mapa_hashes_duplicados_sorted.txt | awk -v LEVEL="$LEVEL" '
function rootdir(p, a,i,out){
split(p,a,"/")
out=""
for (i=2; i<=LEVEL+1 && i in a; i++) out=out "/" a[i]
if (out=="") out="/"
return out
}
BEGIN{ FS=" "; prev=""; n=0 }
{
h=substr($0,1,40)
path=substr($0,43)
if (h!=prev && prev!="") {
cmd="stat -c%s \"" first "\" 2>/dev/null"
cmd | getline sz; close(cmd)
if (sz>0 && n>1) for (i=1;i<=n;i++) if (paths[i]!=first) {
d=rootdir(paths[i])
wasted[d]+=sz
}
n=0
}
if (n==0) first=path
prev=h
paths[++n]=path
}
END{
if (n>0) {
cmd="stat -c%s \"" first "\" 2>/dev/null"
cmd | getline sz; close(cmd)
if (sz>0 && n>1) for (i=1;i<=n;i++) if (paths[i]!=first) {
d=rootdir(paths[i])
wasted[d]+=sz
}
}
for (d in wasted) printf "%s,%.2f GB\n", d, wasted[d]/(1024*1024*1024)
}' | sort -t,

O parâmetro LEVEL ajusta a granularidade:

LEVEL=3 → agrupamento em /home/rubens/SSD1TB

LEVEL=4 → inclui um nível adicional

LEVEL=5 → expande até subpastas específicas

8. Conclusões e próximos passos planejados

Finalizar o ranking de pastas duplicadas (Passo A).

Gerar relatório em CSV com as pastas mais críticas.

Iniciar uma etapa de limpeza controlada, com backup seguro antes de qualquer exclusão.

Estudar o awk e esses scripts para tirar essa sensação de incerteza do meu processo.