Montag, Juli 28, 2014

Notizen zur In-Memory Option in Oracle 12c

Sie ist sicher das aufsehenerregendste Feature des gerade veröffentlichten Patch-Sets 12.1.0.2: die In-Memory Option. Wenn man das Marketing fragt, macht sie Zugriffe um den Faktor X schneller - wobei man für X so ziemlich jeden Wert einsetzen kann, der einem gerade einfällt: ein passendes Szenario dafür wird sich basteln lassen. Vor ein paar Tagen habe ich meinen Download des Patches durchgeführt, aber bis zur Installation bin ich noch nicht gekommen, daher bleiben meine Aussagen mal wieder auf der Ebene der Nacherzählung der Beobachtungen anderer Naturforscher:
  • Jonathan Lewis: Analogy - liefert die von Maria Colgan formulierte Beschreibung der Grundidee der In-Memory Option in einem Satz: "In-memory columnar storage gives you bitmap indexes on OLTP systems without the usual disastrous locking side effects." Diese Zusammenfassung gefällt mir aus zwei Gründen: erstens ist sie griffig und zweitens passt sie zu einer entsprechenden Notiz die ich hier gelegentlich untergebracht hatte. Der Herr Lewis erläutert weiter:
    for each column stored you use a compression technique to pack the values for a large number of rows into a very small space, and for each stored row you can derive the rowid by arithmetic.  In highly concurrent OLTP systems there’s still room for some contention as the session journals are applied to the globally stored compressed columns (but then, private redo introduces some effects of that sort anyway); and the “indexes” have to be created dynamically as tables are initially accessed (but that’s a startup cost, it’s timing can be controlled, and it’s basically limited to a tablescan).
  • Martin KlierOracle 12c InMemory – don’t stop thinking about performance - weist darauf hin, dass die In-Memory Option nicht automatisch der berühmte fast=true Parameter ist, sondern nur unter bestimmten Voraussetzungen eine bessere Performance mit sich bringt - und belegt diese Aussage mit einem Beispiel.
  • Tim HallIn-Memory Column Store in Oracle Database 12c Release 1 (12.1.0.2) - wenn es ein neues Oracle-Feature gibt, lässt Tim Halls einführendes Beispiel in der Regel nicht lange auf sich warten. Wenn ich mir die Option gelegentlich genauer anschaue, wird dieser Artikel wahrscheinlich der Ausgangspunkt sein. Unter anderem findet sich darin folgende Taxonomie:
    The documentation claims the IM column store is good for the following.
    • Large scans that apply "=", "<", ">" and "IN" filters.
    • Queries that return a small number of columns from a table with a large number of columns.
    • Queries that join small tables to large tables.
    • Queries that aggregate data.
    • It also states it is not designed for the following.
    • Queries with complex predicates.
    • Queries that return a large number of columns.
    • Queries that return large numbers of rows.
    • Queries with multiple large table joins.
  • Maria ColganOracle Database In-Memory & the Optimizer - die (ehemalige?) "Optimizer Lady" (und aktuelle "In-Memory Lady"?) erläutert, was der Optimizer von der In-Memory Option hält und wie er damit umgeht. Der erste wichtige Hinweis dabei ist, dass es sich immer noch um den alten kostenbasierten Optimizer handelt, der nur ein paar Ergänzungen für den Umgang mit den Herausforderungen der Optimierung mit In-Memory Strategien bekommen hat. Es gibt in diesem Zusammenhang ein paar neue Hints (angefangen mit: INMEMORY) und die Analyse der Entscheidungen des Optimizers erfolgt immer noch mit dem Event 10053. Die Einführung der Option erfordert keine Erfassung zusätzlicher Statistiken und keine Veränderungen an bestehenden Applikationen - in manchen Fällen kann es sinnvoll sein, bestimmte Indizes zu entfernen, die speziell zu Reporting-Zwecken erzeugt wurden, aber generell sind bestehende Indizes weiterhin wichtig; insbesondere, wenn sie zur Sicherstellung der referentiellen Integrität oder zum punktuellen OLTP-Zugriff benötigt werden. Wenn das alles stimmt, was Frau Colgan da schreibt - und ich habe grundsätzlich keinen Grund an ihren Worten zu zweifeln -, dann hat Oracle diese Integration ausgesprochen schmerzlos bewerkstelligen können. Im Oracle Database In-Memory Blog finden sich auch noch ein paar andere Artikel, die aber eher einführenden Charakter hatten.
An dieser Stelle beende ich meine Aufzählung, obwohl es noch zahlreiche weitere (und sicher ebenfalls lesenswerte) Artikel gibt - aber für den Moment genügt mir das Material. Der nächste Schritt ist die eigenständige Erkundung - ich frage mal beim Herrn Franklin nach, ob ich mir Terror und Erebus ausleihen kann...

Freitag, Juli 25, 2014

Patchset 12.1.0.2 veröffentlicht

Seit einigen Tagen ist das Patchset 12.1.0.2 für erste OS-Versionen verfügbar und einige der interessantesten Ergänzungen wurden bereits in diversen Artikeln vorgestellt - insbesondere natürlich die In-Memory-Option (für die verschiedentlich das Acronym IMDB verwendet wurde, was möglicherweise noch für Verwirrung sorgen wird). Auf den ersten Blick sieht es so aus, als wären insbesondere die Ergänzungen, die sich potentiell auf die Datenbank-Performance auswirken, sehr umfangreich - und man kann darüber spekulieren, warum derart wichtige Änderungen in einem Patchset untergebracht wurden. Es folgt eine kleine (und ungeordnete) Link-Liste auf einige interessante Artikel zum Thema:
  • Franck Pachot - Beyond In-Memory, what's new in 12.1.0.2? Weist auf die Möglichkeit der Partitionierung für Hash Cluster, das Attribute Clustering und die JSON-Unterstützung hin.
  • Richard Foote - 12.1.0.2 Released With Cool Indexing Features (Short Memory): mit Hinweis auf Advanced Index Compression (automatische Auswahl geeigneter Kompression pro Leaf-Block), sowie auf Attribute Clustering und (natürlich) In-Memory Option hin.
  • Marco Gralike - ORACLE DATABASE 12.1.0.2.0 – NATIVE JSON SUPPORT (1): Einführung zum JSON-Support.
  • Kevin Closson: Oracle Database 12c Release 12.1.0.2 – My First Observations. Licensed Features Usage Concerns – Part I: mit der Zusammenfassung "This release is hugely important." Außerdem wird darauf hingewiesen, dass die übliche Monitoring Skripte zur Feature-Nutzung die In-Memory Column Store Verwendung nicht korrekt anzeigen. Aus seinen Bemerkungen zur Lizenzierung hat sich offenbar eine recht heftige Debatte entwickelt, deren Verlauf ich hier allerdings nicht nacherzählen möchte.
  • Julian Dontcheff - Oracle Database 12.1.0.2 New Features: Kurzes Beispiel zur In-Memory Option und Aufzählung weiterer neuer Features.
Möglicherweise erweitere ich diese Liste gelegentlich (auch angesichts der ganzen "Part 1" Hinweise), aber vielleicht ist es zweckdienlicher die neuen Features einzeln zu betrachten.

Donnerstag, Juli 24, 2014

Korrektur der Join Cardinality

Laut Randolf Geist ist es "one of the most obvious weak spots of the optimizer: A filter on a table that is joined to another table that has a skewed value distribution in the FK column(s)." Der Satz ist die Antwort auf eine Frage im OTN-Forum, die ein recht kompaktes Beispiel enthielt, bei dem der Optimizer sich hinsichtlich der Cardinality eines Joins massiv verschätzt. Hier eine vereinfachte Version des einfachen Beispiels:

-- 11.2.0.1
drop table t1;
drop table t2;

create table t1
as
select rownum id
     , 'val_' || rownum col1
  from dual
connect by level <= 20;

create table t2
as
select case when rownum > 1000 then 20
            else mod(rownum, 19) + 1
            end id
  from dual
connect by level <= 10000;  

exec dbms_stats.gather_table_stats(user, 't1')
exec dbms_stats.gather_table_stats(user, 't2')

set autot on explain
 
select count(*)
  from t1
     , t2
 where t1.id = t2.id
   and t1.col1 = 'val_19'; 
   
select count(*)
  from t1
     , t2
 where t1.id = t2.id
   and t1.col1 = 'val_20';    
   
set autot off  

Das Skript liefert folgende Ergebnisse:

SQL> select count(*)
  2    from t1
  3       , t2
  4   where t1.id = t2.id
  5     and t1.col1 = 'val_19';

  COUNT(*)
----------
        52

Ausführungsplan
----------------------------------------------------------
Plan hash value: 906334482

----------------------------------------------------------------------------
| Id  | Operation           | Name | Rows  | Bytes | Cost (%CPU)| Time     |
----------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |      |     1 |    13 |     5  (20)| 00:00:01 |
|   1 |  SORT AGGREGATE     |      |     1 |    13 |            |          |
|*  2 |   HASH JOIN         |      |   500 |  6500 |     5  (20)| 00:00:01 |
|*  3 |    TABLE ACCESS FULL| T1   |     1 |    10 |     2   (0)| 00:00:01 |
|   4 |    TABLE ACCESS FULL| T2   | 10000 | 30000 |     2   (0)| 00:00:01 |
----------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - access("T1"."ID"="T2"."ID")
   3 - filter("T1"."COL1"='val_19')

SQL> select count(*)
  2    from t1
  3       , t2
  4   where t1.id = t2.id
  5     and t1.col1 = 'val_20';

  COUNT(*)
----------
      9000

Ausführungsplan
----------------------------------------------------------
Plan hash value: 906334482

----------------------------------------------------------------------------
| Id  | Operation           | Name | Rows  | Bytes | Cost (%CPU)| Time     |
----------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |      |     1 |    13 |     5  (20)| 00:00:01 |
|   1 |  SORT AGGREGATE     |      |     1 |    13 |            |          |
|*  2 |   HASH JOIN         |      |   500 |  6500 |     5  (20)| 00:00:01 |
|*  3 |    TABLE ACCESS FULL| T1   |     1 |    10 |     2   (0)| 00:00:01 |
|   4 |    TABLE ACCESS FULL| T2   | 10000 | 30000 |     2   (0)| 00:00:01 |
----------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - access("T1"."ID"="T2"."ID")
   3 - filter("T1"."COL1"='val_20')

Obwohl sich die Anzahl der Ergebnissätze massiv unterscheidet (52 zu 9000) schätzt der Optimizer die Join-Cardinality in beiden Fällen auf 500. Dieser Wert ergibt sich aus der Verwendung der Standard-Formeln zur Berechung von Join-Selectivity und -Cardinality:

Join Selectivity = 1 / greater(num_distinct(t1.id), num_distinct(t2.id))
Join Cardinality = Join Selectivity * cardinality t1 * cardinality t2

Im Beispiel ergibt sich:

Join Selectivity = 1 / greater(20, 20) = 0,05
Join Cardinality = 0,05 * 1 * 10000 = 500

Im gegebenen Fall kann der menschliche Betrachter zwar unmittelbar erkennen, dass ein Wert für col1 einen entsprechenden Wert für id bedingt, aber der Optimizer ist in einer weniger günstigen Situation: er besitzt jeweils nur die Statistiken einer Tabelle: er weiß, dass der Zugriff auf t1 nur einen Satz liefern wird, aber zum Zeitpunkt der Optimierung kann er nicht bestimmen, welchem Satz der zweiten Tabelle diese Angabe entsprechen wird: dazu müsste er den Join bereits durchführen. Auch Histogramme helfen in diesem Fall nicht. Allerdings nennt Randolf Geist zwei Möglichkeiten mit deren Hilfe der Optimizer zu korrekten Cardinalities für den Join kommt - wobei beide Varianten wiederum recht massive Nachteile mit sich bringen. In 11.2 kann man den Hint precompute_subquery einsetzen, der zwar nicht offiziell dokumentiert ist, aber von Tanel Poder recht detailliert beschreiben wurde. Im Plan ergibt sich durch die Verwendung des Hints ein recht merkwürdiges Bild:

select count(*)
  from t2
 where t2.id in (select /*+ precompute_subquery */ t1.id
                   from t1
                  where t1.col1 = 'val_20');

  COUNT(*)
----------
      9000

Ausführungsplan
----------------------------------------------------------
Plan hash value: 3321871023

---------------------------------------------------------------------------
| Id  | Operation          | Name | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |      |     1 |     3 |     2   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE    |      |     1 |     3 |            |          |
|*  2 |   TABLE ACCESS FULL| T2   |   500 |  1500 |     2   (0)| 00:00:01 |
---------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - filter("T2"."ID"=20)

exec dbms_stats.gather_table_stats(user, 't2', method_opt => 'for all columns size 254')

--> erneute Ausführung

Ausführungsplan
----------------------------------------------------------
Plan hash value: 3321871023

---------------------------------------------------------------------------
| Id  | Operation          | Name | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |      |     1 |     3 |     2   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE    |      |     1 |     3 |            |          |
|*  2 |   TABLE ACCESS FULL| T2   |  9000 | 27000 |     2   (0)| 00:00:01 |
---------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - filter("T2"."ID"=20)

In diesem Fall wird die Subquery vor der Optimierung der Haupt-Query ausgewertet und das Ergebnis des ersten Schritts bei der Planung des zweiten Schritts zugrunde gelegt. Für die id-Spalte in t2 müssen allerdings Histogramme vorliegen, damit die ungleiche Verteilung der Werte berücksichtigt werden kann. Allerdings stellt Randolf Geist fest: "However the PRECOMPUTE_SUBQUERY is undocumented (and comes with some odd behaviour for more complex queries), hence no good solution." In Oracle 12 gibt es als Alternative dazu noch das dynamic_sampling auf Level 11, das die Join-Cardinalities durch explizite Prüfung der Datengrundlage korrrigiert:

-- 12.1.0.1
select /*+ dynamic_sampling (11) */count(*)
  from t1
     , t2
 where t1.id = t2.id
   and t1.col1 = 'val_20';

Ausführungsplan
----------------------------------------------------------
Plan hash value: 906334482

----------------------------------------------------------------------------
| Id  | Operation           | Name | Rows  | Bytes | Cost (%CPU)| Time     |
----------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |      |     1 |    13 |    10   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE     |      |     1 |    13 |            |          |
|*  2 |   HASH JOIN         |      |  9000 |   114K|    10   (0)| 00:00:01 |
|*  3 |    TABLE ACCESS FULL| T1   |     1 |    10 |     3   (0)| 00:00:01 |
|   4 |    TABLE ACCESS FULL| T2   | 10000 | 30000 |     7   (0)| 00:00:01 |
----------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - access("T1"."ID"="T2"."ID")
   3 - filter("T1"."COL1"='val_20')

Note
-----
   - dynamic statistics used: dynamic sampling (level=0)

Aber auch hier gilt:
However, this feature, too, comes at a price. At present I think one of the two row sources joined will be processed in its entirety during the sampling, so I don't think it's a viable option for large tables being joined, but I haven't done any exhaustive tests with this new feature. Since Oracle themselves don't enable it by default it looks like a bit experimental anyway.
Zu dieser Aussage passt die Beobachtung, dass ein zugehöriges SQL Trace (Event 10046) unter anderem Folgendes enthält:

SELECT /* DS_SVC */ /*+ cursor_sharing_exact dynamic_sampling(0) no_sql_tune 
  no_monitoring optimizer_features_enable(default) result_cache */ SUM(C1) 
FROM
 (SELECT /*+ qb_name("innerQuery") NO_INDEX_FFS( "T2#0")  */ 1 AS C1 FROM 
  "T2" "T2#0", "T1" "T1#1" WHERE ("T1#1"."COL1"='val_20') AND ("T1#1"."ID"=
  "T2#0"."ID")) innerQuery


call     count       cpu    elapsed       disk      query    current        rows
------- ------  -------- ---------- ---------- ---------- ----------  ----------
Parse        1      0.00       0.00          0          0          0           0
Execute      1      0.00       0.00          0          0          0           0
Fetch        1      0.00       0.00          0         22          0           1
------- ------  -------- ---------- ---------- ---------- ----------  ----------
total        3      0.00       0.00          0         22          0           1

Misses in library cache during parse: 0
Optimizer mode: ALL_ROWS
Parsing user id: 110     (recursive depth: 1)
Number of plan statistics captured: 1

Rows (1st) Rows (avg) Rows (max)  Row Source Operation
---------- ---------- ----------  ---------------------------------------------------
         1          1          1  RESULT CACHE  7cy3yc33w1z8m0wa5xpc39wqcb (cr=22 pr=0 pw=0 time=4854 us)
         1          1          1   SORT AGGREGATE (cr=22 pr=0 pw=0 time=4824 us)
      9000       9000       9000    HASH JOIN  (cr=22 pr=0 pw=0 time=5399 us cost=10 size=6500 card=500)
         1          1          1     TABLE ACCESS FULL T1 (cr=3 pr=0 pw=0 time=34 us cost=3 size=10 card=1)
     10000      10000      10000     TABLE ACCESS FULL T2 (cr=19 pr=0 pw=0 time=1922 us cost=7 size=30000 card=10000)

Unter den Row Source Operation-Angaben findet sich also noch die card=500 des ursprünglichen Plans - und das Ergebnis dieser Query wird dann zur Bestimmung der korrekten Cardinality verwendet: das scheint noch nicht unbedingt eine besonders effiziente Lösung zu sein.

Da der Artikel etwas länger geworden ist, spendiere ich ausnahmsweise ein Fazit: der Optimizer hat seine Probleme mit der Bestimmung von Join Kardinalitäten, wenn ein Filter auf einer Tabelle eingesetzt wird, die mit einer zweiten Tabelle gejoint wird, bei der für die Spalten des Foreign Key eine Ungleichverteilung vorliegt. Es gibt zwei mögliche Workarounds zur Korrektur dieser Fehleinschätzungen, aber diese Workarounds schaffen möglicherweise mehr Probleme als sie lösen. Ein expliziter Dank noch mal an Randolf Geist, der an dieser Stelle mal wieder erstaunliche Details geliefert hat.

Dienstag, Juli 22, 2014

Einfügen von Zeilen aus Excel im SQL Developer

Dass der SQL Developer ein großartiges Tool ist, dem ich womöglich verfallen wäre, wenn ich meine Seele nicht an sqlplus verkauft hätte, habe ich vielleicht gelegentlich schon mal erwähnt. Ein in entsprechender Situation sehr nützliches Detail zeigt Jeff Smith in einem aktuellen Blog-Eintrag: die Möglichkeit, Daten aus Excel per Copy&Paste im SQL Developer in eine Tabelle einzufügen. Das zumindest kann ich mit sqlplus nicht durchführen...

Sonntag, Juli 20, 2014

Implizite Typ-Konvertierung und Index-Zugriff

Das Thema wurde sicherlich schon in diversen Blog-Einträgen erläutert - ich war aber zu faul, um danach zu suchen. Außerdem wollte ich hier mal wieder ein kleines Code-Beispiel unterbringen.

Um was es geht, ist Folgendes: Oracle sieht großzügig über fehlerhafte Typ-Angaben bei den Einschränkungen von Abfragen hinweg und korrigiert sie stillschweigend via implizite Typ-Umwandlung. Grundsätzlich ist das eine nette Geste, aber natürlich bringt das Verhalten gelegentlich auch Probleme mit sich. Unter anderem gibt es Situationen, in denen eine solche implizite Konvertierung einen Index-Zugriff ausschalten kann. Dazu ein kleines Beispiel, das nur vier sehr einfache Fälle berücksichtigt: die Verwendung einer numerischen bzw. einer String-Bedingung für eine numerische bzw. eine String-Spalte:

-- 11.2.0.1
drop table t;

create table t
as 
select rownum num_col
     , to_char(rownum) char_col
  from dual 
connect by level <= 100000;

exec dbms_stats.gather_table_stats(user, 'T')

create unique index t_num_col_idx on t(num_col);

create unique index t_char_col_idx on t(char_col);

set autot trace

-- number column and number condition
select count(*) 
  from t 
 where num_col = 1;

------------------------------------------------------------------------------------
| Id  | Operation          | Name          | Rows  | Bytes | Cost (%CPU)| Time     |
------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |               |     1 |     5 |     1   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE    |               |     1 |     5 |            |          |
|*  2 |   INDEX UNIQUE SCAN| T_NUM_COL_IDX |     1 |     5 |     1   (0)| 00:00:01 |
------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - access("NUM_COL"=1)

-- number column and string condition 
select count(*) 
  from t 
 where num_col = '1';

------------------------------------------------------------------------------------
| Id  | Operation          | Name          | Rows  | Bytes | Cost (%CPU)| Time     |
------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |               |     1 |     5 |     1   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE    |               |     1 |     5 |            |          |
|*  2 |   INDEX UNIQUE SCAN| T_NUM_COL_IDX |     1 |     5 |     1   (0)| 00:00:01 |
------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - access("NUM_COL"=1)

-- string column and number condition 
select count(*) 
  from t 
 where char_col = 1;

---------------------------------------------------------------------------
| Id  | Operation          | Name | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |      |     1 |     6 |    11   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE    |      |     1 |     6 |            |          |
|*  2 |   TABLE ACCESS FULL| T    |     1 |     6 |    11   (0)| 00:00:01 |
---------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - filter(TO_NUMBER("CHAR_COL")=1)

-- string column and string condition 
select count(*) 
  from t 
 where char_col = '1';

-------------------------------------------------------------------------------------
| Id  | Operation          | Name           | Rows  | Bytes | Cost (%CPU)| Time     |
-------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |                |     1 |     6 |     1   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE    |                |     1 |     6 |            |          |
|*  2 |   INDEX UNIQUE SCAN| T_CHAR_COL_IDX |     1 |     6 |     1   (0)| 00:00:01 |
-------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - access("CHAR_COL"='1')

Das Verhalten ist demnach in den gegebenen Fällen recht übersichtlich:
  • number column and number condition: keine Typumwandlung.
  • number column and string condition: der String in der Bedingung wird in einen numerischen Wert umgewandelt, wobei diese Umwandlung im Plan nicht sichtbar ist. In diesem Fall ist die Umwandlung ungefährlich, weil der Vergleich nur für numerische Werte sinnvoll ist: würde als Vergleichswert ein nicht-numerischer Wert erscheinen, so ergäbe sich ein Parse-Fehler (ORA-01722: Ungültige Zahl).
  • string column and number condition: dies ist der problematische Fall: der Zugriff erfolgt über Full Table Scan, weil aufgrund des Vergleichs ein Filter-Prädikat (TO_NUMBER("CHAR_COL")=1) eingeführt werden muss.
  • string column and string condition: keine Typumwandlung.
Hier angekommen fällt mir auf, dass ich keine Lust habe, die komplette Matrix möglicher Konvertierungen auf ihre Behandlung der impliziten Konvertierung hin zu untersuchen, aber das hat Maxym Kharchenko dankenswerterweise gelegentlich bereits erledigt. Beim Blick auf diese Matrix fällt es mir schwer, eine allgemeingültige Regel zu formulieren, die das Verhalten allgemein beschreibt - aber für meine Zwecke genügt das Fazit, dass numerische Spalten, die als String-Typen abgelegt werden, Probleme hervorrufen können, was wieder ein Argument dafür wäre, Attribute in geeigneten Typen abzubilden - wenn man dafür noch Argumente bräuchte.